diff --git a/webui/src/components/HooksFormList.tsx b/webui/src/components/HooksFormList.tsx
index d002c8af..2b421a25 100644
--- a/webui/src/components/HooksFormList.tsx
+++ b/webui/src/components/HooksFormList.tsx
@@ -4,6 +4,20 @@ import { Button, Card, Collapse, CollapseProps, Form, FormListFieldData, Input,
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Rule } from 'antd/es/form';
+export interface HookFormData {
+ hooks: {
+ conditions: string[];
+ }[];
+}
+
+export interface HookFields {
+ conditions: string[];
+ actionCommand?: any;
+ actionGotify?: any;
+ actionDiscord?: any;
+ actionWebhook?: any;
+}
+
export const hooksListTooltipText = <>
Hooks are actions that can execute on backup lifecycle events.
@@ -34,19 +48,15 @@ export const hooksListTooltipText = <>
>
+
/**
* HooksFormList is a UI component for editing a list of hooks that can apply either at the repo level or at the plan level.
*/
-export const HooksFormList = (props: { hooks: Hook[] }) => {
- const [hooks, _] = useState([...props.hooks] || []);
-
- return
+export const HooksFormList = () => {
+ return
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => {
- console.log(index, field);
- const hook = hooks[index];
- if (!hook) return null;
return
Hook {index}
{
/>
>
} size="small" >
-
+
{
]}
/>
-
+ {
+ return prevValues.hooks[index] !== curValues.hooks[index];
+ }}>
+
+
})}
@@ -79,11 +92,7 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
content={<>
{hookTypes.map((hookType, index) => {
return {
- const hook = new Hook({
- action: hookType.action
- });
- hooks.push(hook);
- add(hook);
+ add(structuredClone(hookType.template));
}}>
{hookType.name}
@@ -105,78 +114,79 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
const hookTypes: {
name: string,
- action: typeof Hook.prototype.action,
+ template: HookFields,
}[] = [
{
- name: "Command", action: {
- case: "actionCommand",
- value: new Hook_Command({
+ name: "Command", template: {
+ actionCommand: {
command: "echo {{ .ShellEscape .Summary }}",
- }),
- }
+ },
+ conditions: [],
+ },
},
{
- name: "Discord", action: {
- case: "actionDiscord",
- value: new Hook_Discord({
+ name: "Discord", template: {
+ actionDiscord: {
webhookUrl: "",
template: "{{ .Summary }}",
- }),
+ },
+ conditions: [],
}
},
{
- name: "Gotify", action: {
- case: "actionGotify",
- value: new Hook_Gotify({
+ name: "Gotify", template: {
+ actionGotify: {
baseUrl: "",
token: "",
template: "{{ .Summary }}",
titleTemplate: "Backrest {{ .EventName .Event }} in plan {{ .Plan.Id }}",
- }),
+ },
+ conditions: [],
}
}
];
-const HookBuilder = ({ field, hook }: { field: FormListFieldData, hook: Hook }) => {
- let component: React.ReactNode;
- switch (hook.action.case) {
- case "actionDiscord":
- return <>
-
- Discord Webhook} />
-
- Text Template:
-
-
-
- >
- case "actionCommand":
- return <>
-
- Script:
-
-
-
-
- >
- case "actionGotify":
- return <>
-
- Gotify Base URL} />
-
-
- Gotify Token} />
-
-
- Title Template} />
-
- Text Template:
-
-
-
- >
- default:
- return Unknown hook {hook.action.case}
+const HookBuilder = ({ field }: { field: FormListFieldData }) => {
+ const form = Form.useFormInstance();
+ const hookData = form.getFieldValue(["hooks", field.name]) as HookFields;
+
+ if (hookData.actionDiscord) {
+ return <>
+
+ Discord Webhook} />
+
+ Text Template:
+
+
+
+ >
+ } else if (hookData.actionCommand) {
+ return <>
+
+ Script:
+
+
+
+
+ >
+ } else if (hookData.actionGotify) {
+ return <>
+
+ Gotify Base URL} />
+
+
+ Gotify Token} />
+
+
+ Title Template} />
+
+ Text Template:
+
+
+
+ >
+ } else {
+ return Unknown hook
}
}
diff --git a/webui/src/lib/formutil.ts b/webui/src/lib/formutil.ts
index 2cfaddce..7384dba5 100644
--- a/webui/src/lib/formutil.ts
+++ b/webui/src/lib/formutil.ts
@@ -3,7 +3,8 @@ import type { ValidateErrorEntity } from "rc-field-form/lib/interface";
export const validateForm = async (form: FormInstance) => {
try {
- return await form.validateFields();
+ await form.validateFields();
+ return form.getFieldsValue();
} catch (e: any) {
if (e.errorFields) {
const firstError = (e as ValidateErrorEntity).errorFields?.[0]
diff --git a/webui/src/views/AddPlanModal.tsx b/webui/src/views/AddPlanModal.tsx
index d42af624..83562266 100644
--- a/webui/src/views/AddPlanModal.tsx
+++ b/webui/src/views/AddPlanModal.tsx
@@ -14,7 +14,7 @@ import {
Collapse,
FormInstance,
} from "antd";
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
import { Plan, RetentionPolicy } from "../../gen/ts/v1/config_pb";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
@@ -30,13 +30,16 @@ import { backrestService } from "../api";
export const AddPlanModal = ({
template,
}: {
- template: Partial | null;
+ template: Plan | null;
}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
const [config, setConfig] = useConfig();
- const [form] = Form.useForm();
+ const [form] = Form.useForm();
+ useEffect(() => {
+ form.setFieldsValue(template ? JSON.parse(template.toJsonString()) : {});
+ }, [template])
if (!config) {
return null;
@@ -77,7 +80,12 @@ export const AddPlanModal = ({
setConfirmLoading(true);
try {
- let plan = new Plan(await validateForm(form));
+ let planFormData = await validateForm(form);
+ const plan = new Plan().fromJsonString(JSON.stringify(planFormData), { ignoreUnknownFields: false });
+
+ if (plan.retention && plan.retention.equals(new RetentionPolicy())) {
+ delete plan.retention;
+ }
// Merge the new plan (or update) into the config
if (template) {
@@ -330,14 +338,13 @@ export const AddPlanModal = ({
{/* Plan.retention */}
-
-
+
{/* Plan.hooks */}
Hooks}
>
-
+
@@ -360,145 +367,133 @@ export const AddPlanModal = ({
)}
-
+
>
);
};
-const RetentionPolicyView = ({ form, policy }: { policy?: RetentionPolicy, form: FormInstance }) => {
- enum PolicyType {
- TimeBased,
- CountBased,
- None,
- }
+const RetentionPolicyView = () => {
+ const form = Form.useFormInstance();
+ const retention = form.getFieldValue("retention") as RetentionPolicy | undefined;
+ const [mode, setMode] = useState(!retention ? 2 : retention.keepLastN ? 0 : 1);
- policy = policy || new RetentionPolicy();
-
- let defaultPolicyType = PolicyType.None;
- if (policy.keepLastN) {
- defaultPolicyType = PolicyType.CountBased;
- } else if (policy) {
- defaultPolicyType = PolicyType.TimeBased;
- }
-
- const [policyType, setPolicyType] = useState(defaultPolicyType);
-
- let elem = null;
- switch (policyType) {
- case PolicyType.TimeBased:
- elem = (
-
-
-
-
- Yearly}
- type="number"
- />
-
-
- Monthly}
- type="number"
- />
-
-
- Weekly}
- type="number"
- />
-
-
-
-
- Daily}
- type="number"
- />
-
-
- Hourly}
- type="number"
- />
-
-
-
-
- );
- break;
- case PolicyType.CountBased:
- elem = (
-
- Count} type="number" />
-
- );
- break;
- case PolicyType.None:
- elem = All backups are retained e.g. for append-only repos. Ensure that you manually forget / prune backups elsewhere. Backrest will register forgets performed externally on the next backup.
+ let elem: React.ReactNode = null;
+ if (mode === 2) {
+ elem = All backups are retained e.g. for append-only repos. Ensure that you manually forget / prune backups elsewhere. Backrest will register forgets performed externally on the next backup.
;
+ } else if (mode === 0) {
+ elem = (
+
+ Count} type="number" />
+
+ );
+ } else {
+ elem = (
+
+
+
+
+ Yearly}
+ type="number"
+ />
+
+
+ Monthly}
+ type="number"
+ />
+
+
+ Weekly}
+ type="number"
+ />
+
+
+
+
+ Daily}
+ type="number"
+ />
+
+
+ Hourly}
+ type="number"
+ />
+
+
+
+
+ );
}
return (
<>
- {
- setPolicyType(e.target.value);
- if (e.target.value === PolicyType.None) {
- form.resetFields(["retention"]);
- }
- }}
- >
-
+ {
+ const selected = e.target.value;
+ if (selected === 0) {
+ setMode(0);
+ form.setFieldValue("retention", { keepLastN: 30 });
+ } else if (selected === 1) {
+ setMode(1);
+ form.setFieldValue("retention", { keepYearly: 0, keepMonthly: 3, keepWeekly: 4, keepDaily: 7, keepHourly: 24 });
+ } else {
+ setMode(2);
+ form.setFieldValue("retention", null);
+ }
+ }}>
+
By Count
-
+
By Time Period
-
+
None
@@ -509,7 +504,7 @@ const RetentionPolicyView = ({ form, policy }: { policy?: RetentionPolicy, form:
{elem}
-
+
>
);
};
diff --git a/webui/src/views/AddRepoModal.tsx b/webui/src/views/AddRepoModal.tsx
index e832bef1..f97e9de9 100644
--- a/webui/src/views/AddRepoModal.tsx
+++ b/webui/src/views/AddRepoModal.tsx
@@ -13,9 +13,9 @@ import {
FormInstance,
Collapse,
} from "antd";
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
-import { Repo } from "../../gen/ts/v1/config_pb";
+import { Hook, Repo } from "../../gen/ts/v1/config_pb";
import { URIAutocomplete } from "../components/URIAutocomplete";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { useAlertApi } from "../components/Alerts";
@@ -24,6 +24,7 @@ import { backrestService } from "../api";
import {
HooksFormList,
hooksListTooltipText,
+ HookFormData,
} from "../components/HooksFormList";
import { ConfirmButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
@@ -31,13 +32,16 @@ import { useConfig } from "../components/ConfigProvider";
export const AddRepoModal = ({
template,
}: {
- template: Partial | null;
+ template: Repo | null;
}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
const [config, setConfig] = useConfig();
- const [form] = Form.useForm();
+ const [form] = Form.useForm();
+ useEffect(() => {
+ form.setFieldsValue(template ? JSON.parse(template.toJsonString()) : {});
+ }, [template])
if (!config) {
return null;
@@ -88,7 +92,8 @@ export const AddRepoModal = ({
setConfirmLoading(true);
try {
- let repo = await validateForm(form);
+ let repoFormData = await validateForm(form);
+ const repo = new Repo().fromJsonString(JSON.stringify(repoFormData), { ignoreUnknownFields: false });
if (template !== null) {
// We are in the edit repo flow, update the repo in the config
@@ -97,7 +102,7 @@ export const AddRepoModal = ({
alertsApi.error("Can't update repo, not found");
return;
}
- config.repos![idx] = new Repo(repo);
+ config.repos![idx] = repo;
setConfig(await backrestService.setConfig(config));
showModal(null);
alertsApi.success("Updated repo " + repo.uri);
@@ -166,7 +171,6 @@ export const AddRepoModal = ({
hasFeedback
name="id"
label="Repo Name"
- initialValue={template ? template.id : ""}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
@@ -219,7 +223,6 @@ export const AddRepoModal = ({
hasFeedback
name="uri"
label="Repository URI"
- initialValue={template ? template.uri : ""}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
@@ -239,7 +242,6 @@ export const AddRepoModal = ({
hasFeedback
name="password"
- initialValue={template ? template.password : ""}
validateTrigger={["onChange", "onBlur"]}
>
@@ -276,16 +278,15 @@ export const AddRepoModal = ({
},
},
]}
- initialValue={template ? template.env : []}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => (
+ console.log("FIELD: ", field),
remove(field.name)}
+ onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
@@ -313,7 +314,7 @@ export const AddRepoModal = ({
add()}
+ onClick={() => add("THIS IS A NEW VALUE")}
style={{ width: "90%" }}
icon={ }
>
@@ -330,7 +331,6 @@ export const AddRepoModal = ({
{(fields, { add, remove }, { errors }) => (
<>
@@ -339,7 +339,6 @@ export const AddRepoModal = ({
remove(field.name)}
+ onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
@@ -398,7 +397,7 @@ export const AddRepoModal = ({
>
Hooks}
>
-
+
@@ -438,9 +437,7 @@ export const AddRepoModal = ({
label: "Repo Config as JSON",
children: (
- {new Repo(form.getFieldsValue()).toJsonString({
- prettySpaces: 2,
- })}
+ {JSON.stringify(form.getFieldsValue(), undefined, 2)}
),
},
@@ -461,13 +458,20 @@ const expectedEnvVars: { [scheme: string]: string[] } = {
gs: ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_PROJECT_ID"],
};
-const envVarSetValidator = (form: FormInstance, envVars: string[]) => {
+const envVarSetValidator = (form: FormInstance, 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 "";
diff --git a/webui/src/views/App.tsx b/webui/src/views/App.tsx
index f8ed2fa1..bfac2a98 100644
--- a/webui/src/views/App.tsx
+++ b/webui/src/views/App.tsx
@@ -121,7 +121,7 @@ export const App: React.FC = () => {
{
setAuthToken("");
window.location.reload();
diff --git a/webui/src/views/GettingStartedGuide.tsx b/webui/src/views/GettingStartedGuide.tsx
index 55f760e6..c60d8570 100644
--- a/webui/src/views/GettingStartedGuide.tsx
+++ b/webui/src/views/GettingStartedGuide.tsx
@@ -11,6 +11,7 @@ export const GettingStartedGuide = () => {
<>
Getting Started
+ Backrest documentation on GitHub
Overview