feat: repo can be created through UI

This commit is contained in:
garethgeorge
2023-11-11 20:43:00 -08:00
parent 8c955f9384
commit da8d4e812e
23 changed files with 1270 additions and 116 deletions
+38
View File
@@ -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;
};
+49
View File
@@ -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
View File
@@ -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>
);
};
+14 -7
View File
@@ -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/",
});
};
+102
View File
@@ -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>
</>
);
};
+316
View File
@@ -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
View File
@@ -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} />);
},
};
}),
];