mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-25 09:25:36 +00:00
feat: repo can be created through UI
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { Spin } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { createContext } from "react";
|
||||
|
||||
const ModalContext = createContext<{
|
||||
model: React.ReactNode | null;
|
||||
setModel: (model: React.ReactNode | null) => void;
|
||||
}>({
|
||||
model: null,
|
||||
setModel: () => {
|
||||
throw new Error("add a ModelContextProvider to your hierarchy");
|
||||
},
|
||||
});
|
||||
|
||||
export const ModalContextProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [modal, setModals] = React.useState<React.ReactNode | null>([]);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
model: modal,
|
||||
setModel: setModals,
|
||||
}}
|
||||
>
|
||||
{modal}
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useShowModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
return context.setModel;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AutoComplete } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
import { StringList } from "../../gen/ts/types/value.pb";
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
export const URIAutocomplete = (props: React.PropsWithChildren) => {
|
||||
const [value, setValue] = useState("");
|
||||
const [options, setOptions] = useState<{ value: string }[]>([]);
|
||||
const [showOptions, setShowOptions] = useState<{ value: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowOptions(options.filter((o) => o.value.indexOf(value) !== -1));
|
||||
}, [options]);
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setValue(value);
|
||||
|
||||
const lastSlash = value.lastIndexOf("/");
|
||||
if (lastSlash !== -1) {
|
||||
value = value.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
ResticUI.PathAutocomplete({ value: value + "/" }, { pathPrefix: "/api" })
|
||||
.then((res: StringList) => {
|
||||
if (!res.values) {
|
||||
return;
|
||||
}
|
||||
const vals = res.values.map((v) => {
|
||||
return {
|
||||
value: value + "/" + v,
|
||||
};
|
||||
});
|
||||
setOptions(vals);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Path autocomplete error: ", e);
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return <AutoComplete options={showOptions} onSearch={onChange} {...props} />;
|
||||
};
|
||||
+4
-1
@@ -4,11 +4,14 @@ import { App } from "./views/App";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import ErrorBoundary from "antd/es/alert/ErrorBoundary";
|
||||
import { AlertContextProvider } from "./components/Alerts";
|
||||
import { ModalContextProvider } from "./components/ModalManager";
|
||||
|
||||
const Root = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AlertContextProvider>{children}</AlertContextProvider>
|
||||
<AlertContextProvider>
|
||||
<ModalContextProvider>{children}</ModalContextProvider>
|
||||
</AlertContextProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { atom, useSetRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { Config, Repo } from "../../gen/ts/v1/config.pb";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
|
||||
export const configState = atom({
|
||||
@@ -8,10 +8,17 @@ export const configState = atom({
|
||||
});
|
||||
|
||||
export const fetchConfig = async (): Promise<Config> => {
|
||||
return await ResticUI.GetConfig(
|
||||
{},
|
||||
{
|
||||
pathPrefix: "/api/",
|
||||
}
|
||||
);
|
||||
return await ResticUI.GetConfig({}, { pathPrefix: "/api/" });
|
||||
};
|
||||
|
||||
export const addRepo = async (repo: Repo): Promise<Config> => {
|
||||
return await ResticUI.AddRepo(repo, {
|
||||
pathPrefix: "/api/",
|
||||
});
|
||||
};
|
||||
|
||||
export const setConfig = async (config: Config): Promise<Config> => {
|
||||
return await ResticUI.SetConfig(config, {
|
||||
pathPrefix: "/api/",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Form, Modal, Input, Typography } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
import { Plan } from "../../gen/ts/v1/config.pb";
|
||||
|
||||
const nameRegex = /^[a-zA-Z0-9\-_ ]+$/;
|
||||
|
||||
export const AddPlanModal = ({
|
||||
template,
|
||||
}: {
|
||||
template: Partial<Plan> | null;
|
||||
}) => {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const showModal = useShowModal();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
template = template || {};
|
||||
|
||||
const handleOk = () => {
|
||||
setConfirmLoading(true);
|
||||
setTimeout(() => {
|
||||
showModal(null);
|
||||
setConfirmLoading(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
showModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={true}
|
||||
title="Add Plan"
|
||||
onOk={handleOk}
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form layout={"vertical"} autoComplete="off" form={form}>
|
||||
{/* Plan.id */}
|
||||
<Form.Item<Plan>
|
||||
hasFeedback
|
||||
name="id"
|
||||
label="Plan Name"
|
||||
initialValue={template.id}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input plan name",
|
||||
},
|
||||
{
|
||||
pattern: nameRegex,
|
||||
message: "Invalid symbol",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* Plan.repo */}
|
||||
<Form.Item<Plan>
|
||||
hasFeedback
|
||||
name="repo"
|
||||
label="Repo Name"
|
||||
initialValue={template.repo}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input repo name",
|
||||
},
|
||||
{
|
||||
pattern: nameRegex,
|
||||
message: "Invalid symbol",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* Plan.paths */}
|
||||
|
||||
{/* Plan.excludes */}
|
||||
|
||||
{/* Plan.cron */}
|
||||
|
||||
{/* Plan.retention */}
|
||||
|
||||
<Form.Item shouldUpdate label="Preview">
|
||||
{() => (
|
||||
<Typography>
|
||||
<pre>{JSON.stringify(form.getFieldsValue(), null, 2)}</pre>
|
||||
</Typography>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
Form,
|
||||
Modal,
|
||||
Input,
|
||||
Typography,
|
||||
AutoComplete,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
import { Repo } from "../../gen/ts/v1/config.pb";
|
||||
import { URIAutocomplete } from "../components/URIAutocomplete";
|
||||
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
|
||||
export const AddRepoModel = ({
|
||||
template,
|
||||
}: {
|
||||
template: Partial<Repo> | null;
|
||||
}) => {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const showModal = useShowModal();
|
||||
const alertsApi = useAlertApi()!;
|
||||
const [form] = Form.useForm<Repo>();
|
||||
|
||||
template = template || {};
|
||||
|
||||
const handleOk = () => {
|
||||
const errors = form
|
||||
.getFieldsError()
|
||||
.map((e) => e.errors)
|
||||
.flat();
|
||||
if (errors.length > 0) {
|
||||
alertsApi.warning("Please fix form errors " + errors.join(", "));
|
||||
return;
|
||||
}
|
||||
setConfirmLoading(true);
|
||||
|
||||
const repo = form.getFieldsValue() as Repo;
|
||||
|
||||
if (template === null) {
|
||||
// We are in the create repo flow, create the new repo via the service
|
||||
ResticUI.AddRepo(repo, {
|
||||
pathPrefix: "/api",
|
||||
})
|
||||
.then((res) => {
|
||||
showModal(null);
|
||||
alertsApi.success("Added repo " + repo.uri);
|
||||
})
|
||||
.catch((e) => {
|
||||
alertsApi.error("Error adding repo: " + e.message, 15);
|
||||
})
|
||||
.finally(() => {
|
||||
setConfirmLoading(false);
|
||||
});
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
showModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={true}
|
||||
title={template ? "Add Restic Repository" : "Edit Restic Repository"}
|
||||
onOk={handleOk}
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form layout={"vertical"} autoComplete="off" form={form}>
|
||||
{/* Repo.id */}
|
||||
<Form.Item<Repo>
|
||||
hasFeedback
|
||||
name="id"
|
||||
label="Repo Name"
|
||||
initialValue={template.id}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input plan name",
|
||||
},
|
||||
{
|
||||
pattern: nameRegex,
|
||||
message: "Invalid symbol",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<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">
|
||||
restic docs
|
||||
</a>{" "}
|
||||
for more info.
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Item<Repo>
|
||||
hasFeedback
|
||||
name="uri"
|
||||
label="Repo URI"
|
||||
initialValue={template.id}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input repo URI",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
|
||||
{/* Repo.password */}
|
||||
<Form.Item<Repo>
|
||||
hasFeedback
|
||||
name="password"
|
||||
label="Password"
|
||||
initialValue={template.password}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input repo name",
|
||||
},
|
||||
{
|
||||
pattern: nameRegex,
|
||||
message: "Invalid symbol",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* Repo.env */}
|
||||
<Form.List
|
||||
name="env"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, envVars) => {
|
||||
let uri = form.getFieldValue("uri");
|
||||
return await envVarSetValidator(uri, envVars);
|
||||
},
|
||||
},
|
||||
]}
|
||||
initialValue={[]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item
|
||||
label={index === 0 ? "Environment Variables" : ""}
|
||||
required={false}
|
||||
key={field.key}
|
||||
>
|
||||
<Form.Item
|
||||
{...field}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
initialValue={""}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
pattern: /^[\w-]+=.*$/,
|
||||
message:
|
||||
"Environment variable must be in format KEY=VALUE",
|
||||
},
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="KEY=VALUE" style={{ width: "60%" }} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined
|
||||
className="dynamic-delete-button"
|
||||
onClick={() => remove(field.name)}
|
||||
style={{ paddingLeft: "5px" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item
|
||||
label={fields.length === 0 ? "Environment Variables" : ""}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add()}
|
||||
style={{ width: "60%" }}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
Set Environment Variable
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
{/* Repo.flags */}
|
||||
<Form.List name="flags" initialValue={[]}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item
|
||||
label={index === 0 ? "(Advanced) Flag Overrides" : ""}
|
||||
required={false}
|
||||
key={field.key}
|
||||
>
|
||||
<Form.Item
|
||||
{...field}
|
||||
validateTrigger={["onChange", "onBlur"]}
|
||||
initialValue={""}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
pattern: /^\-\-[A-Za-z0-9_\-]*$/,
|
||||
message:
|
||||
"Value should be a CLI flag e.g. see restic --help",
|
||||
},
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="--flag" style={{ width: "60%" }} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined
|
||||
className="dynamic-delete-button"
|
||||
onClick={() => remove(field.name)}
|
||||
style={{ paddingLeft: "5px" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item
|
||||
label={fields.length === 0 ? "(Advanced) Flag Overrides" : ""}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add()}
|
||||
style={{ width: "60%" }}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
Set Environment Variable
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.Item shouldUpdate label="Preview">
|
||||
{() => (
|
||||
<Typography>
|
||||
<pre>{JSON.stringify(form.getFieldsValue(), null, 2)}</pre>
|
||||
</Typography>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const nameRegex = /^[a-zA-Z0-9\-_ ]+$/;
|
||||
|
||||
const expectedEnvVars: { [scheme: string]: string[] } = {
|
||||
s3: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
b2: ["B2_ACCOUNT_ID", "B2_ACCOUNT_KEY"],
|
||||
};
|
||||
|
||||
const envVarSetValidator = (uri: string | undefined, envVars: string[]) => {
|
||||
if (!uri) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
let schemeIdx = uri.indexOf(":");
|
||||
if (schemeIdx === -1) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let scheme = uri.substring(0, schemeIdx);
|
||||
let expected = expectedEnvVars[scheme];
|
||||
if (!expected) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let missing: string[] = [];
|
||||
for (let e of expected) {
|
||||
if (!envVars.includes(e)) {
|
||||
missing.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"Missing env vars " + missing.join(", ") + " for scheme " + scheme
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
+28
-4
@@ -11,6 +11,9 @@ import { configState, fetchConfig } from "../state/config";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { AlertContextProvider, useAlertApi } from "../components/Alerts";
|
||||
import { useShowModal, useShowSpinner } from "../components/ModalManager";
|
||||
import { AddPlanModal } from "./AddPlanModel";
|
||||
import { AddRepoModel } from "./AddRepoModel";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -21,14 +24,20 @@ export const App: React.FC = () => {
|
||||
|
||||
const [config, setConfig] = useRecoilState(configState);
|
||||
const alertApi = useAlertApi()!;
|
||||
const showModal = useShowModal();
|
||||
|
||||
useEffect(() => {
|
||||
showModal(<Spin spinning={true} fullscreen />);
|
||||
|
||||
fetchConfig()
|
||||
.then((config) => {
|
||||
setConfig(config);
|
||||
})
|
||||
.catch((err) => {
|
||||
alertApi.error(err.message, 60);
|
||||
alertApi.error(err.message, 0);
|
||||
})
|
||||
.finally(() => {
|
||||
showModal(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -50,9 +59,10 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
</Sider>
|
||||
<Layout style={{ padding: "0 24px 24px" }}>
|
||||
<Breadcrumb style={{ margin: "16px 0" }}>
|
||||
<Breadcrumb.Item>Home</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
<Breadcrumb
|
||||
style={{ margin: "16px 0" }}
|
||||
items={[{ title: "Home" }]}
|
||||
></Breadcrumb>
|
||||
<Content
|
||||
style={{
|
||||
padding: 24,
|
||||
@@ -70,6 +80,8 @@ export const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
const showModal = useShowModal();
|
||||
|
||||
if (!config) return [];
|
||||
|
||||
const configPlans = config.plans || [];
|
||||
@@ -80,12 +92,18 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
key: "add-plan",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Plan",
|
||||
onClick: () => {
|
||||
showModal(<AddPlanModal template={null} />);
|
||||
},
|
||||
},
|
||||
...configPlans.map((plan) => {
|
||||
return {
|
||||
key: "p-" + plan.id,
|
||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||
label: plan.id,
|
||||
onClick: () => {
|
||||
showModal(<AddPlanModal template={plan} />);
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -95,12 +113,18 @@ const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
key: "add-repo",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Repo",
|
||||
onClick: () => {
|
||||
showModal(<AddRepoModel template={null} />);
|
||||
},
|
||||
},
|
||||
...configRepos.map((repo) => {
|
||||
return {
|
||||
key: "r-" + repo.id,
|
||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||
label: repo.id,
|
||||
onClick: () => {
|
||||
showModal(<AddRepoModel template={repo} />);
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user