diff --git a/cmd/backrest/backrest.go b/cmd/backrest/backrest.go index c7f83dd3..635e6e5a 100644 --- a/cmd/backrest/backrest.go +++ b/cmd/backrest/backrest.go @@ -149,7 +149,10 @@ func main() { apiAuthenticationHandler := api.NewAuthenticationHandler(authenticator) mux := http.NewServeMux() - mux.Handle(v1connect.NewAuthenticationHandler(apiAuthenticationHandler)) + if cfg.GetMultihost() != nil { + // alpha feature, only available if the user manually enables it in the config. + mux.Handle(v1connect.NewAuthenticationHandler(apiAuthenticationHandler)) + } mux.Handle(v1connect.NewBackrestSyncServiceHandler(syncHandler)) backrestHandlerPath, backrestHandler := v1connect.NewBackrestHandler(apiBackrestHandler) mux.Handle(backrestHandlerPath, auth.RequireAuthentication(backrestHandler, authenticator)) diff --git a/webui/src/components/LogView.tsx b/webui/src/components/LogView.tsx index f7807f0f..bff63f2b 100644 --- a/webui/src/components/LogView.tsx +++ b/webui/src/components/LogView.tsx @@ -37,7 +37,7 @@ export const LogView = ({ logref }: { logref: string }) => { }); } } catch (e) { - setLines((prev) => [...prev, `Fetch log error: ${e}`]); + // setLines((prev) => [...prev, `Fetch log error: ${e}`]); } })(); diff --git a/webui/src/components/OperationList.tsx b/webui/src/components/OperationListView.tsx similarity index 98% rename from webui/src/components/OperationList.tsx rename to webui/src/components/OperationListView.tsx index 0aa4d4b4..6bea70e1 100644 --- a/webui/src/components/OperationList.tsx +++ b/webui/src/components/OperationListView.tsx @@ -14,7 +14,7 @@ import { toJsonString } from "@bufbuild/protobuf"; // 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 = ({ +export const OperationListView = ({ req, useOperations, showPlan, diff --git a/webui/src/components/OperationRow.tsx b/webui/src/components/OperationRow.tsx index ff933b8e..2a5a0167 100644 --- a/webui/src/components/OperationRow.tsx +++ b/webui/src/components/OperationRow.tsx @@ -34,7 +34,6 @@ import { MessageInstance } from "antd/es/message/interface"; import { backrestService } from "../api"; import { useShowModal } from "./ModalManager"; import { useAlertApi } from "./Alerts"; -import { OperationList } from "./OperationList"; import { displayTypeToString, getTypeForDisplay, @@ -44,6 +43,7 @@ import { OperationIcon } from "./OperationIcon"; import { LogView } from "./LogView"; import { ConfirmButton } from "./SpinButton"; import { create } from "@bufbuild/protobuf"; +import { OperationListView } from "./OperationListView"; export const OperationRow = ({ operation, @@ -133,7 +133,9 @@ export const OperationRow = ({ const title: React.ReactNode[] = [
- {showPlan ? operation.planId + " - " : undefined}{" "} + {showPlan + ? operation.instanceId + " - " + operation.planId + " - " + : undefined}{" "} {formatTime(Number(operation.unixTimeStartMs))} - {opName}{" "} {details}
, @@ -305,7 +307,7 @@ export const OperationRow = ({ key: "hookOperations", label: "Hooks Triggered", children: ( - diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTreeView.tsx similarity index 92% rename from webui/src/components/OperationTree.tsx rename to webui/src/components/OperationTreeView.tsx index 45c88c69..7959db2f 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTreeView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { Col, Empty, Flex, Modal, Row, Splitter, Tooltip, Tree } from "antd"; -import _ from "lodash"; +import _, { flow } from "lodash"; import { DataNode } from "antd/es/tree"; import { formatDate, formatTime, localISOTime } from "../lib/formatting"; import { ExclamationOutlined, QuestionOutlined } from "@ant-design/icons"; @@ -9,7 +9,7 @@ import { OperationStatus, } from "../../gen/ts/v1/operations_pb"; import { useAlertApi } from "./Alerts"; -import { OperationList } from "./OperationList"; +import { OperationListView } from "./OperationListView"; import { ClearHistoryRequestSchema, ForgetRequestSchema, @@ -17,7 +17,6 @@ import { type GetOperationsRequest, } 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"; @@ -35,7 +34,7 @@ type OpTreeNode = DataNode & { backup?: FlowDisplayInfo; }; -export const OperationTree = ({ +export const OperationTreeView = ({ req, isPlanView, }: React.PropsWithoutRef<{ @@ -45,10 +44,6 @@ export const OperationTree = ({ const alertApi = useAlertApi(); const setScreenWidth = useState(window.innerWidth)[1]; const [backups, setBackups] = useState([]); - const [treeData, setTreeData] = useState<{ - tree: OpTreeNode[]; - expanded: React.Key[]; - }>({ tree: [], expanded: [] }); const [selectedBackupId, setSelectedBackupId] = useState(null); // track the screen width so we can switch between mobile and desktop layouts. @@ -70,16 +65,6 @@ export const OperationTree = ({ const backupInfoByFlowID = new Map(); - 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 || @@ -88,6 +73,7 @@ export const OperationTree = ({ for (const flowID of flowIDs) { const ops = logState.getByFlowID(flowID); if (!ops || ops[0].op.case === "operationRunHook") { + // sometimes hook operations become awkwardly orphaned. These are ignored. continue; } @@ -103,7 +89,8 @@ export const OperationTree = ({ backupInfoByFlowID.delete(flowID); } } - refresh(); + + setBackups([...backupInfoByFlowID.values()]); }); return syncStateFromRequest(logState, req, (err) => { @@ -111,7 +98,7 @@ export const OperationTree = ({ }); }, [toJsonString(GetOperationsRequestSchema, req)]); - if (treeData.tree.length === 0) { + if (backups.length === 0) { return ( ); @@ -119,7 +106,88 @@ export const OperationTree = ({ const useMobileLayout = isMobile(); - const backupTree = ( + const displayTree = ( + { + setSelectedBackupId(flow ? flow.flowID : null); + }} + /> + ); + + if (useMobileLayout) { + const backup = backups.find((b) => b.flowID === selectedBackupId); + return ( + <> + { + setSelectedBackupId(null); + }} + width="60vw" + > + + + {displayTree} + + ); + } + + return ( + + + + {displayTree} + + + + {selectedBackupId ? ( + b.flowID === selectedBackupId)} + /> + ) : null} + {" "} + + + + ); +}; + +const DisplayOperationTree = ({ + operations, + isPlanView, + onSelect, +}: { + operations: FlowDisplayInfo[]; + isPlanView?: boolean; + onSelect?: (flow: FlowDisplayInfo | null) => any; +}) => { + const [treeData, setTreeData] = useState<{ + tree: OpTreeNode[]; + expanded: React.Key[]; + }>({ tree: [], expanded: [] }); + + useEffect(() => { + const cancel = setTimeout( + () => { + const { tree, expanded } = buildTree(operations, isPlanView || false); + setTreeData({ tree, expanded }); + }, + treeData && treeData.tree.length > 0 ? 100 : 0 + ); + + return () => { + clearTimeout(cancel); + }; + }, [operations]); + + if (treeData.tree.length === 0) { + return <>; + } + + return ( treeData={treeData.tree} showIcon @@ -127,11 +195,7 @@ export const OperationTree = ({ onSelect={(keys, info) => { if (info.selectedNodes.length === 0) return; const backup = info.selectedNodes[0].backup; - if (!backup) { - setSelectedBackupId(null); - return; - } - setSelectedBackupId(backup!.flowID); + onSelect && onSelect(backup || null); }} titleRender={(node: OpTreeNode): React.ReactNode => { if (node.title !== undefined) { @@ -157,44 +221,6 @@ export const OperationTree = ({ }} /> ); - - if (useMobileLayout) { - const backup = backups.find((b) => b.flowID === selectedBackupId); - return ( - <> - { - setSelectedBackupId(null); - }} - width="60vw" - > - - - {backupTree} - - ); - } - - return ( - - - - {backupTree} - - - - {selectedBackupId ? ( - b.flowID === selectedBackupId)} - /> - ) : null} - {" "} - - - - ); }; const treeLeafCache = new WeakMap(); @@ -528,7 +554,10 @@ const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => { : null} - + ); } diff --git a/webui/src/components/StatsPanel.tsx b/webui/src/components/StatsPanel.tsx index 164d01b3..c5022381 100644 --- a/webui/src/components/StatsPanel.tsx +++ b/webui/src/components/StatsPanel.tsx @@ -15,33 +15,19 @@ import { Operation, OperationStats } from "../../gen/ts/v1/operations_pb"; import { useAlertApi } from "./Alerts"; import { getOperations } from "../state/oplog"; import { - GetOperationsRequest, GetOperationsRequestSchema, OpSelector, } from "../../gen/ts/v1/service_pb"; import _ from "lodash"; import { create } from "@bufbuild/protobuf"; -const StatsPanel = ({ - instanceId, - repoId, -}: { - instanceId: string; - repoId: string; -}) => { +const StatsPanel = ({ selector }: { selector: OpSelector }) => { const [operations, setOperations] = useState([]); const alertApi = useAlertApi(); useEffect(() => { - if (!repoId) { - return; - } - const req = create(GetOperationsRequestSchema, { - selector: { - repoId, - instanceId, - }, + selector, }); getOperations(req) @@ -54,7 +40,7 @@ const StatsPanel = ({ .catch((e) => { alertApi!.error("Failed to fetch operations: " + e.message); }); - }, [repoId]); + }, [JSON.stringify(selector)]); if (operations.length === 0) { return ( diff --git a/webui/src/state/logstate.ts b/webui/src/state/logstate.ts index 019f6843..fac0cadb 100644 --- a/webui/src/state/logstate.ts +++ b/webui/src/state/logstate.ts @@ -223,7 +223,7 @@ export const matchSelector = (selector: OpSelector, op: Operation) => { if (selector.planId && selector.planId !== op.planId) { return false; } - if (selector.repoId && selector.repoId !== op.repoId) { + if (selector.repoGuid && selector.repoGuid !== op.repoGuid) { return false; } if (selector.flowId && selector.flowId !== op.flowId) { diff --git a/webui/src/views/App.tsx b/webui/src/views/App.tsx index 923c107b..2315cded 100644 --- a/webui/src/views/App.tsx +++ b/webui/src/views/App.tsx @@ -29,7 +29,7 @@ import { useConfig } from "../components/ConfigProvider"; import { shouldShowSettings } from "../state/configutil"; import { OpSelector, OpSelectorSchema } from "../../gen/ts/v1/service_pb"; import { colorForStatus } from "../state/flowdisplayaggregator"; -import { getStatusForSelector } from "../state/logstate"; +import { getStatusForSelector, matchSelector } from "../state/logstate"; import { Route, Routes, useNavigate, useParams } from "react-router-dom"; import { MainContentAreaTemplate } from "./MainContentArea"; import { create } from "@bufbuild/protobuf"; @@ -281,6 +281,7 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => { return; } + const reposById = _.keyBy(config.repos, (r) => r.id); const configPlans = config.plans || []; const configRepos = config.repos || []; @@ -295,15 +296,15 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => { }, }, ...configPlans.map((plan) => { + const sel = create(OpSelectorSchema, { + instanceId: config.instance, + planId: plan.id, + repoGuid: reposById[plan.repo]?.guid, + }); + return { key: "p-" + plan.id, - icon: ( - - ), + icon: , label: (
{ ...configRepos.map((repo) => { return { key: "r-" + repo.id, - icon: , + icon: ( + + ), label: (
{ ]; }; -const IconForResource = ({ - instanceId, - planId, - repoId, -}: { - instanceId: string; - planId?: string; - repoId?: string; -}) => { +const IconForResource = ({ selector }: { selector: OpSelector }) => { const [status, setStatus] = useState(OperationStatus.STATUS_UNKNOWN); useEffect(() => { + if (!selector || !selector.instanceId || !selector.repoGuid) { + return; + } + const load = async () => { - setStatus( - await getStatusForSelector( - create(OpSelectorSchema, { instanceId, planId, repoId }) - ) - ); + setStatus(await getStatusForSelector(selector)); }; load(); const refresh = _.debounce(load, 1000, { maxWait: 10000, trailing: true }); @@ -423,11 +423,7 @@ const IconForResource = ({ case "createdOperations": case "updatedOperations": const ops = event.event.value.operations; - if ( - ops.find( - (op) => (!planId || op.planId === planId) && op.repoId === repoId - ) - ) { + if (ops.find((op) => matchSelector(selector, op))) { refresh(); } break; @@ -441,7 +437,7 @@ const IconForResource = ({ return () => { unsubscribeFromOperations(callback); }; - }, [planId, repoId]); + }, [JSON.stringify(selector)]); return iconForStatus(status); }; diff --git a/webui/src/views/PlanView.tsx b/webui/src/views/PlanView.tsx index a751b57e..384c903f 100644 --- a/webui/src/views/PlanView.tsx +++ b/webui/src/views/PlanView.tsx @@ -2,8 +2,6 @@ import React, { useEffect, useState } from "react"; import { Plan } from "../../gen/ts/v1/config_pb"; import { Button, Flex, Tabs, Tooltip, Typography } from "antd"; import { useAlertApi } from "../components/Alerts"; -import { OperationList } from "../components/OperationList"; -import { OperationTree } from "../components/OperationTree"; import { MAX_OPERATION_HISTORY } from "../constants"; import { backrestService } from "../api"; import { @@ -16,6 +14,8 @@ import { SpinButton } from "../components/SpinButton"; import { useShowModal } from "../components/ModalManager"; import { create } from "@bufbuild/protobuf"; import { useConfig } from "../components/ConfigProvider"; +import { OperationListView } from "../components/OperationListView"; +import { OperationTreeView } from "../components/OperationTreeView"; export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { const [config, _] = useConfig(); @@ -54,7 +54,7 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { create(ClearHistoryRequestSchema, { selector: { planId: plan.id, - repoId: plan.repo, + repoGuid: repo!.guid, }, onlyFailed: true, }) @@ -89,7 +89,7 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { type="default" onClick={async () => { const { RunCommandModal } = await import("./RunCommandModal"); - showModal(); + showModal(); }} > Run Command @@ -114,7 +114,7 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { label: "Tree View", children: ( <> - ) => { children: ( <>

Backup Action History

- ) => { label: "Tree View", children: ( <> - ) => { children: ( <>

Backup Action History

- ) => { label: "Stats", children: ( Loading...
}> - + ), destroyInactiveTabPane: true, @@ -163,7 +169,7 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { type="default" onClick={async () => { const { RunCommandModal } = await import("./RunCommandModal"); - showModal(); + showModal(); }} > Run Command diff --git a/webui/src/views/RunCommandModal.tsx b/webui/src/views/RunCommandModal.tsx index b41b1929..eeb7379f 100644 --- a/webui/src/views/RunCommandModal.tsx +++ b/webui/src/views/RunCommandModal.tsx @@ -11,7 +11,8 @@ import { RunCommandRequest, RunCommandRequestSchema, } from "../../gen/ts/v1/service_pb"; -import { OperationList } from "../components/OperationList"; +import { Repo } from "../../gen/ts/v1/config_pb"; +import { OperationListView } from "../components/OperationListView"; import { create } from "@bufbuild/protobuf"; import { useConfig } from "../components/ConfigProvider"; @@ -21,7 +22,7 @@ interface Invocation { error: string; } -export const RunCommandModal = ({ repoId }: { repoId: string }) => { +export const RunCommandModal = ({ repo }: { repo: Repo }) => { const [config, _] = useConfig(); const showModal = useShowModal(); const alertApi = useAlertApi()!; @@ -42,7 +43,7 @@ export const RunCommandModal = ({ repoId }: { repoId: string }) => { try { const opID = await backrestService.runCommand( create(RunCommandRequestSchema, { - repoId, + repoId: repo.id!, command: toRun, }) ); @@ -57,7 +58,7 @@ export const RunCommandModal = ({ repoId }: { repoId: string }) => { @@ -82,11 +83,11 @@ export const RunCommandModal = ({ repoId }: { repoId: string }) => { before running another operation that requires the repo lock. ) : null} -