diff --git a/webui/src/components/OperationList.tsx b/webui/src/components/OperationList.tsx index fe2575c3..57309b25 100644 --- a/webui/src/components/OperationList.tsx +++ b/webui/src/components/OperationList.tsx @@ -49,6 +49,8 @@ import { useAlertApi } from "./Alerts"; import { MessageInstance } from "antd/es/message/interface"; import { backrestService } from "../api"; +// OperationList displays a list of operations that are either fetched based on 'req' or passed in via 'useBackups'. +// If showPlan is provided the planId will be displayed next to each operation in the operation list. export const OperationList = ({ req, useBackups, diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx index bf402889..f76569f6 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTree.tsx @@ -9,7 +9,7 @@ import { subscribeToOperations, unsubscribeFromOperations, } from "../state/oplog"; -import { Col, Divider, Empty, Row, Tree } from "antd"; +import { Col, Divider, Empty, Modal, Row, Tree } from "antd"; import _ from "lodash"; import { DataNode } from "antd/es/tree"; import { @@ -29,6 +29,8 @@ import { OperationEvent, OperationEventType, OperationStatus } from "../../gen/t import { useAlertApi } from "./Alerts"; import { OperationList } from "./OperationList"; import { GetOperationsRequest } from "../../gen/ts/v1/service_pb"; +import { isMobile } from "../lib/browserutil"; +import { useShowModal } from "./ModalManager"; type OpTreeNode = DataNode & { backup?: BackupInfo; @@ -38,6 +40,7 @@ export const OperationTree = ({ req, }: React.PropsWithoutRef<{ req: GetOperationsRequest }>) => { const alertApi = useAlertApi(); + const showModal = useShowModal(); const [backups, setBackups] = useState([]); const [selectedBackupId, setSelectedBackupId] = useState(null); @@ -93,99 +96,99 @@ export const OperationTree = ({ ); } - let oplist: React.ReactNode | null = null; - if (selectedBackupId) { - const backup = backups.find((b) => b.id === selectedBackupId); + const useMobileLayout = isMobile(); - if (!backup) { - oplist = ; - } else { - oplist = ( - <> -

Backup on {formatTime(backup.displayTime)}

- - + const backupTree = + treeData={treeData} + showIcon + defaultExpandedKeys={backups.slice(0, Math.min(5, backups.length)).map((b) => b.id!)} + onSelect={(keys, info) => { + if (info.selectedNodes.length === 0) return; + const backup = info.selectedNodes[0].backup; + if (!backup) { + setSelectedBackupId(null); + return; + } + setSelectedBackupId(backup.id!); + + if (useMobileLayout) { + showModal( + { showModal(null); }}> + + + ); + } + }} + titleRender={(node: OpTreeNode): React.ReactNode => { + if (node.title) { + return <>{node.title}; + } + if (node.backup) { + const b = node.backup; + const details: string[] = []; + + if (b.status === OperationStatus.STATUS_PENDING) { + details.push("scheduled, waiting"); + } else if (b.status === OperationStatus.STATUS_SYSTEM_CANCELLED) { + details.push("system cancel"); + } else if (b.status === OperationStatus.STATUS_USER_CANCELLED) { + details.push("cancelled"); + } + + if (b.backupLastStatus) { + if (b.backupLastStatus.entry.case === "summary") { + const s = b.backupLastStatus.entry.value; + details.push( + `${formatBytes(Number(s.totalBytesProcessed))} in ${formatDuration( + s.totalDuration! * 1000.0 // convert to ms + )}` + ); + } else if (b.backupLastStatus.entry.case === "status") { + const s = b.backupLastStatus.entry.value; + const percent = Math.floor((Number(s.bytesDone) / Number(s.totalBytes)) * 100); + details.push( + `${percent}% processed ${formatBytes( + Number(s.bytesDone) + )} / ${formatBytes(Number(s.totalBytes))}` + ); + } + } + if (b.snapshotInfo) { + details.push(`ID: ${normalizeSnapshotId(b.snapshotInfo.id)}`); + } + + let detailsElem: React.ReactNode | null = null; + if (details.length > 0) { + detailsElem = ( + + [{details.join(", ")}] + + ); + } + + const type = getTypeForDisplay(b.operations[0]); + return ( + <> + {displayTypeToString(type)} {formatTime(b.displayTime)} {detailsElem} + + ); + } + return ( + ERROR: this element should not appear, this is a bug. ); - } + }} + />; + + if (useMobileLayout) { + return backupTree; } return ( - - treeData={treeData} - showIcon - defaultExpandedKeys={backups.slice(0, Math.min(5, backups.length)).map((b) => b.id!)} - onSelect={(keys, info) => { - if (info.selectedNodes.length === 0) return; - const backup = info.selectedNodes[0].backup; - if (!backup) { - setSelectedBackupId(null); - return; - } - setSelectedBackupId(backup.id!); - }} - titleRender={(node: OpTreeNode): React.ReactNode => { - if (node.title) { - return <>{node.title}; - } - if (node.backup) { - const b = node.backup; - const details: string[] = []; - - if (b.status === OperationStatus.STATUS_PENDING) { - details.push("scheduled, waiting"); - } else if (b.status === OperationStatus.STATUS_SYSTEM_CANCELLED) { - details.push("system cancel"); - } else if (b.status === OperationStatus.STATUS_USER_CANCELLED) { - details.push("cancelled"); - } - - if (b.backupLastStatus) { - if (b.backupLastStatus.entry.case === "summary") { - const s = b.backupLastStatus.entry.value; - details.push( - `${formatBytes(Number(s.totalBytesProcessed))} in ${formatDuration( - s.totalDuration! * 1000.0 // convert to ms - )}` - ); - } else if (b.backupLastStatus.entry.case === "status") { - const s = b.backupLastStatus.entry.value; - const percent = Math.floor((Number(s.bytesDone) / Number(s.totalBytes)) * 100); - details.push( - `${percent}% processed ${formatBytes( - Number(s.bytesDone) - )} / ${formatBytes(Number(s.totalBytes))}` - ); - } - } - if (b.snapshotInfo) { - details.push(`ID: ${normalizeSnapshotId(b.snapshotInfo.id)}`); - } - - let detailsElem: React.ReactNode | null = null; - if (details.length > 0) { - detailsElem = ( - - [{details.join(", ")}] - - ); - } - - const type = getTypeForDisplay(b.operations[0]); - return ( - <> - {displayTypeToString(type)} {formatTime(b.displayTime)} {detailsElem} - - ); - } - return ( - ERROR: this element should not appear, this is a bug. - ); - }} - > + {backupTree} - {oplist} + {selectedBackupId ? b.id === selectedBackupId)} /> : null} ); }; @@ -256,3 +259,14 @@ const sortByKey = (a: OpTreeNode, b: OpTreeNode) => { } return 0; }; + +const BackupView = ({ backup }: { backup?: BackupInfo }) => { + if (!backup) { + return ; + } else { + return <> +

Backup on {formatTime(backup.displayTime)}

+ + ; + } +} \ No newline at end of file diff --git a/webui/src/lib/browserutil.ts b/webui/src/lib/browserutil.ts new file mode 100644 index 00000000..331ef317 --- /dev/null +++ b/webui/src/lib/browserutil.ts @@ -0,0 +1,8 @@ +export const isMobile = () => { + // check if window is narrow + if (window.innerWidth <= 768) { + return true; + } + // check if user agent is mobile + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} \ No newline at end of file