chore: put a feature flag around multihost sync UI
Release Please / release-please (push) Has been cancelled
Release Preview / call-reusable-release (push) Has been cancelled
Test / test-nix (push) Has been cancelled
Test / test-win (push) Has been cancelled
Update Restic / update-restic-version (push) Has been cancelled

This commit is contained in:
Gareth
2025-11-14 22:13:38 -08:00
parent 039274e1f2
commit 84cb2a2193
3 changed files with 155 additions and 59 deletions
+5 -1
View File
@@ -6,4 +6,8 @@ export const uiBuildVersion = (
export const isDevBuild = uiBuildVersion === "dev-snapshot-build";
export const pathSeparator = isWindows ? "\\" : "/";
export const backendUrl = process.env.UI_BACKEND_URL || "./";
console.log(`UI OS: ${uios}, Build Version: ${uiBuildVersion}, Backend URL: ${backendUrl}`);
console.log(`UI OS: ${uios}, Build Version: ${uiBuildVersion}, Backend URL: ${backendUrl}`);
export const features = new Set<string>((process.env.UI_FEATURES || "").split(","));
// Feature flags
export const isMultihostSyncEnabled = features.has("multihost-sync");
+145 -54
View File
@@ -12,6 +12,7 @@ import {
Col,
Collapse,
Checkbox,
AutoComplete,
} from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { useShowModal } from "../components/ModalManager";
@@ -44,6 +45,101 @@ import {
import { clone, create, equals, fromJson, toJson } from "@bufbuild/protobuf";
import { formatDuration } from "../lib/formatting";
import { getMinimumCronDuration } from "../lib/cronutil";
import _ from "lodash";
import { StringList } from "../../gen/ts/types/value_pb";
import { isWindows } from "../state/buildcfg";
const { TextArea } = Input;
const sep = isWindows ? "\\" : "/";
const PathsTextArea = ({ value, onChange, ...props }: any) => {
const [options, setOptions] = useState<{ value: string }[]>([]);
const [currentLine, setCurrentLine] = useState("");
const [cursorPosition, setCursorPosition] = useState(0);
const handleSearch = _.debounce((searchValue: string) => {
if (!searchValue) {
setOptions([]);
return;
}
const lastSlash = searchValue.lastIndexOf(sep);
let searchPath = searchValue;
if (lastSlash !== -1) {
searchPath = searchValue.substring(0, lastSlash);
}
backrestService
.pathAutocomplete({ value: searchPath + sep })
.then((res: StringList) => {
if (!res.values) {
return;
}
const vals = res.values.map((v) => {
return {
value: searchPath + sep + v,
};
});
setOptions(vals.filter((o) => o.value.indexOf(searchValue) !== -1));
})
.catch((e) => {
console.log("Path autocomplete error: ", e);
});
}, 200);
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart || 0;
// Find the current line based on cursor position
const lines = newValue.substring(0, cursorPos).split("\n");
const currentLineValue = lines[lines.length - 1];
setCurrentLine(currentLineValue);
setCursorPosition(cursorPos);
// Trigger autocomplete for the current line
handleSearch(currentLineValue);
// Update the form value
if (onChange) {
onChange(newValue);
}
};
const onSelect = (selectedValue: string) => {
const lines = (value || "").split("\n");
const beforeCursor = (value || "").substring(0, cursorPosition);
const afterCursor = (value || "").substring(cursorPosition);
const linesBeforeCursor = beforeCursor.split("\n");
// Replace the current line with the selected value
linesBeforeCursor[linesBeforeCursor.length - 1] = selectedValue;
const newValue = linesBeforeCursor.join("\n") + afterCursor;
if (onChange) {
onChange(newValue);
}
setOptions([]);
};
return (
<AutoComplete
options={options}
onSelect={onSelect}
onSearch={() => {}} // We handle search in textarea change
{...props}
>
<TextArea
value={value}
onChange={handleTextAreaChange}
placeholder="Enter paths, one per line&#10;e.g.&#10;/home/user/documents&#10;/home/user/photos"
style={{ minHeight: 100 }}
autoSize={{ minRows: 3, maxRows: 10 }}
/>
</AutoComplete>
);
};
const planDefaults = create(PlanSchema, {
schedule: {
@@ -72,11 +168,19 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const [config, setConfig] = useConfig();
const [form] = Form.useForm();
useEffect(() => {
form.setFieldsValue(
template
? toJson(PlanSchema, template, { alwaysEmitImplicit: true })
: toJson(PlanSchema, planDefaults, { alwaysEmitImplicit: true })
);
const formData = template
? toJson(PlanSchema, template, { alwaysEmitImplicit: true })
: toJson(PlanSchema, planDefaults, { alwaysEmitImplicit: true });
// Convert paths array to newline-separated string for the textarea
const formDataObj = formData as any;
if (formDataObj?.paths && Array.isArray(formDataObj.paths)) {
formDataObj.pathsText = formDataObj.paths.join("\n");
} else {
formDataObj.pathsText = "";
}
form.setFieldsValue(formDataObj);
}, [template]);
if (!config) {
@@ -120,6 +224,18 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
try {
let planFormData = await validateForm(form);
// Convert pathsText back to paths array
if (planFormData.pathsText) {
planFormData.paths = planFormData.pathsText
.split("\n")
.map((path: string) => path.trim())
.filter((path: string) => path.length > 0);
delete planFormData.pathsText;
} else {
planFormData.paths = [];
}
const plan = fromJson(PlanSchema, planFormData, {
ignoreUnknownFields: false,
});
@@ -270,56 +386,31 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
</Form.Item>
{/* Plan.paths */}
<Form.Item label="Paths" required={true}>
<Form.List
name="paths"
rules={[]}
initialValue={template ? template.paths : []}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => {
const { key, ...restField } = field;
return (
<Form.Item key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
},
]}
noStyle
>
<URIAutocomplete
style={{ width: "90%" }}
onBlur={() => form.validateFields()}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
style={{ paddingLeft: "5px" }}
/>
</Form.Item>
<Form.Item
name="pathsText"
label="Paths"
required={true}
tooltip="Enter file paths to backup, one per line. Autocomplete is available as you type."
rules={[
{
validator: async (_, value) => {
if (!value || !value.trim()) {
throw new Error("Please enter at least one path to backup");
}
const paths = value
.split("\n")
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0);
if (paths.length === 0) {
throw new Error(
"Please enter at least one valid path to backup"
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
icon={<PlusOutlined />}
>
Add Path
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</>
)}
</Form.List>
}
},
},
]}
>
<PathsTextArea />
</Form.Item>
{/* Plan.excludes */}
+5 -4
View File
@@ -32,6 +32,7 @@ import {
import { PeerState } from "../../gen/ts/v1sync/syncservice_pb";
import { useSyncStates } from "../state/peerstates";
import { PeerStateConnectionStatusIcon } from "../components/SyncStateIcon";
import { isMultihostSyncEnabled } from "../state/buildcfg";
interface FormData {
auth: {
@@ -212,7 +213,7 @@ export const SettingsModal = () => {
peerStates={peerStates}
/>
),
// style: { display: "none" },
style: isMultihostSyncEnabled ? undefined : { display: "none" },
},
{
key: "last",
@@ -351,9 +352,9 @@ const MultihostIdentityForm: React.FC<{
status of a collections of systems.
</Typography.Paragraph>
<Typography.Paragraph italic>
This feature is experimental and may be subject to version incompatible
changes in the future which will require all instances to be updated at
the same time.
Warning: this feature is very experimental and may be subject to version
incompatible changes in the future which will require all instances to
be updated at the same time.
</Typography.Paragraph>
{/* Show the current instance's identity */}