diff --git a/internal/orchestrator/tasks.go b/internal/orchestrator/tasks.go index 6e2889a3..3ba7358d 100644 --- a/internal/orchestrator/tasks.go +++ b/internal/orchestrator/tasks.go @@ -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) diff --git a/webui/package-lock.json b/webui/package-lock.json index 4cc0c5d3..8531c804 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package.json b/webui/package.json index 8e0380b4..99cf9377 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx new file mode 100644 index 00000000..e48e9594 --- /dev/null +++ b/webui/src/components/OperationTree.tsx @@ -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 ; +}; + +// 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: {formatTime(op.parsedTime)} - AN OPERATION, + }; + }), + }; + }); +}; diff --git a/webui/src/components/SnapshotBrowser.tsx b/webui/src/components/SnapshotBrowser.tsx index 9be49c98..6ebf8197 100644 --- a/webui/src/components/SnapshotBrowser.tsx +++ b/webui/src/components/SnapshotBrowser.tsx @@ -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); }; diff --git a/webui/src/lib/formatting.ts b/webui/src/lib/formatting.ts index ca55e62a..e6f9b040 100644 --- a/webui/src/lib/formatting.ts +++ b/webui/src/lib/formatting.ts @@ -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) => { diff --git a/webui/src/state/oplog.ts b/webui/src/state/oplog.ts index d3a75b0a..b83c8803 100644 --- a/webui/src/state/oplog.ts +++ b/webui/src/state/oplog.ts @@ -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) { diff --git a/webui/src/views/App.tsx b/webui/src/views/App.tsx index 500fc23b..61d6062a 100644 --- a/webui/src/views/App.tsx +++ b/webui/src/views/App.tsx @@ -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 ( +

{ 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(, [ - { 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: , - label: ( - {"Operation " + formatTime(snapshot.parsedTime)} - ), - onClick: () => { - showModal( - showModal(null)} - footer={[ - , - ]} - > - - - - - ); - }, - }; - }); - return { key: "p-" + plan.id, icon: , label: plan.id, - children: children, - onTitleClick: onSelectPlan.bind(null, plan), // if children - onClick: onSelectPlan.bind(null, plan), // if no children + onClick: () => { + setContent(, [ + { 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(, [ + { 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 <>; +}; diff --git a/webui/src/views/PlanView.tsx b/webui/src/views/PlanView.tsx index bbf82f51..ce2cf616 100644 --- a/webui/src/views/PlanView.tsx +++ b/webui/src/views/PlanView.tsx @@ -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 -

Backup Action History ({operations.length} loaded)

- + + + + ), + }, + { + key: "2", + label: "Operation List", + children: ( + <> +

Backup Action History ({operations.length} loaded)

+ + + ), + }, + ]} + /> ); };