mirror of
https://github.com/garethgeorge/backrest.git
synced 2025-12-14 09:35:41 +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))
|
err = multierror.Append(err, fmt.Errorf("id %q invalid: %w", plan.Id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
if e := protoutil.ValidateSchedule(plan.Schedule); e != nil {
|
if plan.Schedule != nil {
|
||||||
err = multierror.Append(err, fmt.Errorf("schedule: %w", e))
|
if e := protoutil.ValidateSchedule(plan.Schedule); e != nil {
|
||||||
|
err = multierror.Append(err, fmt.Errorf("schedule: %w", e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx, p := range plan.Paths {
|
for idx, p := range plan.Paths {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const OperationTree = ({
|
|||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
showIcon
|
showIcon
|
||||||
defaultExpandedKeys={backups
|
defaultExpandedKeys={backups
|
||||||
.slice(0, Math.min(5, backups.length))
|
.slice(0, Math.min(10, backups.length))
|
||||||
.map((b) => b.id!)}
|
.map((b) => b.id!)}
|
||||||
onSelect={(keys, info) => {
|
onSelect={(keys, info) => {
|
||||||
if (info.selectedNodes.length === 0) return;
|
if (info.selectedNodes.length === 0) return;
|
||||||
@@ -250,7 +250,7 @@ const buildTreePlan = (operations: BackupInfo[]): OpTreeNode[] => {
|
|||||||
if (entries.length === 1) {
|
if (entries.length === 1) {
|
||||||
return entries[0].children!;
|
return entries[0].children!;
|
||||||
}
|
}
|
||||||
entries.sort(sortByKey);
|
entries.sort(sortByKeyReverse);
|
||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +305,10 @@ const sortByKey = (a: OpTreeNode, b: OpTreeNode) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortByKeyReverse = (a: OpTreeNode, b: OpTreeNode) => {
|
||||||
|
return -sortByKey(a, b);
|
||||||
|
};
|
||||||
|
|
||||||
const BackupView = ({ backup }: { backup?: BackupInfo }) => {
|
const BackupView = ({ backup }: { backup?: BackupInfo }) => {
|
||||||
const alertApi = useAlertApi();
|
const alertApi = useAlertApi();
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export const ScheduleFormItem = ({ name }: { name: string[] }) => {
|
|||||||
const retention = Form.useWatch(name, { form, preserve: true }) as any;
|
const retention = Form.useWatch(name, { form, preserve: true }) as any;
|
||||||
|
|
||||||
const determineMode = () => {
|
const determineMode = () => {
|
||||||
if (!retention || retention.disabled) {
|
if (!retention) {
|
||||||
|
return "";
|
||||||
|
} else if (retention.disabled) {
|
||||||
return "disabled";
|
return "disabled";
|
||||||
} else if (retention.maxFrequencyDays) {
|
} else if (retention.maxFrequencyDays) {
|
||||||
return "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");
|
throw new Error("template not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configCopy = config.clone();
|
||||||
|
|
||||||
// Remove the plan from the config
|
// 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) {
|
if (idx === -1) {
|
||||||
throw new Error("failed to update config, plan to delete not found");
|
throw new Error("failed to update config, plan to delete not found");
|
||||||
}
|
}
|
||||||
|
configCopy.plans.splice(idx, 1);
|
||||||
config.plans.splice(idx, 1);
|
|
||||||
|
|
||||||
// Update config and notify success.
|
// Update config and notify success.
|
||||||
setConfig(await backrestService.setConfig(config));
|
setConfig(await backrestService.setConfig(configCopy));
|
||||||
showModal(null);
|
showModal(null);
|
||||||
|
|
||||||
alertsApi.success(
|
alertsApi.success(
|
||||||
@@ -90,19 +91,21 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
|
|||||||
delete plan.retention;
|
delete plan.retention;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configCopy = config.clone();
|
||||||
|
|
||||||
// Merge the new plan (or update) into the config
|
// Merge the new plan (or update) into the config
|
||||||
if (template) {
|
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) {
|
if (idx === -1) {
|
||||||
throw new Error("failed to update plan, not found");
|
throw new Error("failed to update plan, not found");
|
||||||
}
|
}
|
||||||
config.plans[idx] = plan;
|
configCopy.plans[idx] = plan;
|
||||||
} else {
|
} else {
|
||||||
config.plans.push(plan);
|
configCopy.plans.push(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config and notify success.
|
// Update config and notify success.
|
||||||
setConfig(await backrestService.setConfig(config));
|
setConfig(await backrestService.setConfig(configCopy));
|
||||||
showModal(null);
|
showModal(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alertsApi.error("Operation failed: " + e.message, 15);
|
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 { Repo } from "../../gen/ts/v1/config_pb";
|
||||||
import {
|
import { Flex, Tabs, Tooltip, Typography, Button } from "antd";
|
||||||
Col,
|
|
||||||
Empty,
|
|
||||||
Flex,
|
|
||||||
Row,
|
|
||||||
Spin,
|
|
||||||
TabsProps,
|
|
||||||
Tabs,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
} from "antd";
|
|
||||||
import { OperationList } from "../components/OperationList";
|
import { OperationList } from "../components/OperationList";
|
||||||
import { OperationTree } from "../components/OperationTree";
|
import { OperationTree } from "../components/OperationTree";
|
||||||
import { MAX_OPERATION_HISTORY, STATS_OPERATION_HISTORY } from "../constants";
|
import { MAX_OPERATION_HISTORY, STATS_OPERATION_HISTORY } from "../constants";
|
||||||
import { GetOperationsRequest, OpSelector } from "../../gen/ts/v1/service_pb";
|
import { GetOperationsRequest, OpSelector } from "../../gen/ts/v1/service_pb";
|
||||||
import {
|
import { shouldHideStatus } from "../state/oplog";
|
||||||
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 { backrestService } from "../api";
|
import { backrestService } from "../api";
|
||||||
import { StringValue } from "@bufbuild/protobuf";
|
import { StringValue } from "@bufbuild/protobuf";
|
||||||
import { SpinButton } from "../components/SpinButton";
|
import { SpinButton } from "../components/SpinButton";
|
||||||
import { useConfig } from "../components/ConfigProvider";
|
import { useConfig } from "../components/ConfigProvider";
|
||||||
import { useAlertApi } from "../components/Alerts";
|
import { useAlertApi } from "../components/Alerts";
|
||||||
import { LineChart } from "@mui/x-charts";
|
|
||||||
import { useShowModal } from "../components/ModalManager";
|
import { useShowModal } from "../components/ModalManager";
|
||||||
|
|
||||||
|
const StatsPanel = React.lazy(() => import("../components/StatsPanel"));
|
||||||
|
|
||||||
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||||
const [config, setConfig] = useConfig();
|
const [config, setConfig] = useConfig();
|
||||||
const showModal = useShowModal();
|
const showModal = useShowModal();
|
||||||
@@ -127,9 +106,9 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
|||||||
key: "3",
|
key: "3",
|
||||||
label: "Stats",
|
label: "Stats",
|
||||||
children: (
|
children: (
|
||||||
<>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<StatsPanel repoId={repo.id!} />
|
<StatsPanel repoId={repo.id!} />
|
||||||
</>
|
</Suspense>
|
||||||
),
|
),
|
||||||
destroyInactiveTabPane: true,
|
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