mirror of
https://github.com/garethgeorge/backrest.git
synced 2025-12-16 18:45:36 +00:00
feat: show snapshots in sidenav
This commit is contained in:
@@ -199,6 +199,8 @@ func (s *Server) GetOperations(ctx context.Context, req *v1.GetOperationsRequest
|
||||
ops, err = s.oplog.GetByPlan(req.PlanId, filter)
|
||||
} else if req.RepoId != "" {
|
||||
ops, err = s.oplog.GetByRepo(req.RepoId, filter)
|
||||
} else {
|
||||
ops, err = s.oplog.GetAll(filter)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get operations: %w", err)
|
||||
@@ -209,7 +211,7 @@ func (s *Server) GetOperations(ctx context.Context, req *v1.GetOperationsRequest
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s* Server) Backup(ctx context.Context, req *types.StringValue) (*emptypb.Empty, error) {
|
||||
func (s *Server) Backup(ctx context.Context, req *types.StringValue) (*emptypb.Empty, error) {
|
||||
plan, err := s.orchestrator.GetPlan(req.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get plan %q: %w", req.Value, err)
|
||||
@@ -233,4 +235,3 @@ func (s *Server) PathAutocomplete(ctx context.Context, path *types.StringValue)
|
||||
|
||||
return &types.StringList{Values: paths}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,24 @@ func (o *OpLog) GetByPlan(planId string, filter Filter) ([]*v1.Operation, error)
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (o *OpLog) GetAll(filter Filter) ([]*v1.Operation, error) {
|
||||
var ops []*v1.Operation
|
||||
if err := o.db.View(func(tx *bolt.Tx) error {
|
||||
c := tx.Bucket(OpLogBucket).Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
op := &v1.Operation{}
|
||||
if err := proto.Unmarshal(v, op); err != nil {
|
||||
return fmt.Errorf("error unmarshalling operation: %w", err)
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (o *OpLog) Subscribe(callback *func(EventType, *v1.Operation)) {
|
||||
o.subscribersMu.Lock()
|
||||
defer o.subscribersMu.Unlock()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:9090",
|
||||
"target": "http://localhost:9898",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic.pb";
|
||||
import { EOperation } from "../state/oplog";
|
||||
import { SnapshotBrowser } from "./SnapshotBrowser";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
normalizeSnapshotId,
|
||||
} from "../lib/formatting";
|
||||
|
||||
export const OperationList = ({
|
||||
operations,
|
||||
@@ -47,24 +53,10 @@ export const OperationList = ({
|
||||
return Object.values(groups);
|
||||
};
|
||||
|
||||
// snapshotKey is a heuristic that tries to find a snapshot ID to group the operation by,
|
||||
// if one can not be found the operation ID is the key.
|
||||
const snapshotKey = (op: EOperation) => {
|
||||
if (
|
||||
op.operationBackup &&
|
||||
op.operationBackup.lastStatus &&
|
||||
op.operationBackup.lastStatus.summary
|
||||
) {
|
||||
return normalizeSnapshotId(
|
||||
op.operationBackup.lastStatus.summary.snapshotId!
|
||||
);
|
||||
} else if (op.operationIndexSnapshot) {
|
||||
return normalizeSnapshotId(op.operationIndexSnapshot.snapshot!.id!);
|
||||
}
|
||||
return op.id!;
|
||||
};
|
||||
|
||||
const groupedItems = groupBy(operations, snapshotKey);
|
||||
// groups items by snapshotID if one can be identified, otherwise by operation ID.
|
||||
const groupedItems = groupBy(operations, (op: EOperation) => {
|
||||
return getSnapshotId(op) || op.id!;
|
||||
});
|
||||
groupedItems.sort((a, b) => {
|
||||
return b[0].parsedTime - a[0].parsedTime;
|
||||
});
|
||||
@@ -334,46 +326,14 @@ const BackupOperationStatus = ({
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number | string) => {
|
||||
if (!bytes) {
|
||||
return 0;
|
||||
const getSnapshotId = (op: EOperation): string | null => {
|
||||
if (op.operationBackup) {
|
||||
const ob = op.operationBackup;
|
||||
if (ob.lastStatus && ob.lastStatus.summary) {
|
||||
return normalizeSnapshotId(ob.lastStatus.summary.snapshotId!);
|
||||
}
|
||||
if (typeof bytes === "string") {
|
||||
bytes = parseInt(bytes);
|
||||
} else if (op.operationIndexSnapshot) {
|
||||
return normalizeSnapshotId(op.operationIndexSnapshot.snapshot!.id!);
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let unit = 0;
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${Math.round(bytes * 100) / 100} ${units[unit]}`;
|
||||
};
|
||||
|
||||
const timezoneOffsetMs = new Date().getTimezoneOffset() * 60 * 1000;
|
||||
const formatTime = (time: number | string) => {
|
||||
if (typeof time === "string") {
|
||||
time = parseInt(time);
|
||||
}
|
||||
const d = new Date();
|
||||
d.setTime(time - timezoneOffsetMs);
|
||||
const isoStr = d.toISOString();
|
||||
return `${isoStr.substring(0, 10)} ${d.getUTCHours()}h${d.getUTCMinutes()}m`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.floor(ms / 100) / 10;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours === 0 && minutes === 0) {
|
||||
return `${seconds % 60}s`;
|
||||
} else if (hours === 0) {
|
||||
return `${minutes}m${seconds % 60}s`;
|
||||
}
|
||||
return `${hours}h${minutes % 60}m${seconds % 60}s`;
|
||||
};
|
||||
|
||||
const normalizeSnapshotId = (id: string) => {
|
||||
return id.substring(0, 8);
|
||||
return null;
|
||||
};
|
||||
|
||||
43
webui/src/lib/formatting.ts
Normal file
43
webui/src/lib/formatting.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const formatBytes = (bytes?: number | string) => {
|
||||
if (!bytes) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof bytes === "string") {
|
||||
bytes = parseInt(bytes);
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let unit = 0;
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${Math.round(bytes * 100) / 100} ${units[unit]}`;
|
||||
};
|
||||
|
||||
const timezoneOffsetMs = new Date().getTimezoneOffset() * 60 * 1000;
|
||||
export const formatTime = (time: number | string) => {
|
||||
if (typeof time === "string") {
|
||||
time = parseInt(time);
|
||||
}
|
||||
const d = new Date();
|
||||
d.setTime(time - timezoneOffsetMs);
|
||||
const isoStr = d.toISOString();
|
||||
return `${isoStr.substring(0, 10)} ${d.getUTCHours()}h${d.getUTCMinutes()}m`;
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number) => {
|
||||
const seconds = Math.floor(ms / 100) / 10;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours === 0 && minutes === 0) {
|
||||
return `${seconds % 60}s`;
|
||||
} else if (hours === 0) {
|
||||
return `${minutes}m${seconds % 60}s`;
|
||||
}
|
||||
return `${hours}h${minutes % 60}m${seconds % 60}s`;
|
||||
};
|
||||
|
||||
export const normalizeSnapshotId = (id: string) => {
|
||||
return id.substring(0, 8);
|
||||
};
|
||||
@@ -44,7 +44,7 @@ export const getOperations = async ({
|
||||
planId,
|
||||
repoId,
|
||||
lastN,
|
||||
}: GetOperationsRequest): Promise<Operation[]> => {
|
||||
}: GetOperationsRequest): Promise<EOperation[]> => {
|
||||
const opList = await ResticUI.GetOperations(
|
||||
{
|
||||
planId,
|
||||
@@ -55,7 +55,7 @@ export const getOperations = async ({
|
||||
pathPrefix: "/api",
|
||||
}
|
||||
);
|
||||
return opList.operations || [];
|
||||
return (opList.operations || []).map(toEop);
|
||||
};
|
||||
|
||||
export const subscribeToOperations = (
|
||||
@@ -85,7 +85,7 @@ export const buildOperationListListener = (
|
||||
let operations: EOperation[] = [];
|
||||
|
||||
(async () => {
|
||||
let opsFromServer = (await getOperations(req)).map(toEop);
|
||||
let opsFromServer = await getOperations(req);
|
||||
operations = opsFromServer.filter(
|
||||
(o) => !operations.find((op) => op.parsedId === o.parsedId)
|
||||
);
|
||||
@@ -123,7 +123,7 @@ export const buildOperationListListener = (
|
||||
};
|
||||
};
|
||||
|
||||
const toEop = (op: Operation): EOperation => {
|
||||
export const toEop = (op: Operation): EOperation => {
|
||||
const time =
|
||||
op.operationIndexSnapshot?.snapshot?.unixTimeMs || op.unixTimeStartMs;
|
||||
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
ScheduleOutlined,
|
||||
DatabaseOutlined,
|
||||
PlusOutlined,
|
||||
CheckCircleOutlined,
|
||||
PaperClipOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Layout, Menu, Spin, theme } from "antd";
|
||||
import { Button, Layout, List, Menu, Modal, Spin, theme } from "antd";
|
||||
import { configState, fetchConfig } from "../state/config";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { Config, Plan } from "../../gen/ts/v1/config.pb";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
import { AddPlanModal } from "./AddPlanModel";
|
||||
import { AddRepoModel } from "./AddRepoModel";
|
||||
import { MainContentArea, useSetContent } from "./MainContentArea";
|
||||
import { PlanView } from "./PlanView";
|
||||
import {
|
||||
EOperation,
|
||||
buildOperationListListener,
|
||||
getOperations,
|
||||
subscribeToOperations,
|
||||
toEop,
|
||||
unsubscribeFromOperations,
|
||||
} from "../state/oplog";
|
||||
import { formatTime } from "../lib/formatting";
|
||||
import { SnapshotBrowser } from "../components/SnapshotBrowser";
|
||||
import { OperationRow } from "../components/OperationList";
|
||||
import {
|
||||
Operation,
|
||||
OperationEvent,
|
||||
OperationEventType,
|
||||
} from "../../gen/ts/v1/operations.pb";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -63,7 +80,7 @@ export const App: React.FC = () => {
|
||||
</h1>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider width={200} style={{ background: colorBgContainer }}>
|
||||
<Sider width={300} style={{ background: colorBgContainer }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultSelectedKeys={["1"]}
|
||||
@@ -81,12 +98,66 @@ export const App: React.FC = () => {
|
||||
const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
const showModal = useShowModal();
|
||||
const setContent = useSetContent();
|
||||
const [snapshotsByPlan, setSnapshotsByPlan] = useState<{
|
||||
[planId: string]: EOperation[];
|
||||
}>({});
|
||||
|
||||
const addSnapshots = (planId: string, ops: EOperation[]) => {
|
||||
const snapsByPlanCpy = { ...snapshotsByPlan };
|
||||
let snapsForPlanCpy = [...(snapsByPlanCpy[planId] || [])];
|
||||
for (const op of ops) {
|
||||
snapsForPlanCpy.push(toEop(op));
|
||||
}
|
||||
snapsForPlanCpy.sort((a, b) => {
|
||||
return a.parsedTime > b.parsedTime ? -1 : 1;
|
||||
});
|
||||
if (snapsForPlanCpy.length > 5) {
|
||||
snapsForPlanCpy = snapsForPlanCpy.slice(0, 5);
|
||||
}
|
||||
snapsByPlanCpy[planId] = snapsForPlanCpy;
|
||||
setSnapshotsByPlan(snapsByPlanCpy);
|
||||
};
|
||||
|
||||
// Track newly created snapshots in the set.
|
||||
useEffect(() => {
|
||||
const listener = (event: OperationEvent) => {
|
||||
if (event.type !== OperationEventType.EVENT_CREATED) return;
|
||||
const op = event.operation!;
|
||||
if (!op.planId) return;
|
||||
if (!op.operationIndexSnapshot) return;
|
||||
addSnapshots(op.planId!, [toEop(op)]);
|
||||
};
|
||||
|
||||
subscribeToOperations(listener);
|
||||
|
||||
return () => {
|
||||
unsubscribeFromOperations(listener);
|
||||
};
|
||||
}, [snapshotsByPlan]);
|
||||
|
||||
if (!config) return [];
|
||||
|
||||
const configPlans = config.plans || [];
|
||||
const configRepos = config.repos || [];
|
||||
|
||||
const onSelectPlan = (plan: Plan) => {
|
||||
setContent(<PlanView plan={plan} />, [
|
||||
{ title: "Plans" },
|
||||
{ title: plan.id || "" },
|
||||
]);
|
||||
|
||||
if (!snapshotsByPlan[plan.id!]) {
|
||||
(async () => {
|
||||
const ops = await getOperations({ planId: plan.id!, lastN: "20" });
|
||||
// avoid races by checking again after the request
|
||||
if (!snapshotsByPlan[plan.id!]) {
|
||||
const snapshots = ops.filter((op) => !!op.operationIndexSnapshot);
|
||||
addSnapshots(plan.id!, snapshots);
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const plans: MenuProps["items"] = [
|
||||
{
|
||||
key: "add-plan",
|
||||
@@ -97,16 +168,47 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
},
|
||||
},
|
||||
...configPlans.map((plan) => {
|
||||
const children: MenuProps["items"] = (
|
||||
snapshotsByPlan[plan.id!] || []
|
||||
).map((snapshot) => {
|
||||
return {
|
||||
key: "s-" + snapshot.id,
|
||||
icon: <PaperClipOutlined />,
|
||||
label: (
|
||||
<small>{"Operation " + formatTime(snapshot.parsedTime)}</small>
|
||||
),
|
||||
onClick: () => {
|
||||
showModal(
|
||||
<Modal
|
||||
title="View Snapshot"
|
||||
open={true}
|
||||
onCancel={() => showModal(null)}
|
||||
footer={[
|
||||
<Button
|
||||
key="done"
|
||||
onClick={() => showModal(null)}
|
||||
type="primary"
|
||||
>
|
||||
Done
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List>
|
||||
<OperationRow operation={snapshot} />
|
||||
</List>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: "p-" + plan.id,
|
||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||
label: plan.id,
|
||||
onClick: () => {
|
||||
setContent(<PlanView plan={plan} />, [
|
||||
{ title: "Plans" },
|
||||
{ title: plan.id || "" },
|
||||
]);
|
||||
},
|
||||
children: children,
|
||||
onTitleClick: onSelectPlan.bind(null, plan), // if children
|
||||
onClick: onSelectPlan.bind(null, plan), // if no children
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user