mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-25 18:36:31 +00:00
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
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:
@@ -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");
|
||||
@@ -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 e.g. /home/user/documents /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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user