mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
- Unlinking a field enables Apply on its own and sends the current value. - The link button no longer toggles the checkbox of its label. - Link and disconnect icons for all four states, pending ones highlighted. - instance.is_template is hidden. - ctl create takes --as-template. - Clearer names for the InstanceManager inheritance helpers. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
import React, { useContext, useEffect, useState } from "react";
|
|
import {
|
|
Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography,
|
|
} from "antd";
|
|
import DisconnectOutlined from "@ant-design/icons/DisconnectOutlined";
|
|
import LinkOutlined from "@ant-design/icons/LinkOutlined";
|
|
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import ControlContext from "./ControlContext";
|
|
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
|
|
import type { Control } from "../util/websocket";
|
|
import notify from "../util/notify";
|
|
|
|
const { Title } = Typography;
|
|
|
|
|
|
function getInitialValues(config: lib.Config<any>, props: BaseConfigTreeProps) {
|
|
let initialValues: any = {};
|
|
for (let [name, def] of Object.entries(config.constructor.fieldDefinitions)) {
|
|
if (!config.canAccess(name, lib.ConfigAccess.read)) {
|
|
continue;
|
|
}
|
|
|
|
let value = config.get(name) as any;
|
|
if (def.type === "object") {
|
|
for (let prop of Object.keys(value ?? {})) {
|
|
initialValues[`${name}.${prop}`] = JSON.stringify(value[prop]);
|
|
}
|
|
} else {
|
|
initialValues[name] = value;
|
|
}
|
|
}
|
|
return initialValues;
|
|
}
|
|
|
|
function renderInput(inputComponents: Map<string, InputComponent>, def: lib.FieldDefinition, readonly: boolean) {
|
|
if (def.inputComponent && inputComponents.has(def.inputComponent)) {
|
|
// Field.Item will provide the value and onChange props to the component.
|
|
type InputPartial = React.ComponentClass<Omit<InputComponentProps, "value" | "onChange">>;
|
|
const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial;
|
|
return <CustomInput fieldDefinition={def} disabled={readonly} />;
|
|
}
|
|
if (def.type === "boolean") {
|
|
return <Checkbox disabled={readonly} />;
|
|
}
|
|
if (def.type === "string") {
|
|
if (def.credential) {
|
|
return <Input.Password autoComplete={def.autoComplete ?? "new-password"} disabled={readonly} />;
|
|
}
|
|
if (def.enum) {
|
|
return <Select
|
|
showSearch
|
|
style={{ minWidth: 175 }}
|
|
options={def.enum.map(value => ({ label: value, value: value }))}
|
|
allowClear={def.optional}
|
|
disabled={readonly}
|
|
/>;
|
|
}
|
|
return <Input autoComplete={def.autoComplete} disabled={readonly} />;
|
|
}
|
|
if (def.type === "number") {
|
|
return <InputNumber autoComplete={def.autoComplete} disabled={readonly} />;
|
|
}
|
|
|
|
return `Unknown type ${def.type}`;
|
|
}
|
|
|
|
/** Lets fields follow the value of another config, inherited values are locked until unlinking */
|
|
export type ConfigInheritance = {
|
|
canInherit: (config: lib.Config<any>, field: string) => boolean;
|
|
isInherited: (config: lib.Config<any>, field: string, prop?: string) => boolean;
|
|
inherit: (field: string, prop?: string) => Promise<void>;
|
|
templateValue: (field: string, prop?: string) => unknown;
|
|
notice?: (config: lib.Config<any>) => React.ReactNode;
|
|
};
|
|
|
|
type Linking = Map<string, [field: string, prop?: string]>;
|
|
|
|
type LinkState = {
|
|
inheritance?: ConfigInheritance;
|
|
unlinking: Set<string>;
|
|
setUnlinking: (unlinking: Set<string>) => void;
|
|
linking: Linking;
|
|
setLinking: (linking: Linking) => void;
|
|
};
|
|
|
|
type BaseConfigTreeProps = {
|
|
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
|
|
inheritance?: ConfigInheritance;
|
|
retrieveConfig: () => Promise<lib.ConfigSchema>;
|
|
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
|
|
id?: number;
|
|
onApply?: () => void;
|
|
available?: boolean;
|
|
};
|
|
export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
|
let [config, setConfig] = useState<lib.Config<any>|null>(null);
|
|
let [form] = Form.useForm();
|
|
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
|
|
let [errorFields, setErrorFields] = useState(new Map<string, string>());
|
|
let [applying, setApplying] = useState(false);
|
|
let [unlinking, setUnlinking] = useState<Set<string>>(new Set());
|
|
let [linking, setLinking] = useState<Linking>(new Map());
|
|
const available = props.available ?? true;
|
|
const control = useContext(ControlContext);
|
|
|
|
async function updateConfig() {
|
|
let serializedConfig = await props.retrieveConfig();
|
|
const newConfig = props.ConfigClass.fromJSON(serializedConfig, "control");
|
|
setConfig(newConfig);
|
|
return newConfig;
|
|
}
|
|
useEffect(() => {
|
|
if (available) {
|
|
updateConfig().then(updatedConfig => {
|
|
form.setFieldsValue(getInitialValues(updatedConfig, props));
|
|
});
|
|
}
|
|
}, [props.id, available]);
|
|
|
|
if (!available) {
|
|
return <>
|
|
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
|
|
<Card size="small" className="config-container">
|
|
Config is currently unavailable.
|
|
</Card>
|
|
</>;
|
|
}
|
|
|
|
if (config === null) {
|
|
return <>
|
|
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
|
|
<Card size="small" className="config-container">
|
|
<Spin size="large" />;
|
|
</Card>
|
|
</>;
|
|
}
|
|
|
|
let initialValues: any = {};
|
|
function onValuesChange(changedValues: object) {
|
|
let newChangedFields = new Set(changedFields);
|
|
let changed = false;
|
|
for (let [key, value] of Object.entries(changedValues)) {
|
|
if (key.endsWith(":add.name") || key.endsWith(":add.value")) {
|
|
continue;
|
|
}
|
|
const def = config?.constructor.fieldDefinitions[key];
|
|
if (!def?.credential && initialValues[key] === value) {
|
|
changed = newChangedFields.delete(key) || changed;
|
|
} else {
|
|
changed = !newChangedFields.has(key) || changed;
|
|
newChangedFields.add(key);
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setChangedFields(newChangedFields);
|
|
}
|
|
}
|
|
|
|
async function applyChanges(fields: Record<string, string | Record<string, string>>) {
|
|
if (Object.keys(fields).length) {
|
|
await props.setConfig(fields);
|
|
}
|
|
for (const [field, prop] of linking.values()) {
|
|
await props.inheritance!.inherit(field, prop);
|
|
}
|
|
setChangedFields(new Set());
|
|
setUnlinking(new Set());
|
|
setLinking(new Map());
|
|
}
|
|
|
|
const [propsMap, treeData] = computeTreeData(
|
|
control,
|
|
form,
|
|
initialValues,
|
|
errorFields,
|
|
config,
|
|
setConfig,
|
|
props.ConfigClass,
|
|
onValuesChange,
|
|
{ inheritance: props.inheritance, unlinking, setUnlinking, linking, setLinking },
|
|
);
|
|
|
|
return <>
|
|
<Title level={5} style={{ marginTop: 16 }}>
|
|
Config
|
|
<Space style={{ float: "right" }}>
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
loading={applying}
|
|
onClick={async () => {
|
|
setApplying(true);
|
|
const fields: Record<string, string | Record<string, string>> = {};
|
|
// Unlinked fields are sent even when unchanged so they stop following the template
|
|
for (const field of new Set([...changedFields, ...unlinking])) {
|
|
const value = form.getFieldValue(field);
|
|
if (propsMap.has(field)) {
|
|
const [fieldName, prop] = propsMap.get(field)!;
|
|
const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>;
|
|
fields[fieldName] = fieldValue;
|
|
try {
|
|
fieldValue[prop] = value ? JSON.parse(value) : undefined;
|
|
} catch (err: any) {
|
|
setErrorFields(errors => {
|
|
const newErrors = new Map(errors);
|
|
newErrors.set(fieldName, err.message);
|
|
return newErrors;
|
|
});
|
|
}
|
|
|
|
} else {
|
|
fields[field] = value === null ? "" : String(value);
|
|
}
|
|
}
|
|
|
|
let applied = false;
|
|
try {
|
|
await applyChanges(fields);
|
|
applied = true;
|
|
} catch (err: any) {
|
|
notify(err.message, "error");
|
|
}
|
|
|
|
const newConfig = await updateConfig();
|
|
if (applied) {
|
|
// Linking can change other values
|
|
form.setFieldsValue(getInitialValues(newConfig, props));
|
|
}
|
|
setApplying(false);
|
|
|
|
if (props.onApply) {
|
|
props.onApply();
|
|
}
|
|
}}
|
|
disabled={changedFields.size === 0 && linking.size === 0 && unlinking.size === 0}
|
|
>
|
|
Apply
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
form.resetFields();
|
|
setChangedFields(new Set());
|
|
setUnlinking(new Set());
|
|
setLinking(new Map());
|
|
setErrorFields(new Map());
|
|
}}
|
|
>
|
|
Revert
|
|
</Button>
|
|
</Space>
|
|
</Title>
|
|
<Card size="small" className="config-container">
|
|
{props.inheritance?.notice?.(config)}
|
|
<Form
|
|
size="small"
|
|
form={form}
|
|
initialValues={initialValues}
|
|
onValuesChange={onValuesChange}
|
|
>
|
|
<Tree
|
|
treeData={treeData}
|
|
selectable={false}
|
|
defaultExpandAll={true}
|
|
filterTreeNode={(node) => changedFields.has(node.key)}
|
|
/>
|
|
</Form>
|
|
</Card>
|
|
</>;
|
|
}
|
|
|
|
|
|
function computeTreeData(
|
|
control: Control,
|
|
form: FormInstance<any>,
|
|
initialValues: any,
|
|
errorFields: Map<string, string>,
|
|
config: lib.Config<any>,
|
|
setConfig: (newConfig: lib.Config<any>) => void,
|
|
ConfigClass: BaseConfigTreeProps["ConfigClass"],
|
|
onValuesChange: (changedValues: object) => void,
|
|
link: LinkState,
|
|
) {
|
|
let restartTip = <Tooltip
|
|
className="ant-form-item-tooltip"
|
|
title="A restart is required for this setting to take effect"
|
|
><ReloadOutlined/></Tooltip>;
|
|
|
|
type ChildNode = {
|
|
key: string;
|
|
children: any[];
|
|
title?: React.ReactElement;
|
|
};
|
|
type TreeNode = {
|
|
key: string;
|
|
title: string;
|
|
children: ChildNode[];
|
|
};
|
|
|
|
function linkButton(key: string, fieldName: string, prop?: string) {
|
|
const { inheritance, unlinking, setUnlinking, linking, setLinking } = link;
|
|
if (!inheritance?.canInherit(config, fieldName) || !config.canAccess(fieldName, lib.ConfigAccess.write)) {
|
|
return null;
|
|
}
|
|
const button = (
|
|
title: string, icon: React.ReactNode, label: string, pending: boolean, onClick: () => void,
|
|
) => <Tooltip title={title}>
|
|
<Button
|
|
type={pending ? "link" : "text"} size="small" icon={icon} aria-label={`${label} ${key}`}
|
|
onClick={e => {
|
|
// The button sits in the label, which would toggle checkboxes
|
|
e.preventDefault();
|
|
onClick();
|
|
}}
|
|
/>
|
|
</Tooltip>;
|
|
const revert = () => {
|
|
form.setFieldValue(key, initialValues[key]);
|
|
onValuesChange({ [key]: initialValues[key] });
|
|
};
|
|
if (inheritance.isInherited(config, fieldName, prop)) {
|
|
if (!unlinking.has(key)) {
|
|
return button(
|
|
"Linked to the template. Click to unlink it and set your own value.", <LinkOutlined />, "Unlink",
|
|
false, () => setUnlinking(new Set([...unlinking, key])),
|
|
);
|
|
}
|
|
return button(
|
|
"Unlinked from the template when applied. Click to keep it linked.", <DisconnectOutlined />,
|
|
"Keep linked", true,
|
|
() => { setUnlinking(new Set([...unlinking].filter(k => k !== key))); revert(); },
|
|
);
|
|
}
|
|
if (!linking.has(key)) {
|
|
return button(
|
|
"Not linked to the template. Click to link it again.", <DisconnectOutlined />, "Link", false,
|
|
() => {
|
|
setLinking(new Map([...linking, [key, [fieldName, prop]]]));
|
|
revert();
|
|
// Show the value the field takes after apply
|
|
const value = inheritance.templateValue(fieldName, prop);
|
|
form.setFieldValue(key, prop === undefined ? value : JSON.stringify(value));
|
|
},
|
|
);
|
|
}
|
|
return button(
|
|
"Linked to the template when applied. Click to keep this value instead.", <LinkOutlined />,
|
|
"Keep unlinked", true,
|
|
() => { setLinking(new Map([...linking].filter(([k]) => k !== key))); revert(); },
|
|
);
|
|
}
|
|
function isReadonly(canWrite: boolean, key: string, fieldName: string, prop?: string) {
|
|
return !canWrite || link.linking.has(key) || Boolean(link.inheritance?.canInherit(config, fieldName))
|
|
&& link.inheritance!.isInherited(config, fieldName, prop)
|
|
&& !link.unlinking.has(key);
|
|
}
|
|
|
|
let treeData = [];
|
|
const propsMap = new Map<string, [fieldName: string, prop: string]>();
|
|
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
|
|
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
|
|
const [groupName, field] = lib.splitOn(".", fieldName);
|
|
let group = groups.get(groupName);
|
|
if (!group) {
|
|
group = {};
|
|
groups.set(groupName, group);
|
|
}
|
|
group[field] = def;
|
|
}
|
|
for (let [groupName, groupDefs] of groups) {
|
|
let treeNode: TreeNode = {
|
|
key: groupName,
|
|
title: groupName,
|
|
children: [],
|
|
};
|
|
|
|
for (let [field, def] of Object.entries(groupDefs)) {
|
|
if (def.hidden === true) {
|
|
continue;
|
|
}
|
|
|
|
const fieldName = `${groupName}.${field}`;
|
|
const canWrite = config.canAccess(fieldName, lib.ConfigAccess.write);
|
|
const canRead = config.canAccess(fieldName, lib.ConfigAccess.read);
|
|
if (!canWrite && !canRead) {
|
|
continue;
|
|
}
|
|
|
|
let value = config.get(fieldName) as any;
|
|
let childNode: ChildNode = {
|
|
key: fieldName,
|
|
children: [],
|
|
};
|
|
|
|
if (def.type === "object" && !control.inputComponents.has(def.inputComponent!)) {
|
|
let restartRequiredProps = new Set(def.restartRequiredProps || []);
|
|
childNode.title = <Form.Item
|
|
label={<>
|
|
{def.title || field}
|
|
{!def.restartRequiredProps && def.restartRequired && restartTip}
|
|
</>}
|
|
tooltip={def.description}
|
|
/>;
|
|
for (let prop of Object.keys(value ?? {})) {
|
|
let propPath = `${groupName}.${field}.${prop}`;
|
|
let restart = Boolean(
|
|
def.restartRequiredProps && Number(def.restartRequired) ^ Number(restartRequiredProps.has(prop))
|
|
);
|
|
childNode.children.push({
|
|
key: propPath,
|
|
title: <Form.Item
|
|
name={propPath}
|
|
label={<>
|
|
{`"${prop}"`}
|
|
{restart && restartTip}
|
|
{linkButton(propPath, fieldName, prop)}
|
|
</>}
|
|
dependencies={def.dependsOn}
|
|
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
|
|
if (!fieldValue.length) {
|
|
throw new Error("Will be removed");
|
|
}
|
|
|
|
try {
|
|
fieldValue = JSON.parse(fieldValue);
|
|
} catch (err) {
|
|
throw new Error("Must be valid json");
|
|
}
|
|
|
|
if (!def.validator) { return; }
|
|
config.stageProp(fieldName, prop, fieldValue);
|
|
|
|
for (const dependsOn of def.dependsOn ?? []) {
|
|
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
|
try {
|
|
if (propsMap.has(dependsOn)) {
|
|
const [name, otherProp] = propsMap.get(dependsOn)!;
|
|
config.stageProp(name, otherProp, JSON.parse(getFieldValue(dependsOn)));
|
|
} else if (dependsOnDef.type !== "object") {
|
|
config.stage(dependsOn, getFieldValue(dependsOn));
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
|
}
|
|
}
|
|
|
|
def.validator(fieldValue, config.staging);
|
|
}})]}
|
|
>
|
|
<Input className="json-input" disabled={isReadonly(canWrite, propPath, fieldName, prop)} />
|
|
</Form.Item>,
|
|
});
|
|
|
|
initialValues[propPath] = JSON.stringify(value[prop]);
|
|
propsMap.set(propPath, [fieldName, prop]);
|
|
}
|
|
|
|
if (canWrite) {
|
|
let newPropPath = `${groupName}.${field}:add`;
|
|
childNode.children.push({
|
|
key: newPropPath,
|
|
title: <Space>
|
|
<Form.Item
|
|
name={`${newPropPath}.name`}
|
|
noStyle={true}
|
|
>
|
|
<Input />
|
|
</Form.Item>:
|
|
<Form.Item
|
|
name={`${newPropPath}.value`}
|
|
noStyle={true}
|
|
>
|
|
<Input className="json-input" />
|
|
</Form.Item>
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
let propName = form.getFieldValue(`${newPropPath}.name`);
|
|
let propValue = form.getFieldValue(`${newPropPath}.value`);
|
|
if (!value || !Object.prototype.hasOwnProperty.call(value, propName)) {
|
|
let newConfig = ConfigClass.fromJSON(config!.toJSON(), "control");
|
|
(newConfig as lib.Config<any>).setProp(fieldName, propName, null);
|
|
setConfig(newConfig);
|
|
}
|
|
|
|
let propPath = `${groupName}.${field}.${propName}`;
|
|
link.setUnlinking(new Set([...link.unlinking, propPath]));
|
|
form.setFieldsValue({
|
|
[`${newPropPath}.name`]: "",
|
|
[`${newPropPath}.value`]: "",
|
|
[propPath]: propValue,
|
|
});
|
|
form.validateFields([propPath]);
|
|
onValuesChange({ [propPath]: propValue });
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Space>,
|
|
});
|
|
}
|
|
|
|
if (!childNode.children.length) {
|
|
continue;
|
|
}
|
|
|
|
} else {
|
|
childNode.title = <Form.Item
|
|
name={fieldName}
|
|
label={<>
|
|
{def.title || field}
|
|
{def.restartRequired && restartTip}
|
|
{linkButton(fieldName, fieldName)}
|
|
</>}
|
|
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
|
help={errorFields.get(fieldName)}
|
|
tooltip={def.description}
|
|
dependencies={def.dependsOn}
|
|
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
|
rules={[({getFieldValue}) => ({
|
|
validator: async (_, fieldValue) => {
|
|
if (!def.validator) { return; }
|
|
config.stage(fieldName, fieldValue);
|
|
|
|
for (const dependsOn of def.dependsOn ?? []) {
|
|
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
|
try {
|
|
if (propsMap.has(dependsOn)) {
|
|
const [name, prop] = propsMap.get(dependsOn)!;
|
|
config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn)));
|
|
} else if (dependsOnDef.type !== "object") {
|
|
config.stage(dependsOn, getFieldValue(dependsOn));
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
|
}
|
|
}
|
|
|
|
def.validator(fieldValue, config.staging);
|
|
},
|
|
})]}
|
|
>
|
|
{renderInput(control.inputComponents, def, isReadonly(canWrite, fieldName, fieldName))}
|
|
</Form.Item>;
|
|
initialValues[fieldName] = value;
|
|
}
|
|
|
|
treeNode.children.push(childNode);
|
|
}
|
|
|
|
treeData.push(treeNode);
|
|
}
|
|
|
|
return [propsMap, treeData] as [typeof propsMap, typeof treeData];
|
|
}
|