mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-24 00:45:36 +00:00
feat: initial optree implementation
This commit is contained in:
@@ -56,7 +56,7 @@ func (t *ScheduledBackupTask) Next(now time.Time) *time.Time {
|
||||
}
|
||||
|
||||
if lastBackupOp != nil {
|
||||
now = time.Unix(0, lastBackupOp.UnixTimeEndMs*int64(time.Millisecond))
|
||||
now = time.Unix(0, lastBackupOp.UnixTimeStartMs*int64(time.Millisecond))
|
||||
}
|
||||
} else {
|
||||
zap.S().Errorf("error getting last operation for plan %q when computing backup schedule: %v", t.plan.Id, err)
|
||||
|
||||
Generated
+10
@@ -1224,6 +1224,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.202",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
|
||||
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ=="
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.1.tgz",
|
||||
@@ -1830,6 +1835,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"antd": "^5.11.1",
|
||||
"buffer": "^6.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"parcel": "^2.10.2",
|
||||
"process": "^0.11.10",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { EOperation, getOperations } from "../state/oplog";
|
||||
import { Tree } from "antd";
|
||||
import _ from "lodash";
|
||||
import { DataNode } from "antd/es/tree";
|
||||
import { formatDate, formatTime } from "../lib/formatting";
|
||||
|
||||
export const OperationTree = ({
|
||||
operations,
|
||||
}: React.PropsWithoutRef<{ operations: EOperation[] }>) => {
|
||||
operations.sort((a, b) => b.parsedTime - a.parsedTime);
|
||||
return <Tree treeData={buildTree(operations)}></Tree>;
|
||||
};
|
||||
|
||||
// TODO: more work on this view
|
||||
const buildTree = (operations: EOperation[]): DataNode[] => {
|
||||
const grouped = _.groupBy(operations, (op) => {
|
||||
return new Date(op.parsedTime).toLocaleDateString("default", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
});
|
||||
|
||||
return _.keys(grouped).map((key) => {
|
||||
return {
|
||||
key: key,
|
||||
title: key,
|
||||
children: grouped[key].map((op) => {
|
||||
return {
|
||||
key: op.id!,
|
||||
title: <span>{formatTime(op.parsedTime)} - AN OPERATION</span>,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -20,6 +20,9 @@ const replaceKeyInTree = (
|
||||
if (curNode.key === setKey) {
|
||||
return setValue;
|
||||
}
|
||||
if (setKey.indexOf(curNode.key as string) === -1) {
|
||||
return null;
|
||||
}
|
||||
if (!curNode.children) {
|
||||
return null;
|
||||
}
|
||||
@@ -83,8 +86,6 @@ export const SnapshotBrowser = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Loading data for key: " + key);
|
||||
|
||||
const resp = await ResticUI.ListSnapshotFiles(
|
||||
{
|
||||
path: (key + "/") as string,
|
||||
@@ -103,33 +104,20 @@ export const SnapshotBrowser = ({
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const formatBytes = (bytes?: number | string) => {
|
||||
};
|
||||
|
||||
const timezoneOffsetMs = new Date().getTimezoneOffset() * 60 * 1000;
|
||||
// formatTime formats a time as YYYY-MM-DD at HH:MM AM/PM
|
||||
export const formatTime = (time: number | string) => {
|
||||
if (typeof time === "string") {
|
||||
time = parseInt(time);
|
||||
@@ -23,7 +24,23 @@ export const formatTime = (time: number | string) => {
|
||||
const d = new Date();
|
||||
d.setTime(time - timezoneOffsetMs);
|
||||
const isoStr = d.toISOString();
|
||||
return `${isoStr.substring(0, 10)} ${d.getUTCHours()}h${d.getUTCMinutes()}m`;
|
||||
const hours = d.getUTCHours() % 12 == 0 ? 12 : d.getUTCHours() % 12;
|
||||
const minutes =
|
||||
d.getUTCMinutes() < 10 ? "0" + d.getUTCMinutes() : d.getUTCMinutes();
|
||||
return `${isoStr.substring(0, 10)} at ${hours}:${minutes} ${
|
||||
d.getUTCHours() > 12 ? "PM" : "AM"
|
||||
}`;
|
||||
};
|
||||
|
||||
// formatDate formats a time as YYYY-MM-DD
|
||||
export const formatDate = (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, 4);
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number) => {
|
||||
|
||||
@@ -112,7 +112,7 @@ export const buildOperationListListener = (
|
||||
} else {
|
||||
operations.push(op);
|
||||
operations.sort((a, b) => {
|
||||
return parseInt(a.id!) - parseInt(b.id!);
|
||||
return a.parsedId - b.parsedId;
|
||||
});
|
||||
}
|
||||
} else if (type === OperationEventType.EVENT_CREATED) {
|
||||
|
||||
+53
-92
@@ -9,7 +9,7 @@ import {
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Layout, List, Menu, Modal, Spin, theme } from "antd";
|
||||
import { configState, fetchConfig } from "../state/config";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { useRecoilState, useRecoilValue } from "recoil";
|
||||
import { Config, Plan } from "../../gen/ts/v1/config.pb";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
OperationEvent,
|
||||
OperationEventType,
|
||||
} from "../../gen/ts/v1/operations.pb";
|
||||
import { MessageInstance } from "antd/es/message/interface";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -66,6 +67,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Layout style={{ height: "auto" }}>
|
||||
<OperationNotificationGenerator />
|
||||
<Header style={{ display: "flex", alignItems: "center" }}>
|
||||
<h1>
|
||||
<a
|
||||
@@ -98,66 +100,12 @@ 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",
|
||||
@@ -168,47 +116,16 @@ 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,
|
||||
children: children,
|
||||
onTitleClick: onSelectPlan.bind(null, plan), // if children
|
||||
onClick: onSelectPlan.bind(null, plan), // if no children
|
||||
onClick: () => {
|
||||
setContent(<PlanView plan={plan} />, [
|
||||
{ title: "Plans" },
|
||||
{ title: plan.id || "" },
|
||||
]);
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -249,3 +166,47 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const OperationNotificationGenerator = () => {
|
||||
const alertApi = useAlertApi()!;
|
||||
const setContent = useSetContent();
|
||||
const config = useRecoilValue(configState);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: OperationEvent) => {
|
||||
if (event.type != OperationEventType.EVENT_CREATED) return;
|
||||
const planId = event.operation!.planId!;
|
||||
const repoId = event.operation!.repoId!;
|
||||
|
||||
const onClick = () => {
|
||||
const plan = config.plans!.find((p) => p.id == planId);
|
||||
if (!plan) return;
|
||||
setContent(<PlanView plan={plan} />, [
|
||||
{ title: "Plans" },
|
||||
{ title: planId || "" },
|
||||
]);
|
||||
};
|
||||
|
||||
if (event.operation?.operationBackup) {
|
||||
alertApi.info({
|
||||
content: `Backup started for plan ${planId}.`,
|
||||
onClick: onClick,
|
||||
});
|
||||
} else if (event.operation?.operationIndexSnapshot) {
|
||||
const indexOp = event.operation.operationIndexSnapshot;
|
||||
alertApi.info({
|
||||
content: `Indexed snapshot ${indexOp.snapshot!
|
||||
.id!} for plan ${planId}.`,
|
||||
onClick: onClick,
|
||||
});
|
||||
}
|
||||
};
|
||||
subscribeToOperations(listener);
|
||||
|
||||
return () => {
|
||||
unsubscribeFromOperations(listener);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Plan } from "../../gen/ts/v1/config.pb";
|
||||
import { Button, Flex } from "antd";
|
||||
import { Button, Flex, Tabs } from "antd";
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
import { AddPlanModal } from "./AddPlanModel";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
unsubscribeFromOperations,
|
||||
} from "../state/oplog";
|
||||
import { OperationList } from "../components/OperationList";
|
||||
import { OperationTree } from "../components/OperationTree";
|
||||
|
||||
export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
const showModal = useShowModal();
|
||||
@@ -23,7 +24,7 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
|
||||
useEffect(() => {
|
||||
const listener = buildOperationListListener(
|
||||
{ planId: plan.id, lastN: "1000" },
|
||||
{ planId: plan.id, lastN: "10000" },
|
||||
(event, changedOp, operations) => {
|
||||
setOperations([...operations]);
|
||||
}
|
||||
@@ -78,8 +79,30 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
Prune Now
|
||||
</Button>
|
||||
</Flex>
|
||||
<h2>Backup Action History ({operations.length} loaded)</h2>
|
||||
<OperationList operations={operations} />
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: "Condensed View",
|
||||
children: (
|
||||
<>
|
||||
<OperationTree operations={[...operations]} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: "Operation List",
|
||||
children: (
|
||||
<>
|
||||
<h2>Backup Action History ({operations.length} loaded)</h2>
|
||||
<OperationList operations={[...operations]} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user