feat: show snapshots in sidenav

This commit is contained in:
Gareth George
2023-11-18 00:18:31 -08:00
parent 0cdfd115e2
commit 1a9a5b60d2
7 changed files with 207 additions and 83 deletions

View File

@@ -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
}

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
{
"/api": {
"target": "http://localhost:9090",
"target": "http://localhost:9898",
"secure": false
}
}

View File

@@ -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;
};

View 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);
};

View File

@@ -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;

View File

@@ -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
};
}),
];