feat: implement 'run command' button to execute arbitrary restic commands in a repo

This commit is contained in:
garethgeorge
2024-05-07 23:40:32 -07:00
parent c236b1f3f1
commit 74c8abc39a
21 changed files with 1635 additions and 426 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}
+11 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Plan } from "../../gen/ts/v1/config_pb";
import { Flex, Tabs, Tooltip, Typography } from "antd";
import { Button, Flex, Tabs, Tooltip, Typography } from "antd";
import { useAlertApi } from "../components/Alerts";
import { OperationList } from "../components/OperationList";
import { OperationTree } from "../components/OperationTree";
@@ -9,9 +9,11 @@ import { backrestService } from "../api";
import { GetOperationsRequest } from "../../gen/ts/v1/service_pb";
import { SpinButton } from "../components/SpinButton";
import { shouldHideStatus } from "../state/oplog";
import { useShowModal } from "../components/ModalManager";
export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
const alertsApi = useAlertApi()!;
const showModal = useShowModal();
const handleBackupNow = async () => {
try {
@@ -77,6 +79,14 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
Clear Error History
</SpinButton>
</Tooltip>
<Tooltip title="Open a restic shell to run commands on the repository.">
<Button type="default" onClick={async () => {
const { RunCommandModal } = await import("./RunCommandModal");
showModal(<RunCommandModal repoId={plan.repo!} />);
}}>
Run Command
</Button>
</Tooltip>
</Flex>
<Tabs
defaultActiveKey="1"
+11 -1
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from "react";
import { Repo } from "../../gen/ts/v1/config_pb";
import { Col, Empty, Flex, Row, Spin, TabsProps, Tabs, Tooltip, Typography } from "antd";
import { Col, Empty, Flex, Row, Spin, TabsProps, Tabs, Tooltip, Typography, Button } from "antd";
import { OperationList } from "../components/OperationList";
import { OperationTree } from "../components/OperationTree";
import { MAX_OPERATION_HISTORY, STATS_OPERATION_HISTORY } from "../constants";
@@ -14,10 +14,12 @@ import { SpinButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
import { useAlertApi } from "../components/Alerts";
import { LineChart } from "@mui/x-charts";
import { useShowModal } from "../components/ModalManager";
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
const [config, setConfig] = useConfig();
const showModal = useShowModal();
// Task handlers
const handleIndexNow = async () => {
@@ -99,6 +101,14 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
Compute Stats
</SpinButton>
</Tooltip>
<Tooltip title="Open a restic shell to run commands on the repository.">
<Button type="default" onClick={async () => {
const { RunCommandModal } = await import("./RunCommandModal");
showModal(<RunCommandModal repoId={repo.id!} />);
}}>
Run Command
</Button>
</Tooltip>
</Flex>
<Tabs
defaultActiveKey={items[0].key}
+82
View File
@@ -0,0 +1,82 @@
import { Button, Input, Modal, Space } from "antd";
import React from "react";
import { useShowModal } from "../components/ModalManager";
import { backrestService } from "../api";
import { SpinButton } from "../components/SpinButton";
import { ConnectError } from "@connectrpc/connect";
interface Invocation {
command: string;
output: string;
error: string;
}
export const RunCommandModal = ({ repoId }: { repoId: string }) => {
const showModal = useShowModal();
const [command, setCommand] = React.useState("");
const [running, setRunning] = React.useState(false);
const [invocations, setInvocations] = React.useState<Invocation[]>([]);
const handleCancel = () => {
showModal(null);
}
const doExecute = async () => {
if (running) return;
setRunning(true);
const newInvocation = { command, output: "", error: "" };
setInvocations((invocations) => [newInvocation, ...invocations]);
let segments: string[] = [];
try {
for await (const bytes of backrestService.runCommand({ repoId, command })) {
const output = new TextDecoder("utf-8").decode(bytes.value);
segments.push(output);
setInvocations((invocations) => {
const copy = [...invocations];
copy[0] = {
...copy[0],
output: segments.join(""),
}
return copy;
});
}
} catch (e: any) {
setInvocations((invocations) => {
const copy = [...invocations];
copy[0] = {
...copy[0],
error: (e as Error).message,
}
return copy;
});
} finally {
setRunning(false);
}
}
return <Modal
open={true}
onCancel={handleCancel}
title={"Run Command in repo " + repoId}
width="80vw"
footer={[]}
>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="Run a restic comamnd e.g. 'help' to print help text" onChange={(e) => setCommand(e.target.value)} onKeyUp={(e) => {
if (e.key === "Enter") {
doExecute();
}
}} />
<SpinButton type="primary" onClickAsync={doExecute}>Execute</SpinButton>
</Space.Compact>
{invocations.map((invocation, i) => <div key={i}>
{invocation.output ? <pre>{invocation.output}</pre> : null}
{invocation.error ? <pre style={{ color: "red" }}>{invocation.error}</pre> : null}
</div>)}
</Modal>
}