diff --git a/internal/config/validate.go b/internal/config/validate.go index 31235982..9d0312a9 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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 { diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx index f654742b..08b6f3d9 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTree.tsx @@ -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) { diff --git a/webui/src/components/ScheduleFormItem.tsx b/webui/src/components/ScheduleFormItem.tsx index 7afab343..d461b57d 100644 --- a/webui/src/components/ScheduleFormItem.tsx +++ b/webui/src/components/ScheduleFormItem.tsx @@ -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"; diff --git a/webui/src/components/StatsPanel.tsx b/webui/src/components/StatsPanel.tsx new file mode 100644 index 00000000..b94efb04 --- /dev/null +++ b/webui/src/components/StatsPanel.tsx @@ -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([]); + 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 ( + + ); + } + + 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 ( + <> + + + 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} + /> + + formatDate(v as number), + min: minTime, + max: maxTime, + }, + ]} + series={[ + { + dataKey: "compressionRatio", + label: "Compression Ratio", + }, + ]} + height={300} + dataset={dataset} + /> + + + formatDate(v as number), + min: minTime, + max: maxTime, + }, + ]} + series={[ + { + dataKey: "snapshotCount", + label: "Snapshot Count", + }, + ]} + height={300} + dataset={dataset} + /> + + formatDate(v as number), + min: minTime, + max: maxTime, + }, + ]} + series={[ + { + dataKey: "totalBlobCount", + label: "Blob Count", + }, + ]} + height={300} + dataset={dataset} + /> + + + + ); +}; + +export default StatsPanel; diff --git a/webui/src/views/AddPlanModal.tsx b/webui/src/views/AddPlanModal.tsx index e1da9ef8..438fc183 100644 --- a/webui/src/views/AddPlanModal.tsx +++ b/webui/src/views/AddPlanModal.tsx @@ -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); diff --git a/webui/src/views/RepoView.tsx b/webui/src/views/RepoView.tsx index 0470528d..4570c9ae 100644 --- a/webui/src/views/RepoView.tsx +++ b/webui/src/views/RepoView.tsx @@ -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: ( - <> + Loading...}> - + ), destroyInactiveTabPane: true, }, @@ -174,156 +153,3 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { ); }; - -const StatsPanel = ({ repoId }: { repoId: string }) => { - const [operations, setOperations] = useState([]); - 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 ( - - ); - } - - 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 ( - <> - - - 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} - /> - - formatDate(v as number), - min: minTime, - max: maxTime, - }, - ]} - series={[ - { - dataKey: "compressionRatio", - label: "Compression Ratio", - }, - ]} - height={300} - dataset={dataset} - /> - - - formatDate(v as number), - min: minTime, - max: maxTime, - }, - ]} - series={[ - { - dataKey: "snapshotCount", - label: "Snapshot Count", - }, - ]} - height={300} - dataset={dataset} - /> - - formatDate(v as number), - min: minTime, - max: maxTime, - }, - ]} - series={[ - { - dataKey: "totalBlobCount", - label: "Blob Count", - }, - ]} - height={300} - dataset={dataset} - /> - - - - ); -};