Files
backrest/webui/src/views/AddRepoModal.tsx
T
Gareth George b3cfef1405
Build Snapshot Release / build (push) Waiting to run
Release Please / release-please (push) Waiting to run
Test / test-nix (push) Waiting to run
Test / test-win (push) Waiting to run
fix: test repo configuration button
2025-01-22 22:47:46 -08:00

877 lines
27 KiB
TypeScript

import {
Form,
Modal,
Input,
Typography,
AutoComplete,
Tooltip,
Button,
Row,
Col,
Card,
InputNumber,
FormInstance,
Collapse,
Checkbox,
Select,
Space,
} from "antd";
import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
import {
CommandPrefix_CPUNiceLevel,
CommandPrefix_CPUNiceLevelSchema,
CommandPrefix_IONiceLevel,
CommandPrefix_IONiceLevelSchema,
type Repo,
RepoSchema,
Schedule_Clock,
} from "../../gen/ts/v1/config_pb";
import { URIAutocomplete } from "../components/URIAutocomplete";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { formatErrorAlert, useAlertApi } from "../components/Alerts";
import { namePattern, validateForm } from "../lib/formutil";
import { backrestService } from "../api";
import {
HooksFormList,
hooksListTooltipText,
} from "../components/HooksFormList";
import { ConfirmButton, SpinButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
import Cron from "react-js-cron";
import {
ScheduleDefaultsInfrequent,
ScheduleFormItem,
} from "../components/ScheduleFormItem";
import { isWindows } from "../state/buildcfg";
import { create, fromJson, JsonValue, toJson } from "@bufbuild/protobuf";
const repoDefaults = create(RepoSchema, {
prunePolicy: {
maxUnusedPercent: 10,
schedule: {
schedule: {
case: "cron",
value: "0 0 1 * *", // 1st of the month,
},
clock: Schedule_Clock.LAST_RUN_TIME,
},
},
checkPolicy: {
schedule: {
schedule: {
case: "cron",
value: "0 0 1 * *", // 1st of the month,
},
clock: Schedule_Clock.LAST_RUN_TIME,
},
},
commandPrefix: {
ioNice: CommandPrefix_IONiceLevel.IO_DEFAULT,
cpuNice: CommandPrefix_CPUNiceLevel.CPU_DEFAULT,
},
});
export const AddRepoModal = ({ template }: { template: Repo | null }) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
const [config, setConfig] = useConfig();
const [form] = Form.useForm<JsonValue>();
useEffect(() => {
const initVal = template
? toJson(RepoSchema, template, {
alwaysEmitImplicit: true,
})
: toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true });
form.setFieldsValue(initVal);
}, [template]);
if (!config) {
return null;
}
const handleDestroy = async () => {
setConfirmLoading(true);
try {
config.repos = config.repos || [];
if (!template) {
throw new Error("template not found");
}
// Check if still in use by a plan
for (const plan of config.plans || []) {
if (plan.repo === template.id) {
throw new Error("Can't delete repo, still in use by plan " + plan.id);
}
}
// Remove the plan from the config
const idx = config.repos.findIndex((r) => r.id === template.id);
if (idx === -1) {
throw new Error("failed to update config, plan to delete not found");
}
config.repos.splice(idx, 1);
// Update config and notify success.
setConfig(await backrestService.setConfig(config));
showModal(null);
alertsApi.success(
"Deleted repo " +
template.id +
" from config but files remain. To release storage delete the files manually. URI: " +
template.uri
);
} catch (e: any) {
alertsApi.error(formatErrorAlert(e, "Operation error: "), 15);
} finally {
setConfirmLoading(false);
}
};
const handleOk = async () => {
setConfirmLoading(true);
try {
let repoFormData = await validateForm(form);
const repo = fromJson(RepoSchema, repoFormData, {
ignoreUnknownFields: false,
});
if (template !== null) {
// We are in the update repo flow, update the repo via the service
setConfig(await backrestService.addRepo(repo));
showModal(null);
alertsApi.success("Updated repo configuration " + repo.uri);
} else {
// We are in the create repo flow, create the new repo via the service
setConfig(await backrestService.addRepo(repo));
showModal(null);
alertsApi.success("Added repo " + repo.uri);
}
try {
// Update the snapshots for the repo to confirm the config works.
// TODO: this operation is only used here, find a different RPC for this purpose.
await backrestService.listSnapshots({ repoId: repo.id });
} catch (e: any) {
alertsApi.error(
formatErrorAlert(
e,
"Failed to list snapshots for updated/added repo: "
),
10
);
}
} catch (e: any) {
alertsApi.error(formatErrorAlert(e, "Operation error: "), 10);
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
showModal(null);
};
return (
<>
<Modal
open={true}
onCancel={handleCancel}
title={template ? "Edit Restic Repository" : "Add Restic Repository"}
width="60vw"
footer={[
<Button loading={confirmLoading} key="back" onClick={handleCancel}>
Cancel
</Button>,
template != null ? (
<ConfirmButton
key="delete"
type="primary"
danger
onClickAsync={handleDestroy}
confirmTitle="Confirm Delete"
>
Delete
</ConfirmButton>
) : null,
<SpinButton
key="check"
onClickAsync={async () => {
let repoFormData = await validateForm(form);
console.log("checking repo", repoFormData);
const repo = fromJson(RepoSchema, repoFormData, {
ignoreUnknownFields: false,
});
try {
const exists = await backrestService.checkRepoExists(repo);
if (exists.value) {
alertsApi.success(
"Connected successfully to " +
repo.uri +
" and found an existing repo.",
10
);
} else {
alertsApi.success(
"Connected successfully to " +
repo.uri +
". No existing repo found at this location, a new one will be initialized",
10
);
}
} catch (e: any) {
alertsApi.error(formatErrorAlert(e, "Check error: "), 10);
}
}}
>
Test Configuration
</SpinButton>,
<Button
key="submit"
type="primary"
loading={confirmLoading}
onClick={handleOk}
>
Submit
</Button>,
]}
maskClosable={false}
>
<p>
See{" "}
<a
href="https://garethgeorge.github.io/backrest/introduction/getting-started"
target="_blank"
>
backrest getting started guide
</a>{" "}
for repository configuration instructions or check the{" "}
<a href="https://restic.readthedocs.io/" target="_blank">
restic documentation
</a>{" "}
for more details about repositories.
</p>
<br />
<Form
autoComplete="off"
form={form}
labelCol={{ span: 4 }}
wrapperCol={{ span: 18 }}
disabled={confirmLoading}
>
{/* Repo.id */}
<Tooltip
title={
"Unique ID that identifies this repo in the backrest UI (e.g. s3-mybucket). This cannot be changed after creation."
}
>
<Form.Item<Repo>
hasFeedback
name="id"
label="Repo Name"
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input repo name",
},
{
validator: async (_, value) => {
if (template) return;
if (config?.repos?.find((r) => r.id === value)) {
throw new Error();
}
},
message: "Repo with name already exists",
},
{
pattern: namePattern,
message:
"Name must be alphanumeric with dashes or underscores as separators",
},
]}
>
<Input
disabled={!!template}
placeholder={"repo" + ((config?.repos?.length || 0) + 1)}
/>
</Form.Item>
</Tooltip>
<Form.Item<Repo> name="guid" hidden>
<Input />
</Form.Item>
{/* Repo.uri */}
<Tooltip
title={
<>
Valid Repo URIs are:
<ul>
<li>Local filesystem path</li>
<li>S3 e.g. s3:// ...</li>
<li>SFTP e.g. sftp:user@host:/repo-path</li>
<li>
See{" "}
<a
href="https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#preparing-a-new-repository"
target="_blank"
>
restic docs
</a>{" "}
for more info.
</li>
</ul>
</>
}
>
<Form.Item<Repo>
hasFeedback
name="uri"
label="Repository URI"
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input repo URI",
},
]}
>
<URIAutocomplete disabled={!!template} />
</Form.Item>
</Tooltip>
{/* Repo.password */}
<Tooltip
title={
<>
This password that encrypts data in your repository.
<ul>
<li>
Recommended to pick a value that is 128 bits of entropy (20
chars or longer)
</li>
<li>
You may alternatively provide env variable credentials e.g.
RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or
RESTIC_PASSWORD_COMMAND.
</li>
<li>
Click [Generate] to seed a random password from your
browser's crypto random API.
</li>
</ul>
</>
}
>
<Form.Item label="Password">
<Row>
<Col span={16}>
<Form.Item<Repo>
hasFeedback
name="password"
validateTrigger={["onChange", "onBlur"]}
>
<Input disabled={!!template} />
</Form.Item>
</Col>
<Col
span={7}
offset={1}
style={{ display: "flex", justifyContent: "left" }}
>
<Button
type="text"
onClick={() => {
if (template) return;
form.setFieldsValue({
password: cryptoRandomPassword(),
});
}}
>
[Generate]
</Button>
</Col>
</Row>
</Form.Item>
</Tooltip>
{/* Repo.env */}
<Tooltip
title={
"Environment variables that are passed to restic (e.g. to provide S3 or B2 credentials). References to parent-process env variables are supported as FOO=${MY_FOO_VAR}."
}
>
<Form.Item label="Env Vars">
<Form.List
name="env"
rules={[
{
validator: async (_, envVars) => {
return await envVarSetValidator(form, envVars);
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Form.Item
{...field}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^[\w-]+=.*$/,
message:
"Environment variable must be in format KEY=VALUE",
},
]}
noStyle
>
<Input
placeholder="KEY=VALUE"
onBlur={() => form.validateFields()}
style={{ width: "90%" }}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add("")}
style={{ width: "90%" }}
icon={<PlusOutlined />}
>
Set Environment Variable
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
</Tooltip>
{/* Repo.flags */}
<Form.Item label="Flags">
<Form.List name="flags">
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => (
<Form.Item required={false} key={field.key}>
<Form.Item
{...field}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^\-\-?.*$/,
message:
"Value should be a CLI flag e.g. see restic --help",
},
]}
noStyle
>
<Input placeholder="--flag" style={{ width: "90%" }} />
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
icon={<PlusOutlined />}
>
Set Flag
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
{/* Repo.prunePolicy */}
<Form.Item
label={
<Tooltip
title={
<span>
The schedule on which prune operations are run for this
repository. Read{" "}
<a
href="https://restic.readthedocs.io/en/stable/060_forget.html#customize-pruning"
target="_blank"
>
the restic docs on customizing prune operations
</a>{" "}
for more details.
</span>
}
>
Prune Policy
</Tooltip>
}
>
<Form.Item
name={["prunePolicy", "maxUnusedPercent"]}
initialValue={10}
required={false}
>
<InputPercent
addonBefore={
<Tooltip title="The maximum percentage of the repo size that may be unused after a prune operation completes. High values reduce copying at the expense of storage.">
<div style={{ width: "12" }}>Max Unused After Prune</div>
</Tooltip>
}
/>
</Form.Item>
<ScheduleFormItem
name={["prunePolicy", "schedule"]}
defaults={ScheduleDefaultsInfrequent}
/>
</Form.Item>
{/* Repo.checkPolicy */}
<Form.Item
label={
<Tooltip
title={
<span>
The schedule on which check operations are run for this
repository. Restic check operations verify the integrity of
your repository by scanning the on-disk structures that make
up your backup data. Check can optionally be configured to
re-read and re-hash data, this is slow and can be bandwidth
expensive but will catch any bitrot or silent corruption in
the storage medium.
</span>
}
>
Check Policy
</Tooltip>
}
>
<Form.Item
name={["checkPolicy", "readDataSubsetPercent"]}
initialValue={0}
required={false}
>
<InputPercent
addonBefore={
<Tooltip title="The percentage of pack data in this repository that will be read and verified. Higher values will use more bandwidth (e.g. 100% will re-read the entire repository on each check).">
<div style={{ width: "12" }}>Read Data %</div>
</Tooltip>
}
/>
</Form.Item>
<ScheduleFormItem
name={["checkPolicy", "schedule"]}
defaults={ScheduleDefaultsInfrequent}
/>
</Form.Item>
{/* Repo.commandPrefix */}
{!isWindows && (
<Form.Item
label={
<Tooltip
title={
<span>
Modifiers for the backup operation e.g. set the CPU or IO
priority.
</span>
}
>
Command Modifiers
</Tooltip>
}
colon={false}
>
<Row>
<Col span={12} style={{ paddingLeft: "5px" }}>
<Tooltip
title={
<>
Available IO priority modes
<ul>
<li>
IO_BEST_EFFORT_LOW - runs at lower than default disk
priority (will prioritize other processes)
</li>
<li>
IO_BEST_EFFORT_HIGH - runs at higher than default
disk priority (top of disk IO queue)
</li>
<li>
IO_IDLE - only runs when disk bandwidth is idle
(e.g. no other operations are queued)
</li>
</ul>
</>
}
>
IO Priority:
<br />
<Form.Item
name={["commandPrefix", "ioNice"]}
required={false}
>
<Select
allowClear
style={{ width: "100%" }}
placeholder="Select an IO priority"
options={CommandPrefix_IONiceLevelSchema.values.map(
(v) => ({
label: v.name,
value: v.name,
})
)}
/>
</Form.Item>
</Tooltip>
</Col>
<Col span={12} style={{ paddingLeft: "5px" }}>
<Tooltip
title={
<>
Available CPU priority modes:
<ul>
<li>CPU_DEFAULT - no change in priority</li>
<li>
CPU_HIGH - higher than default priority (backrest
must be running as root)
</li>
<li>CPU_LOW - lower than default priority</li>
</ul>
</>
}
>
CPU Priority:
<br />
<Form.Item
name={["commandPrefix", "cpuNice"]}
required={false}
>
<Select
allowClear
style={{ width: "100%" }}
placeholder="Select a CPU priority"
options={CommandPrefix_CPUNiceLevelSchema.values.map(
(v) => ({
label: v.name,
value: v.name,
})
)}
/>
</Form.Item>
</Tooltip>
</Col>
</Row>
</Form.Item>
)}
<Form.Item
label={
<Tooltip
title={
"Auto-unlock will remove lockfiles at the start of forget and prune operations. " +
"This is potentially unsafe if the repo is shared by multiple client devices. Disabled by default."
}
>
Auto Unlock
</Tooltip>
}
name="autoUnlock"
valuePropName="checked"
>
<Checkbox />
</Form.Item>
<Form.Item
label={<Tooltip title={hooksListTooltipText}>Hooks</Tooltip>}
>
<HooksFormList />
</Form.Item>
<Form.Item shouldUpdate label="Preview">
{() => (
<Collapse
size="small"
items={[
{
key: "1",
label: "Repo Config as JSON",
children: (
<Typography>
<pre>
{JSON.stringify(form.getFieldsValue(), undefined, 2)}
</pre>
</Typography>
),
},
]}
/>
)}
</Form.Item>
</Form>
</Modal>
</>
);
};
const expectedEnvVars: { [scheme: string]: string[][] } = {
s3: [["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
b2: [["B2_ACCOUNT_ID", "B2_ACCOUNT_KEY"]],
azure: [
["AZURE_ACCOUNT_NAME", "AZURE_ACCOUNT_KEY"],
["AZURE_ACCOUNT_NAME", "AZURE_ACCOUNT_SAS"],
],
gs: [
["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_PROJECT_ID"],
["GOOGLE_ACCESS_TOKEN"],
],
};
const envVarSetValidator = (form: FormInstance<any>, envVars: string[]) => {
if (!envVars) {
return Promise.resolve();
}
let uri = form.getFieldValue("uri");
if (!uri) {
return Promise.resolve();
}
const envVarNames = envVars.map((e) => {
if (!e) {
return "";
}
let idx = e.indexOf("=");
if (idx === -1) {
return "";
}
return e.substring(0, idx);
});
// check that password is provided in some form
const password = form.getFieldValue("password");
if (
(!password || password.length === 0) &&
!envVarNames.includes("RESTIC_PASSWORD") &&
!envVarNames.includes("RESTIC_PASSWORD_COMMAND") &&
!envVarNames.includes("RESTIC_PASSWORD_FILE")
) {
return Promise.reject(
new Error(
"Missing repo password. Either provide a password or set one of the env variables RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE."
)
);
}
// find expected env for scheme
let schemeIdx = uri.indexOf(":");
if (schemeIdx === -1) {
return Promise.resolve();
}
let scheme = uri.substring(0, schemeIdx);
return checkSchemeEnvVars(scheme, envVarNames);
};
const cryptoRandomPassword = (): string => {
let vals = crypto.getRandomValues(new Uint8Array(64));
// 48 chars is at least log2(64) * 48 = ~288 bits of entropy.
return btoa(String.fromCharCode(...vals)).slice(0, 48);
};
const checkSchemeEnvVars = (
scheme: string,
envVarNames: string[]
): Promise<void> => {
let expected = expectedEnvVars[scheme];
if (!expected) {
return Promise.resolve();
}
const missingVarsCollection: string[][] = [];
for (let possibility of expected) {
const missingVars = possibility.filter(
(envVar) => !envVarNames.includes(envVar)
);
// If no env vars are missing, we have a full match and are good
if (missingVars.length === 0) {
return Promise.resolve();
}
// First pass: Only add those missing vars from sets where at least one existing env var already exists
if (missingVars.length < possibility.length) {
missingVarsCollection.push(missingVars);
}
}
// If we didn't find any env var set with a partial match, then add all expected sets
if (!missingVarsCollection.length) {
missingVarsCollection.push(...expected);
}
return Promise.reject(
new Error(
"Missing env vars " +
formatMissingEnvVars(missingVarsCollection) +
" for scheme " +
scheme
)
);
};
const formatMissingEnvVars = (partialMatches: string[][]): string => {
return partialMatches
.map((x) => {
if (x.length > 1) {
return `[ ${x.join(", ")} ]`;
}
return x[0];
})
.join(" or ");
};
const InputPercent = ({ ...props }) => {
return (
<InputNumber
step={1}
min={0}
max={100}
precision={2}
controls={false}
suffix="%"
{...props}
/>
);
};