mirror of
https://github.com/garethgeorge/backrest.git
synced 2025-12-18 19:45:33 +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)
|
ops, err = s.oplog.GetByPlan(req.PlanId, filter)
|
||||||
} else if req.RepoId != "" {
|
} else if req.RepoId != "" {
|
||||||
ops, err = s.oplog.GetByRepo(req.RepoId, filter)
|
ops, err = s.oplog.GetByRepo(req.RepoId, filter)
|
||||||
|
} else {
|
||||||
|
ops, err = s.oplog.GetAll(filter)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get operations: %w", err)
|
return nil, fmt.Errorf("failed to get operations: %w", err)
|
||||||
@@ -233,4 +235,3 @@ func (s *Server) PathAutocomplete(ctx context.Context, path *types.StringValue)
|
|||||||
|
|
||||||
return &types.StringList{Values: paths}, nil
|
return &types.StringList{Values: paths}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,24 @@ func (o *OpLog) GetByPlan(planId string, filter Filter) ([]*v1.Operation, error)
|
|||||||
return ops, nil
|
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)) {
|
func (o *OpLog) Subscribe(callback *func(EventType, *v1.Operation)) {
|
||||||
o.subscribersMu.Lock()
|
o.subscribersMu.Lock()
|
||||||
defer o.subscribersMu.Unlock()
|
defer o.subscribersMu.Unlock()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://localhost:9090",
|
"target": "http://localhost:9898",
|
||||||
"secure": false
|
"secure": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic.pb";
|
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic.pb";
|
||||||
import { EOperation } from "../state/oplog";
|
import { EOperation } from "../state/oplog";
|
||||||
import { SnapshotBrowser } from "./SnapshotBrowser";
|
import { SnapshotBrowser } from "./SnapshotBrowser";
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
normalizeSnapshotId,
|
||||||
|
} from "../lib/formatting";
|
||||||
|
|
||||||
export const OperationList = ({
|
export const OperationList = ({
|
||||||
operations,
|
operations,
|
||||||
@@ -47,24 +53,10 @@ export const OperationList = ({
|
|||||||
return Object.values(groups);
|
return Object.values(groups);
|
||||||
};
|
};
|
||||||
|
|
||||||
// snapshotKey is a heuristic that tries to find a snapshot ID to group the operation by,
|
// groups items by snapshotID if one can be identified, otherwise by operation ID.
|
||||||
// if one can not be found the operation ID is the key.
|
const groupedItems = groupBy(operations, (op: EOperation) => {
|
||||||
const snapshotKey = (op: EOperation) => {
|
return getSnapshotId(op) || op.id!;
|
||||||
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);
|
|
||||||
groupedItems.sort((a, b) => {
|
groupedItems.sort((a, b) => {
|
||||||
return b[0].parsedTime - a[0].parsedTime;
|
return b[0].parsedTime - a[0].parsedTime;
|
||||||
});
|
});
|
||||||
@@ -334,46 +326,14 @@ const BackupOperationStatus = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBytes = (bytes?: number | string) => {
|
const getSnapshotId = (op: EOperation): string | null => {
|
||||||
if (!bytes) {
|
if (op.operationBackup) {
|
||||||
return 0;
|
const ob = op.operationBackup;
|
||||||
|
if (ob.lastStatus && ob.lastStatus.summary) {
|
||||||
|
return normalizeSnapshotId(ob.lastStatus.summary.snapshotId!);
|
||||||
}
|
}
|
||||||
if (typeof bytes === "string") {
|
} else if (op.operationIndexSnapshot) {
|
||||||
bytes = parseInt(bytes);
|
return normalizeSnapshotId(op.operationIndexSnapshot.snapshot!.id!);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|||||||
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,
|
planId,
|
||||||
repoId,
|
repoId,
|
||||||
lastN,
|
lastN,
|
||||||
}: GetOperationsRequest): Promise<Operation[]> => {
|
}: GetOperationsRequest): Promise<EOperation[]> => {
|
||||||
const opList = await ResticUI.GetOperations(
|
const opList = await ResticUI.GetOperations(
|
||||||
{
|
{
|
||||||
planId,
|
planId,
|
||||||
@@ -55,7 +55,7 @@ export const getOperations = async ({
|
|||||||
pathPrefix: "/api",
|
pathPrefix: "/api",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return opList.operations || [];
|
return (opList.operations || []).map(toEop);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subscribeToOperations = (
|
export const subscribeToOperations = (
|
||||||
@@ -85,7 +85,7 @@ export const buildOperationListListener = (
|
|||||||
let operations: EOperation[] = [];
|
let operations: EOperation[] = [];
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let opsFromServer = (await getOperations(req)).map(toEop);
|
let opsFromServer = await getOperations(req);
|
||||||
operations = opsFromServer.filter(
|
operations = opsFromServer.filter(
|
||||||
(o) => !operations.find((op) => op.parsedId === o.parsedId)
|
(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 =
|
const time =
|
||||||
op.operationIndexSnapshot?.snapshot?.unixTimeMs || op.unixTimeStartMs;
|
op.operationIndexSnapshot?.snapshot?.unixTimeMs || op.unixTimeStartMs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { MenuProps } from "antd";
|
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 { configState, fetchConfig } from "../state/config";
|
||||||
import { useRecoilState } from "recoil";
|
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 { useAlertApi } from "../components/Alerts";
|
||||||
import { useShowModal } from "../components/ModalManager";
|
import { useShowModal } from "../components/ModalManager";
|
||||||
import { AddPlanModal } from "./AddPlanModel";
|
import { AddPlanModal } from "./AddPlanModel";
|
||||||
import { AddRepoModel } from "./AddRepoModel";
|
import { AddRepoModel } from "./AddRepoModel";
|
||||||
import { MainContentArea, useSetContent } from "./MainContentArea";
|
import { MainContentArea, useSetContent } from "./MainContentArea";
|
||||||
import { PlanView } from "./PlanView";
|
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;
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
@@ -63,7 +80,7 @@ export const App: React.FC = () => {
|
|||||||
</h1>
|
</h1>
|
||||||
</Header>
|
</Header>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Sider width={200} style={{ background: colorBgContainer }}>
|
<Sider width={300} style={{ background: colorBgContainer }}>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
defaultSelectedKeys={["1"]}
|
defaultSelectedKeys={["1"]}
|
||||||
@@ -81,12 +98,66 @@ export const App: React.FC = () => {
|
|||||||
const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||||
const showModal = useShowModal();
|
const showModal = useShowModal();
|
||||||
const setContent = useSetContent();
|
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 [];
|
if (!config) return [];
|
||||||
|
|
||||||
const configPlans = config.plans || [];
|
const configPlans = config.plans || [];
|
||||||
const configRepos = config.repos || [];
|
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"] = [
|
const plans: MenuProps["items"] = [
|
||||||
{
|
{
|
||||||
key: "add-plan",
|
key: "add-plan",
|
||||||
@@ -97,16 +168,47 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
...configPlans.map((plan) => {
|
...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 {
|
return {
|
||||||
key: "p-" + plan.id,
|
key: "p-" + plan.id,
|
||||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||||
label: plan.id,
|
label: plan.id,
|
||||||
onClick: () => {
|
children: children,
|
||||||
setContent(<PlanView plan={plan} />, [
|
onTitleClick: onSelectPlan.bind(null, plan), // if children
|
||||||
{ title: "Plans" },
|
onClick: onSelectPlan.bind(null, plan), // if no children
|
||||||
{ title: plan.id || "" },
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user