feat: implement snapshot browsing

This commit is contained in:
garethgeorge
2023-11-16 19:38:37 -08:00
parent 7f4be47b0c
commit 0500eeac40
12 changed files with 882 additions and 97 deletions
+103 -16
View File
@@ -1,16 +1,23 @@
import React from "react";
import { Operation, OperationStatus } from "../../gen/ts/v1/operations.pb";
import { Col, Collapse, Empty, List, Progress, Row, Typography } from "antd";
import {
AlertOutlined,
DatabaseOutlined,
Card,
Col,
Collapse,
Empty,
List,
Progress,
Row,
Typography,
} from "antd";
import {
ExclamationCircleOutlined,
ExclamationOutlined,
PaperClipOutlined,
SaveOutlined,
} from "@ant-design/icons";
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic.pb";
import { EOperation } from "../state/oplog";
import { SnapshotBrowser } from "./SnapshotBrowser";
export const OperationList = ({
operations,
@@ -26,14 +33,60 @@ export const OperationList = ({
);
}
const groupBy = (ops: EOperation[], keyFunc: (op: EOperation) => string) => {
const groups: { [key: string]: EOperation[] } = {};
ops.forEach((op) => {
const key = keyFunc(op);
if (!(key in groups)) {
groups[key] = [];
}
groups[key].push(op);
});
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);
groupedItems.sort((a, b) => {
return b[0].parsedTime - a[0].parsedTime;
});
return (
<List
itemLayout="horizontal"
size="small"
dataSource={operations}
renderItem={(item, index) => (
<OperationRow key={item.parsedId} operation={item} />
)}
dataSource={groupedItems}
renderItem={(group, index) => {
if (group.length === 1) {
return <OperationRow key={group[0].parsedId} operation={group[0]} />;
}
return (
<Card size="small" style={{ margin: "5px" }}>
{group.map((op) => (
<OperationRow key={op.parsedId} operation={op} />
))}
</Card>
);
}}
pagination={
operations.length > 50
? { position: "both", align: "center", defaultPageSize: 50 }
@@ -55,11 +108,25 @@ export const OperationRow = ({
color = "blue";
}
if (operation.displayMessage) {
if (
operation.displayMessage &&
operation.status === OperationStatus.STATUS_ERROR
) {
let opType = "Message";
if (operation.operationBackup) {
opType = "Backup";
} else if (operation.operationIndexSnapshot) {
opType = "Snapshot";
}
return (
<List.Item>
<List.Item.Meta
title={<>Message</>}
title={
<>
{opType} Error at {formatTime(operation.unixTimeStartMs!)}
</>
}
avatar={<ExclamationCircleOutlined style={{ color }} />}
description={operation.displayMessage}
/>
@@ -120,14 +187,25 @@ export const OperationRow = ({
<>Snapshot at {formatTime(snapshotOp.snapshot!.unixTimeMs!)}</>
}
avatar={<PaperClipOutlined style={{ color }} />}
description={<SnapshotInfo snapshot={snapshotOp.snapshot!} />}
description={
<SnapshotInfo
snapshot={snapshotOp.snapshot!}
repoId={operation.repoId!}
/>
}
/>
</List.Item>
);
}
};
const SnapshotInfo = ({ snapshot }: { snapshot: ResticSnapshot }) => {
const SnapshotInfo = ({
snapshot,
repoId,
}: {
snapshot: ResticSnapshot;
repoId: string;
}) => {
return (
<Collapse
size="small"
@@ -139,7 +217,7 @@ const SnapshotInfo = ({ snapshot }: { snapshot: ResticSnapshot }) => {
<>
<Typography.Text>
<Typography.Text strong>Snapshot ID: </Typography.Text>
{snapshot.id?.substring(0, 8)}
{normalizeSnapshotId(snapshot.id!)}
</Typography.Text>
<Row gutter={16}>
<Col span={8}>
@@ -164,7 +242,9 @@ const SnapshotInfo = ({ snapshot }: { snapshot: ResticSnapshot }) => {
{
key: 2,
label: "Browse",
children: null,
children: (
<SnapshotBrowser snapshotId={snapshot.id!} repoId={repoId} />
),
},
]}
/>
@@ -277,7 +357,10 @@ const formatTime = (time: number | string) => {
}
const d = new Date();
d.setTime(time);
return d.toISOString();
return d.toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "long",
});
};
const formatDuration = (ms: number) => {
@@ -290,4 +373,8 @@ const formatDuration = (ms: number) => {
return `${minutes}m${seconds % 60}s`;
}
return `${hours}h${minutes % 60}m${seconds % 60}s`;
};
};
const normalizeSnapshotId = (id: string) => {
return id.substring(0, 8);
};
+159
View File
@@ -0,0 +1,159 @@
import React, { useEffect, useMemo, useState } from "react";
import { Input, Tree } from "antd";
import type { DataNode, EventDataNode } from "antd/es/tree";
import {
ListSnapshotFilesResponse,
LsEntry,
ResticUI,
} from "../../gen/ts/v1/service.pb";
import { useAlertApi } from "./Alerts";
import { FileOutlined, FolderOutlined } from "@ant-design/icons";
type ELsEntry = LsEntry & { children?: ELsEntry[] };
// replaceKeyInTree returns a value only if changes are made.
const replaceKeyInTree = (
curNode: DataNode,
setKey: string,
setValue: DataNode
): DataNode | null => {
if (curNode.key === setKey) {
return setValue;
}
if (!curNode.children) {
return null;
}
for (const idx in curNode.children!) {
const child = curNode.children![idx];
const newChild = replaceKeyInTree(child, setKey, setValue);
if (newChild) {
const curNodeCopy = { ...curNode };
curNodeCopy.children = [...curNode.children!];
curNodeCopy.children[idx] = newChild;
return curNodeCopy;
}
}
return null;
};
const findInTree = (curNode: DataNode, key: string): DataNode | null => {
if (curNode.key === key) {
return curNode;
}
if (!curNode.children) {
return null;
}
for (const child of curNode.children) {
const found = findInTree(child, key);
if (found) {
return found;
}
}
return null;
};
export const SnapshotBrowser = ({
repoId,
snapshotId,
}: React.PropsWithoutRef<{ snapshotId: string; repoId: string }>) => {
const alertApi = useAlertApi();
const [treeData, setTreeData] = useState<DataNode[]>([]);
useEffect(() => {
(async () => {
try {
const resp = await ResticUI.ListSnapshotFiles(
{
path: "/",
repoId,
snapshotId,
},
{ pathPrefix: "/api" }
);
setTreeData(respToNodes(resp));
} catch (e: any) {
alertApi?.error("Failed to list snapshot files: " + e.message);
}
})();
}, [repoId, snapshotId]);
const onLoadData = async ({ key, children }: EventDataNode<DataNode>) => {
if (children) {
return;
}
console.log("Loading data for key: " + key);
const resp = await ResticUI.ListSnapshotFiles(
{
path: (key + "/") as string,
repoId,
snapshotId,
},
{ pathPrefix: "/api" }
);
let toUpdate: DataNode | null = null;
for (const node of treeData) {
toUpdate = findInTree(node, key as string);
if (toUpdate) {
break;
}
}
if (!toUpdate) {
console.log("No node to update found!");
return;
}
const toUpdateCopy = { ...toUpdate };
toUpdateCopy.children = respToNodes(resp);
console.log(
"Replacing key: " +
key +
" with: " +
JSON.stringify(toUpdateCopy, null, 2)
);
console.log("In tree: " + JSON.stringify(treeData, null, 2));
const newTree = treeData.map((node) => {
console.log("trying replace in tree...");
const didUpdate = replaceKeyInTree(node, key as string, toUpdateCopy);
if (didUpdate) {
console.log("Replaced in tree successfully!");
return didUpdate;
}
return node;
});
console.log("New tree: ", JSON.stringify(newTree, null, 2));
setTreeData(newTree);
};
return <Tree<DataNode> loadData={onLoadData} treeData={treeData} />;
};
const respToNodes = (resp: ListSnapshotFilesResponse): DataNode[] => {
const nodes = resp
.entries!.filter((entry) => entry.path!.length > resp.path!.length)
.map((entry) => {
const lastSlash = entry.path!.lastIndexOf("/");
const title =
lastSlash === -1 ? entry.path : entry.path!.slice(lastSlash + 1);
const node: DataNode = {
key: entry.path!,
title: title,
isLeaf: entry.type === "file",
icon: entry.type === "file" ? <FileOutlined /> : <FolderOutlined />,
};
return node;
});
console.log(JSON.stringify(nodes, null, 2));
return nodes;
};