mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-25 01:15:54 +00:00
feat: implement snapshot browsing
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user