mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-05-05 04:20:31 +00:00
524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Col, Empty, Modal, Row, Tooltip, Tree } from "antd";
|
|
import _ from "lodash";
|
|
import { DataNode } from "antd/es/tree";
|
|
import { formatDate, formatTime, localISOTime } from "../lib/formatting";
|
|
import { ExclamationOutlined, QuestionOutlined } from "@ant-design/icons";
|
|
import {
|
|
OperationEventType,
|
|
OperationStatus,
|
|
} from "../../gen/ts/v1/operations_pb";
|
|
import { useAlertApi } from "./Alerts";
|
|
import { OperationList } from "./OperationList";
|
|
import {
|
|
ClearHistoryRequest,
|
|
ForgetRequest,
|
|
GetOperationsRequest,
|
|
OpSelector,
|
|
} from "../../gen/ts/v1/service_pb";
|
|
import { isMobile } from "../lib/browserutil";
|
|
import { useShowModal } from "./ModalManager";
|
|
import { backrestService } from "../api";
|
|
import { ConfirmButton } from "./SpinButton";
|
|
import { OplogState, syncStateFromRequest } from "../state/logstate";
|
|
import {
|
|
FlowDisplayInfo,
|
|
colorForStatus,
|
|
displayInfoForFlow,
|
|
displayTypeToString,
|
|
} from "../state/flowdisplayaggregator";
|
|
import { OperationIcon } from "./OperationIcon";
|
|
import { shouldHideOperation } from "../state/oplog";
|
|
|
|
type OpTreeNode = DataNode & {
|
|
backup?: FlowDisplayInfo;
|
|
};
|
|
|
|
export const OperationTree = ({
|
|
req,
|
|
isPlanView,
|
|
}: React.PropsWithoutRef<{
|
|
req: GetOperationsRequest;
|
|
isPlanView?: boolean;
|
|
}>) => {
|
|
const alertApi = useAlertApi();
|
|
const setScreenWidth = useState(window.innerWidth)[1];
|
|
const [backups, setBackups] = useState<FlowDisplayInfo[]>([]);
|
|
const [treeData, setTreeData] = useState<{
|
|
tree: OpTreeNode[];
|
|
expanded: React.Key[];
|
|
}>({ tree: [], expanded: [] });
|
|
const [selectedBackupId, setSelectedBackupId] = useState<bigint | null>(null);
|
|
|
|
// track the screen width so we can switch between mobile and desktop layouts.
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setScreenWidth(window.innerWidth);
|
|
};
|
|
window.addEventListener("resize", handleResize);
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
}, []);
|
|
|
|
// track backups for this operation tree view.
|
|
useEffect(() => {
|
|
setSelectedBackupId(null);
|
|
|
|
const logState = new OplogState((op) => !shouldHideOperation(op));
|
|
|
|
const backupInfoByFlowID = new Map<bigint, FlowDisplayInfo>();
|
|
|
|
const refresh = _.debounce(
|
|
() => {
|
|
const flows = Array.from(backupInfoByFlowID.values());
|
|
setTreeData(buildTree(flows, isPlanView || false));
|
|
setBackups(flows);
|
|
},
|
|
100,
|
|
{ leading: true, trailing: true },
|
|
);
|
|
|
|
logState.subscribe((ids, flowIDs, event) => {
|
|
if (
|
|
event === OperationEventType.EVENT_CREATED ||
|
|
event === OperationEventType.EVENT_UPDATED
|
|
) {
|
|
for (const flowID of flowIDs) {
|
|
const displayInfo = displayInfoForFlow(
|
|
logState.getByFlowID(flowID) || [],
|
|
);
|
|
if (!displayInfo.hidden) {
|
|
backupInfoByFlowID.set(flowID, displayInfo);
|
|
} else {
|
|
backupInfoByFlowID.delete(flowID);
|
|
}
|
|
}
|
|
} else if (event === OperationEventType.EVENT_DELETED) {
|
|
for (const flowID of flowIDs) {
|
|
backupInfoByFlowID.delete(flowID);
|
|
}
|
|
}
|
|
refresh();
|
|
});
|
|
|
|
return syncStateFromRequest(logState, req, (err) => {
|
|
alertApi!.error("API error: " + err.message);
|
|
});
|
|
}, [JSON.stringify(req)]);
|
|
|
|
if (treeData.tree.length === 0) {
|
|
return (
|
|
<Empty description={""} image={Empty.PRESENTED_IMAGE_SIMPLE}></Empty>
|
|
);
|
|
}
|
|
|
|
const useMobileLayout = isMobile();
|
|
|
|
const backupTree = (
|
|
<Tree<OpTreeNode>
|
|
treeData={treeData.tree}
|
|
showIcon
|
|
defaultExpandedKeys={treeData.expanded}
|
|
onSelect={(keys, info) => {
|
|
if (info.selectedNodes.length === 0) return;
|
|
const backup = info.selectedNodes[0].backup;
|
|
if (!backup) {
|
|
setSelectedBackupId(null);
|
|
return;
|
|
}
|
|
setSelectedBackupId(backup!.flowID);
|
|
}}
|
|
titleRender={(node: OpTreeNode): React.ReactNode => {
|
|
if (node.title !== undefined) {
|
|
return <>{node.title}</>;
|
|
}
|
|
if (node.backup !== undefined) {
|
|
const b = node.backup;
|
|
|
|
return (
|
|
<>
|
|
{displayTypeToString(b.type)} {formatTime(b.displayTime)}{" "}
|
|
{b.subtitleComponents && b.subtitleComponents.length > 0 && (
|
|
<span className="backrest operation-details">
|
|
[{b.subtitleComponents.join(", ")}]
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<span>ERROR: this element should not appear, this is a bug.</span>
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
|
|
if (useMobileLayout) {
|
|
const backup = backups.find((b) => b.flowID === selectedBackupId);
|
|
return (
|
|
<>
|
|
<Modal
|
|
open={!!backup}
|
|
footer={null}
|
|
onCancel={() => {
|
|
setSelectedBackupId(null);
|
|
}}
|
|
width="60vw"
|
|
>
|
|
<BackupView backup={backup} />
|
|
</Modal>
|
|
,{backupTree}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Row>
|
|
<Col span={12}>{backupTree}</Col>
|
|
<Col span={12}>
|
|
<BackupViewContainer>
|
|
{selectedBackupId ? (
|
|
<BackupView
|
|
backup={backups.find((b) => b.flowID === selectedBackupId)}
|
|
/>
|
|
) : null}
|
|
</BackupViewContainer>
|
|
</Col>
|
|
</Row>
|
|
);
|
|
};
|
|
|
|
const treeLeafCache = new WeakMap<FlowDisplayInfo, OpTreeNode>();
|
|
const buildTree = (
|
|
operations: FlowDisplayInfo[],
|
|
isForPlanView: boolean,
|
|
): { tree: OpTreeNode[]; expanded: React.Key[] } => {
|
|
const buildTreeInstanceID = (operations: FlowDisplayInfo[]): OpTreeNode[] => {
|
|
const grouped = _.groupBy(operations, (op) => {
|
|
return op.instanceID;
|
|
});
|
|
|
|
const entries: OpTreeNode[] = _.map(grouped, (value, key) => {
|
|
let title: React.ReactNode = key;
|
|
if (title === "_unassociated_") {
|
|
title = (
|
|
<Tooltip title="_unassociated_ instance ID collects operations that do not specify a `created-by:` tag denoting the backrest install that created them.">
|
|
_unassociated_
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return {
|
|
title,
|
|
key: "i-" + key,
|
|
children: buildTreePlan(value),
|
|
};
|
|
});
|
|
entries.sort(sortByKeyReverse);
|
|
return entries;
|
|
};
|
|
|
|
const buildTreePlan = (operations: FlowDisplayInfo[]): OpTreeNode[] => {
|
|
const grouped = _.groupBy(operations, (op) => {
|
|
return op.planID;
|
|
});
|
|
const entries: OpTreeNode[] = _.map(grouped, (value, key) => {
|
|
let title: React.ReactNode = key;
|
|
if (title === "_unassociated_") {
|
|
title = (
|
|
<Tooltip title="_unassociated_ plan ID collects operations that do not specify a `plan:` tag denoting the backup plan that created them.">
|
|
_unassociated_
|
|
</Tooltip>
|
|
);
|
|
} else if (title === "_system_") {
|
|
title = (
|
|
<Tooltip title="_system_ plan ID collects health operations not associated with any single plan e.g. repo level check or prune runs.">
|
|
_system_
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return {
|
|
key: "p-" + key,
|
|
title,
|
|
children: buildTreeDay(key, value),
|
|
};
|
|
});
|
|
entries.sort(sortByKeyReverse);
|
|
return entries;
|
|
};
|
|
|
|
const buildTreeDay = (
|
|
keyPrefix: string,
|
|
operations: FlowDisplayInfo[],
|
|
): OpTreeNode[] => {
|
|
const grouped = _.groupBy(operations, (op) => {
|
|
return localISOTime(op.displayTime).substring(0, 10);
|
|
});
|
|
const entries = _.map(grouped, (value, key) => {
|
|
const children = buildTreeLeaf(value);
|
|
return {
|
|
key: keyPrefix + key,
|
|
title: formatDate(value[0].displayTime),
|
|
children: children,
|
|
};
|
|
});
|
|
entries.sort(sortByKey);
|
|
return entries;
|
|
};
|
|
|
|
const buildTreeLeaf = (operations: FlowDisplayInfo[]): OpTreeNode[] => {
|
|
const entries = _.map(operations, (b): OpTreeNode => {
|
|
let cached = treeLeafCache.get(b);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
let iconColor = colorForStatus(b.status);
|
|
let icon: React.ReactNode | null = <QuestionOutlined />;
|
|
|
|
if (
|
|
b.status === OperationStatus.STATUS_ERROR ||
|
|
b.status === OperationStatus.STATUS_WARNING
|
|
) {
|
|
icon = <ExclamationOutlined style={{ color: iconColor }} />;
|
|
} else {
|
|
icon = <OperationIcon status={b.status} type={b.type} />;
|
|
}
|
|
|
|
let newLeaf = {
|
|
key: b.flowID,
|
|
backup: b,
|
|
icon: icon,
|
|
};
|
|
treeLeafCache.set(b, newLeaf);
|
|
return newLeaf;
|
|
});
|
|
entries.sort((a, b) => {
|
|
return b.backup!.displayTime - a.backup!.displayTime;
|
|
});
|
|
return entries;
|
|
};
|
|
|
|
const expandTree = (
|
|
entries: OpTreeNode[],
|
|
budget: number,
|
|
d1: number,
|
|
d2: number,
|
|
) => {
|
|
let expanded: React.Key[] = [];
|
|
const h2 = (
|
|
entries: OpTreeNode[],
|
|
curDepth: number,
|
|
budget: number,
|
|
): number => {
|
|
if (curDepth >= d2) {
|
|
for (const entry of entries) {
|
|
expanded.push(entry.key);
|
|
budget--;
|
|
if (budget <= 0) {
|
|
break;
|
|
}
|
|
}
|
|
return budget;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.children) continue;
|
|
budget = h2(entry.children, curDepth + 1, budget);
|
|
if (budget <= 0) {
|
|
break;
|
|
}
|
|
}
|
|
return budget;
|
|
};
|
|
const h1 = (entries: OpTreeNode[], curDepth: number) => {
|
|
if (curDepth >= d1) {
|
|
h2(entries, curDepth + 1, budget);
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.children) continue;
|
|
h1(entry.children, curDepth + 1);
|
|
}
|
|
};
|
|
h1(entries, 0);
|
|
return expanded;
|
|
};
|
|
|
|
let tree: OpTreeNode[];
|
|
let expanded: React.Key[];
|
|
if (isForPlanView) {
|
|
tree = buildTreeDay("", operations);
|
|
expanded = expandTree(tree, 5, 0, 2);
|
|
} else {
|
|
tree = buildTreeInstanceID(operations);
|
|
expanded = expandTree(tree, 5, 2, 4);
|
|
}
|
|
return { tree, expanded };
|
|
};
|
|
|
|
const sortByKey = (a: OpTreeNode, b: OpTreeNode) => {
|
|
if (a.key < b.key) {
|
|
return 1;
|
|
} else if (a.key > b.key) {
|
|
return -1;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const sortByKeyReverse = (a: OpTreeNode, b: OpTreeNode) => {
|
|
return -sortByKey(a, b);
|
|
};
|
|
|
|
const BackupViewContainer = ({ children }: { children: React.ReactNode }) => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const innerRef = useRef<HTMLDivElement>(null);
|
|
const refresh = useState(0)[1];
|
|
const [topY, setTopY] = useState(0);
|
|
const [bottomY, setBottomY] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current || !innerRef.current) {
|
|
return;
|
|
}
|
|
|
|
let offset = 0;
|
|
|
|
// handle scroll events to keep the fixed container in view.
|
|
const handleScroll = () => {
|
|
const refRect = ref.current!.getBoundingClientRect();
|
|
let wiggle = Math.max(refRect.height - window.innerHeight, 0);
|
|
let topY = Math.max(ref.current!.getBoundingClientRect().top, 0);
|
|
let bottomY = topY;
|
|
if (topY == 0) {
|
|
// wiggle only if the top is actually the top edge of the screen.
|
|
topY -= wiggle;
|
|
bottomY += wiggle;
|
|
}
|
|
|
|
setTopY(topY);
|
|
setBottomY(bottomY);
|
|
|
|
refresh(Math.random());
|
|
};
|
|
|
|
window.addEventListener("scroll", handleScroll);
|
|
|
|
// attach resize observer to ref to update the width of the fixed container.
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
handleScroll();
|
|
});
|
|
if (ref.current) {
|
|
resizeObserver.observe(ref.current);
|
|
resizeObserver.observe(innerRef.current!);
|
|
}
|
|
return () => {
|
|
window.removeEventListener("scroll", handleScroll);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [ref.current, innerRef.current]);
|
|
|
|
const rect = ref.current?.getBoundingClientRect();
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
style={{
|
|
width: "100%",
|
|
height: innerRef.current?.clientHeight,
|
|
}}
|
|
>
|
|
<div
|
|
ref={innerRef}
|
|
style={{
|
|
position: "fixed",
|
|
top: Math.max(Math.min(rect?.top || 0, bottomY), topY),
|
|
left: rect?.left,
|
|
width: ref.current?.clientWidth,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => {
|
|
const alertApi = useAlertApi();
|
|
if (!backup) {
|
|
return <Empty description="Backup not found." />;
|
|
} else {
|
|
const doDeleteSnapshot = async () => {
|
|
try {
|
|
await backrestService.forget(
|
|
new ForgetRequest({
|
|
planId: backup.planID!,
|
|
repoId: backup.repoID!,
|
|
snapshotId: backup.snapshotID!,
|
|
}),
|
|
);
|
|
alertApi!.success("Snapshot forgotten.");
|
|
} catch (e) {
|
|
alertApi!.error("Failed to forget snapshot: " + e);
|
|
}
|
|
};
|
|
|
|
const snapshotInFlow = backup?.operations.find(
|
|
(op) => op.op.case === "operationIndexSnapshot",
|
|
);
|
|
|
|
const deleteButton =
|
|
snapshotInFlow && snapshotInFlow.snapshotId ? (
|
|
<Tooltip title="This will remove the snapshot from the repository. This is irreversible.">
|
|
<ConfirmButton
|
|
type="text"
|
|
confirmTitle="Confirm forget?"
|
|
confirmTimeout={2000}
|
|
onClickAsync={doDeleteSnapshot}
|
|
>
|
|
Forget (Destructive)
|
|
</ConfirmButton>
|
|
</Tooltip>
|
|
) : (
|
|
<ConfirmButton
|
|
type="text"
|
|
confirmTitle="Confirm clear?"
|
|
onClickAsync={async () => {
|
|
backrestService.clearHistory(
|
|
new ClearHistoryRequest({
|
|
selector: new OpSelector({
|
|
ids: backup.operations.map((op) => op.id),
|
|
}),
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
Delete Event
|
|
</ConfirmButton>
|
|
);
|
|
|
|
return (
|
|
<div style={{ width: "100%" }}>
|
|
<div
|
|
style={{
|
|
alignItems: "center",
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
width: "100%",
|
|
height: "60px",
|
|
}}
|
|
>
|
|
<h3>{formatTime(backup.displayTime)}</h3>
|
|
<div style={{ position: "absolute", right: "20px" }}>
|
|
{backup.status !== OperationStatus.STATUS_PENDING &&
|
|
backup.status !== OperationStatus.STATUS_INPROGRESS
|
|
? deleteButton
|
|
: null}
|
|
</div>
|
|
</div>
|
|
<OperationList key={backup.flowID} useOperations={backup.operations} />
|
|
</div>
|
|
);
|
|
}
|
|
};
|