mirror of
https://github.com/garethgeorge/backrest.git
synced 2025-12-13 17:25:38 +00:00
fix: schedule view bug
This commit is contained in:
@@ -105,8 +105,10 @@ func validatePlan(plan *v1.Plan, repos map[string]*v1.Repo) error {
|
||||
err = multierror.Append(err, fmt.Errorf("id %q invalid: %w", plan.Id, e))
|
||||
}
|
||||
|
||||
if e := protoutil.ValidateSchedule(plan.Schedule); e != nil {
|
||||
err = multierror.Append(err, fmt.Errorf("schedule: %w", e))
|
||||
if plan.Schedule != nil {
|
||||
if e := protoutil.ValidateSchedule(plan.Schedule); e != nil {
|
||||
err = multierror.Append(err, fmt.Errorf("schedule: %w", e))
|
||||
}
|
||||
}
|
||||
|
||||
for idx, p := range plan.Paths {
|
||||
|
||||
@@ -123,7 +123,7 @@ export const OperationTree = ({
|
||||
treeData={treeData}
|
||||
showIcon
|
||||
defaultExpandedKeys={backups
|
||||
.slice(0, Math.min(5, backups.length))
|
||||
.slice(0, Math.min(10, backups.length))
|
||||
.map((b) => b.id!)}
|
||||
onSelect={(keys, info) => {
|
||||
if (info.selectedNodes.length === 0) return;
|
||||
@@ -250,7 +250,7 @@ const buildTreePlan = (operations: BackupInfo[]): OpTreeNode[] => {
|
||||
if (entries.length === 1) {
|
||||
return entries[0].children!;
|
||||
}
|
||||
entries.sort(sortByKey);
|
||||
entries.sort(sortByKeyReverse);
|
||||
return entries;
|
||||
};
|
||||
|
||||
@@ -305,6 +305,10 @@ const sortByKey = (a: OpTreeNode, b: OpTreeNode) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortByKeyReverse = (a: OpTreeNode, b: OpTreeNode) => {
|
||||
return -sortByKey(a, b);
|
||||
};
|
||||
|
||||
const BackupView = ({ backup }: { backup?: BackupInfo }) => {
|
||||
const alertApi = useAlertApi();
|
||||
if (!backup) {
|
||||
|
||||
@@ -17,7 +17,9 @@ export const ScheduleFormItem = ({ name }: { name: string[] }) => {
|
||||
const retention = Form.useWatch(name, { form, preserve: true }) as any;
|
||||
|
||||
const determineMode = () => {
|
||||
if (!retention || retention.disabled) {
|
||||
if (!retention) {
|
||||
return "";
|
||||
} else if (retention.disabled) {
|
||||
return "disabled";
|
||||
} else if (retention.maxFrequencyDays) {
|
||||
return "maxFrequencyDays";
|
||||
|
||||
171
webui/src/components/StatsPanel.tsx
Normal file
171
webui/src/components/StatsPanel.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LineChart } from "@mui/x-charts/LineChart";
|
||||
import { formatBytes, formatDate } from "../lib/formatting";
|
||||
import { Col, Empty, Row } from "antd";
|
||||
import {
|
||||
Operation,
|
||||
OperationStats,
|
||||
OperationStatus,
|
||||
} from "../../gen/ts/v1/operations_pb";
|
||||
import { useAlertApi } from "./Alerts";
|
||||
import { BackupInfoCollector, getOperations } from "../state/oplog";
|
||||
import { MAX_OPERATION_HISTORY } from "../constants";
|
||||
import { GetOperationsRequest, OpSelector } from "../../gen/ts/v1/service_pb";
|
||||
|
||||
const StatsPanel = ({ repoId }: { repoId: string }) => {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const alertApi = useAlertApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCollector = new BackupInfoCollector((op) => {
|
||||
return (
|
||||
op.status === OperationStatus.STATUS_SUCCESS &&
|
||||
op.op.case === "operationStats" &&
|
||||
!!op.op.value.stats
|
||||
);
|
||||
});
|
||||
|
||||
getOperations(
|
||||
new GetOperationsRequest({
|
||||
selector: new OpSelector({
|
||||
repoId: repoId,
|
||||
}),
|
||||
lastN: BigInt(MAX_OPERATION_HISTORY),
|
||||
})
|
||||
)
|
||||
.then((ops) => {
|
||||
backupCollector.bulkAddOperations(ops);
|
||||
|
||||
const operations = backupCollector
|
||||
.getAll()
|
||||
.flatMap((b) => b.operations);
|
||||
operations.sort((a, b) => {
|
||||
return Number(b.unixTimeEndMs - a.unixTimeEndMs);
|
||||
});
|
||||
setOperations(operations);
|
||||
})
|
||||
.catch((e) => {
|
||||
alertApi!.error("Failed to fetch operations: " + e.message);
|
||||
});
|
||||
}, [repoId]);
|
||||
|
||||
if (operations.length === 0) {
|
||||
return (
|
||||
<Empty description="No stats available. Have you run a prune operation yet?" />
|
||||
);
|
||||
}
|
||||
|
||||
const dataset: {
|
||||
time: number;
|
||||
totalSizeMb: number;
|
||||
compressionRatio: number;
|
||||
snapshotCount: number;
|
||||
totalBlobCount: number;
|
||||
}[] = operations.map((op) => {
|
||||
const stats = (op.op.value! as OperationStats).stats!;
|
||||
return {
|
||||
time: Number(op.unixTimeEndMs!),
|
||||
totalSizeMb: Number(stats.totalSize) / 1000000,
|
||||
compressionRatio: Number(stats.compressionRatio),
|
||||
snapshotCount: Number(stats.snapshotCount),
|
||||
totalBlobCount: Number(stats.totalBlobCount),
|
||||
};
|
||||
});
|
||||
|
||||
const minTime = Math.min(...dataset.map((d) => d.time));
|
||||
const maxTime = Math.max(...dataset.map((d) => d.time));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalSizeMb",
|
||||
label: "Total Size",
|
||||
valueFormatter: (v: any) =>
|
||||
formatBytes((v * 1000000) as number),
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
width={600}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "compressionRatio",
|
||||
label: "Compression Ratio",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "snapshotCount",
|
||||
label: "Snapshot Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalBlobCount",
|
||||
label: "Blob Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsPanel;
|
||||
@@ -54,16 +54,17 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
|
||||
throw new Error("template not found");
|
||||
}
|
||||
|
||||
const configCopy = config.clone();
|
||||
|
||||
// Remove the plan from the config
|
||||
const idx = config.plans.findIndex((r) => r.id === template.id);
|
||||
const idx = configCopy.plans.findIndex((r) => r.id === template.id);
|
||||
if (idx === -1) {
|
||||
throw new Error("failed to update config, plan to delete not found");
|
||||
}
|
||||
|
||||
config.plans.splice(idx, 1);
|
||||
configCopy.plans.splice(idx, 1);
|
||||
|
||||
// Update config and notify success.
|
||||
setConfig(await backrestService.setConfig(config));
|
||||
setConfig(await backrestService.setConfig(configCopy));
|
||||
showModal(null);
|
||||
|
||||
alertsApi.success(
|
||||
@@ -90,19 +91,21 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
|
||||
delete plan.retention;
|
||||
}
|
||||
|
||||
const configCopy = config.clone();
|
||||
|
||||
// Merge the new plan (or update) into the config
|
||||
if (template) {
|
||||
const idx = config.plans.findIndex((r) => r.id === template.id);
|
||||
const idx = configCopy.plans.findIndex((r) => r.id === template.id);
|
||||
if (idx === -1) {
|
||||
throw new Error("failed to update plan, not found");
|
||||
}
|
||||
config.plans[idx] = plan;
|
||||
configCopy.plans[idx] = plan;
|
||||
} else {
|
||||
config.plans.push(plan);
|
||||
configCopy.plans.push(plan);
|
||||
}
|
||||
|
||||
// Update config and notify success.
|
||||
setConfig(await backrestService.setConfig(config));
|
||||
setConfig(await backrestService.setConfig(configCopy));
|
||||
showModal(null);
|
||||
} catch (e: any) {
|
||||
alertsApi.error("Operation failed: " + e.message, 15);
|
||||
|
||||
@@ -1,41 +1,20 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import React, { Suspense, useContext, useEffect, useState } from "react";
|
||||
import { Repo } from "../../gen/ts/v1/config_pb";
|
||||
import {
|
||||
Col,
|
||||
Empty,
|
||||
Flex,
|
||||
Row,
|
||||
Spin,
|
||||
TabsProps,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Button,
|
||||
} from "antd";
|
||||
import { Flex, Tabs, Tooltip, Typography, Button } from "antd";
|
||||
import { OperationList } from "../components/OperationList";
|
||||
import { OperationTree } from "../components/OperationTree";
|
||||
import { MAX_OPERATION_HISTORY, STATS_OPERATION_HISTORY } from "../constants";
|
||||
import { GetOperationsRequest, OpSelector } from "../../gen/ts/v1/service_pb";
|
||||
import {
|
||||
BackupInfo,
|
||||
BackupInfoCollector,
|
||||
getOperations,
|
||||
shouldHideStatus,
|
||||
} from "../state/oplog";
|
||||
import { formatBytes, formatDate, formatTime } from "../lib/formatting";
|
||||
import {
|
||||
Operation,
|
||||
OperationStats,
|
||||
OperationStatus,
|
||||
} from "../../gen/ts/v1/operations_pb";
|
||||
import { shouldHideStatus } from "../state/oplog";
|
||||
import { backrestService } from "../api";
|
||||
import { StringValue } from "@bufbuild/protobuf";
|
||||
import { SpinButton } from "../components/SpinButton";
|
||||
import { useConfig } from "../components/ConfigProvider";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { LineChart } from "@mui/x-charts";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
|
||||
const StatsPanel = React.lazy(() => import("../components/StatsPanel"));
|
||||
|
||||
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
const [config, setConfig] = useConfig();
|
||||
const showModal = useShowModal();
|
||||
@@ -127,9 +106,9 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
key: "3",
|
||||
label: "Stats",
|
||||
children: (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<StatsPanel repoId={repo.id!} />
|
||||
</>
|
||||
</Suspense>
|
||||
),
|
||||
destroyInactiveTabPane: true,
|
||||
},
|
||||
@@ -174,156 +153,3 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StatsPanel = ({ repoId }: { repoId: string }) => {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const alertApi = useAlertApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCollector = new BackupInfoCollector((op) => {
|
||||
return (
|
||||
op.status === OperationStatus.STATUS_SUCCESS &&
|
||||
op.op.case === "operationStats" &&
|
||||
!!op.op.value.stats
|
||||
);
|
||||
});
|
||||
|
||||
getOperations(
|
||||
new GetOperationsRequest({
|
||||
repoId: repoId,
|
||||
lastN: BigInt(MAX_OPERATION_HISTORY),
|
||||
})
|
||||
)
|
||||
.then((ops) => {
|
||||
backupCollector.bulkAddOperations(ops);
|
||||
|
||||
const operations = backupCollector
|
||||
.getAll()
|
||||
.flatMap((b) => b.operations);
|
||||
operations.sort((a, b) => {
|
||||
return Number(b.unixTimeEndMs - a.unixTimeEndMs);
|
||||
});
|
||||
setOperations(operations);
|
||||
})
|
||||
.catch((e) => {
|
||||
alertApi!.error("Failed to fetch operations: " + e.message);
|
||||
});
|
||||
}, [repoId]);
|
||||
|
||||
if (operations.length === 0) {
|
||||
return (
|
||||
<Empty description="No stats available. Have you run a prune operation yet?" />
|
||||
);
|
||||
}
|
||||
|
||||
const dataset: {
|
||||
time: number;
|
||||
totalSizeMb: number;
|
||||
compressionRatio: number;
|
||||
snapshotCount: number;
|
||||
totalBlobCount: number;
|
||||
}[] = operations.map((op) => {
|
||||
const stats = (op.op.value! as OperationStats).stats!;
|
||||
return {
|
||||
time: Number(op.unixTimeEndMs!),
|
||||
totalSizeMb: Number(stats.totalSize) / 1000000,
|
||||
compressionRatio: Number(stats.compressionRatio),
|
||||
snapshotCount: Number(stats.snapshotCount),
|
||||
totalBlobCount: Number(stats.totalBlobCount),
|
||||
};
|
||||
});
|
||||
|
||||
const minTime = Math.min(...dataset.map((d) => d.time));
|
||||
const maxTime = Math.max(...dataset.map((d) => d.time));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalSizeMb",
|
||||
label: "Total Size",
|
||||
valueFormatter: (v: any) =>
|
||||
formatBytes((v * 1000000) as number),
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "compressionRatio",
|
||||
label: "Compression Ratio",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "snapshotCount",
|
||||
label: "Snapshot Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalBlobCount",
|
||||
label: "Blob Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user