Merge pull request #545 from Hornwitser/remote-host-config

Remote host config
This commit is contained in:
Hornwitser authored and GitHub committed 2024-01-16 04:18:20 +01:00
commit c2a3d4361a
21 files changed
+540 -180

No files matched your search

-5
View File
@@ -107,9 +107,7 @@ Sent by the controller when the WebSocket connection has been opened.
Host handshake for establishing a new connection session.
- `token: string` - Authentication token.
- `agent: string` - Human readable string of the software connecting.
- `version: string` - The protocol version of the host, e.g. "2.0.0".
- `name: string` - Name of the host.
- `id: integer` - ID of the host.
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the host.
@@ -119,7 +117,6 @@ Host handshake for establishing a new connection session.
Control handshake for enstablishing a new connection session.
- `token: string` - Authentication token.
- `agent: string` - Human readable string of the software connecting.
- `version: string` - The protocol version of the control, e.g. "2.0.0".
#### `resume`
@@ -243,9 +240,7 @@ Host registering a new connection session.
"type": "register_host",
"data": {
"token": "xyzabc123",
"agent": "Clusterio Host",
"version": "2.0.0",
"name": "Foo",
"id": 123
}
}
+1 -3
View File
@@ -24,7 +24,6 @@ const queryLogTime = new lib.Summary(
* @alias module:controller/src/ControlConnection
*/
export default class ControlConnection extends BaseConnection {
private _agent: string;
private _version: string;
logTransport: lib.LinkTransport | null = null;
logSubscriptions = {
@@ -38,7 +37,7 @@ export default class ControlConnection extends BaseConnection {
declare connector: WsServerConnector;
constructor(
registerData: { agent:string, version:string },
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: ControllerUser, // The user making this connection.
@@ -46,7 +45,6 @@ export default class ControlConnection extends BaseConnection {
) {
super(connector, controller);
this._agent = registerData.agent;
this._version = registerData.version;
this.connector.on("connect", () => {
+37 -34
View File
@@ -16,10 +16,7 @@ import HostInfo from "./HostInfo";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private _agent: string;
private _id: number;
private _name: string;
private _version: string;
private info: HostInfo;
plugins: Map<string, string>;
constructor(
@@ -29,28 +26,24 @@ export default class HostConnection extends BaseConnection {
) {
super(connector, controller);
this._agent = registerData.agent;
this._id = registerData.id;
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._checkPluginVersions();
const previousHostInfo = this._controller.hosts.get(this._id);
const currentHostInfo = new HostInfo(
this._agent,
this._id,
this._name,
this._version,
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostInfo(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
this.plugins,
true,
registerData.publicAddress ?? "",
previousHostInfo?.publicAddress,
previousHostInfo?.tokenValidAfter,
Date.now(),
0,
false,
);
this._controller.hosts.set(this._id, currentHostInfo);
this._controller.hostsUpdated([currentHostInfo]);
this._controller.hosts.set(this.id, this.info);
this._controller.hostsUpdated([this.info]);
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
// eslint-disable-next-line no-loop-func
@@ -65,7 +58,7 @@ export default class HostConnection extends BaseConnection {
// Update status to unknown for instances on this host.
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") !== this._id) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
}
@@ -76,10 +69,11 @@ export default class HostConnection extends BaseConnection {
}
this._controller.instanceDetailsUpdated(instances);
currentHostInfo.connected = false;
this._controller.hostsUpdated([currentHostInfo]);
this.info.connected = false;
this._controller.hostsUpdated([this.info]);
});
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
@@ -104,7 +98,7 @@ export default class HostConnection extends BaseConnection {
case lib.Address.instance:
let instance = this._controller.instances.get(message.src.id);
if (!instance || instance.config.get("instance.assigned_host") !== this._id) {
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
throw new lib.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
@@ -121,21 +115,24 @@ export default class HostConnection extends BaseConnection {
for (let [name, version] of this.plugins) {
let info = pluginInfos.get(name);
if (!info) {
logger.warn(`Host ${this._name} has plugin ${name} ${version} which the controller does not have`);
logger.warn(
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which the ` +
"controller does not have",
);
continue;
}
if (info.version !== version) {
logger.warn(
`Host ${this._name} has plugin ${name} ${version} which does not match the version of this ` +
`plugin on the controller (${info.version})`
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which does not match ` +
`the version of this plugin on the controller (${info.version})`
);
}
}
for (let [name, info] of pluginInfos) {
if (!this.plugins.has(name)) {
logger.warn(`Host ${this._name} is missing plugin ${name} ${info.version}`);
logger.warn(`Host ${this.info.name} (${this.id}) is missing plugin ${name} ${info.version}`);
}
}
}
@@ -151,7 +148,13 @@ export default class HostConnection extends BaseConnection {
* @returns {number} host ID.
*/
get id() {
return this._id;
return this.info.id;
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._controller.hostsUpdated([this.info]);
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
@@ -161,7 +164,7 @@ export default class HostConnection extends BaseConnection {
// or is not assigned to the host it originated from if it was
// reassigned or deleted while the connection to the host it was
// originally on was down at the time.
if (!instance || instance.config.get("instance.assigned_host") !== this._id) {
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
logger.warn(`Got bogus update for instance id ${request.instanceId}`);
return;
}
@@ -187,7 +190,7 @@ export default class HostConnection extends BaseConnection {
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
// Push updated instance configs
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") === this._id) {
if (instance.config.get("instance.assigned_host") === this.id) {
await this.send(
new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host"))
);
@@ -203,7 +206,7 @@ export default class HostConnection extends BaseConnection {
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
if (controllerInstance) {
// Check if this instance is assigned somewhere else.
if (controllerInstance.config.get("instance.assigned_host") !== this._id) {
if (controllerInstance.config.get("instance.assigned_host") !== this.id) {
await this.send(
new lib.InstanceUnassignInternalRequest(instanceConfig.get("instance.id"))
);
@@ -225,7 +228,7 @@ export default class HostConnection extends BaseConnection {
continue;
}
instanceConfig.set("instance.assigned_host", this._id);
instanceConfig.set("instance.assigned_host", this.id);
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
@@ -301,8 +304,8 @@ export default class HostConnection extends BaseConnection {
async handleLogMessageEvent(event: lib.LogMessageEvent) {
this._controller.clusterLogger.log({
...event.info,
host_id: this._id,
host_name: this._name,
host_id: this.id,
host_name: this.info.name,
});
}
-6
View File
@@ -6,8 +6,6 @@ import { HostDetails } from "@clusterio/lib";
*/
export default class HostInfo {
constructor(
/** Agent this host last connected with */
public agent: string,
/** Id of this host */
public id: number,
/** Version this host last connected with */
@@ -29,7 +27,6 @@ export default class HostInfo {
) { }
static jsonSchema = Type.Object({
"agent": Type.String(),
"id": Type.Number(),
"name": Type.String(),
"version": Type.String(),
@@ -43,7 +40,6 @@ export default class HostInfo {
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.agent,
json.id,
json.name,
json.version,
@@ -58,7 +54,6 @@ export default class HostInfo {
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
agent: this.agent,
id: this.id,
name: this.name,
version: this.version,
@@ -73,7 +68,6 @@ export default class HostInfo {
toHostDetails() {
return new HostDetails(
this.agent,
this.version,
this.name,
this.id,
+3 -3
View File
@@ -261,11 +261,11 @@ ${err.stack}`
}
logger.info(
`WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}`
`WsServer | registered host ${data.id} using version ${data.version}`
);
if (data.agent === "Clusterio Host" && data.version !== packageVersion) {
if (data.version !== packageVersion) {
logger.warn(
`Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` +
`Host ${data.id} connected using version ${data.version} which does not match the ` +
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
);
}
-1
View File
@@ -38,7 +38,6 @@ class ControlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
"clusterioctl",
version,
)
)
+144 -1
View File
@@ -58,7 +58,7 @@ async function configToKeyVal(data: string) {
function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.InstanceConfig,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
@@ -289,6 +289,149 @@ hostCommands.add(new lib.Command({
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
-3
View File
@@ -51,11 +51,8 @@ export class HostConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.hostConfig.get("host.controller_token"),
"Clusterio Host",
version,
this.hostConfig.get("host.name"),
this.hostConfig.get("host.id"),
this.hostConfig.get("host.public_address"),
plugins,
)
)
+37
View File
@@ -288,6 +288,9 @@ export default class Host extends lib.Link {
this.config = hostConfig;
this.config.on("fieldChanged", (name, curr, prev) => {
if (name === "host.name" || name === "host.public_address") {
this.sendHostUpdate();
}
lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev);
});
@@ -301,6 +304,7 @@ export default class Host extends lib.Link {
return;
}
this.sendHostUpdate();
this.updateInstances().catch((err) => {
if (err instanceof lib.SessionLost) {
return undefined;
@@ -341,6 +345,9 @@ export default class Host extends lib.Link {
});
}
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
@@ -423,6 +430,25 @@ export default class Host extends lib.Link {
}
}
async handleHostConfigGetRequest() {
return this.config.toRemote("control");
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
async handleSyncUserListsEvent(event: lib.SyncUserListsEvent) {
let updateList = (list: Set<string>, updatedList: Set<string>, Event: lib.EventClass<unknown>) => {
let added = new Set(updatedList);
@@ -970,6 +996,17 @@ export default class Host extends lib.Link {
await fs.remove(instanceInfo.path);
}
sendHostUpdate() {
this.send(
new lib.HostInfoUpdateEvent(
new lib.HostInfoUpdate(
this.config.get("host.name"),
this.config.get("host.public_address"),
),
),
);
}
/**
* Discover available instances
*
+2 -10
View File
@@ -211,26 +211,20 @@ export class MessageHello extends Message {
export class RegisterHostData {
constructor(
public token: string,
public agent: string,
public version: string,
public name: string,
public id: number,
public publicAddress: string | undefined,
public plugins: Record<string, string>,
) { }
static jsonSchema = Type.Object({
"token": Type.String(),
"agent": Type.String(),
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"publicAddress": Type.Optional(Type.String()),
"plugins": Type.Record(Type.String(), Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.token, json.agent, json.version, json.name, json.id, json.publicAddress, json.plugins);
return new this(json.token, json.version, json.id, json.plugins);
}
}
@@ -254,18 +248,16 @@ export class MessageRegisterHost extends Message {
export class RegisterControlData {
constructor(
public token: string,
public agent: string,
public version: string,
) { }
static jsonSchema = Type.Object({
"token": Type.String(),
"agent": Type.String(),
"version": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.token, json.agent, json.version);
return new this(json.token, json.version);
}
}
+95 -4
View File
@@ -1,10 +1,65 @@
import { Type, Static } from "@sinclair/typebox";
import { jsonArray, StringEnum } from "./composites";
import { jsonArray, plainJson, StringEnum } from "./composites";
import { CollectorResultSerialized } from "../prometheus";
import { HostConfig } from "../config/definitions";
export class HostConfigGetRequest {
declare ["constructor"]: typeof HostConfigGetRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.get_config" as const;
static Response = plainJson(HostConfig.jsonSchema);
}
export class HostConfigSetFieldRequest {
declare ["constructor"]: typeof HostConfigSetFieldRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public field: string,
public value: string,
) { }
static jsonSchema = Type.Object({
"field": Type.String(),
"value": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.field, json.value);
}
}
export class HostConfigSetPropRequest {
declare ["constructor"]: typeof HostConfigSetPropRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public field: string,
public prop: string,
public value?: unknown,
) { }
static jsonSchema = Type.Object({
"field": Type.String(),
"prop": Type.String(),
"value": Type.Optional(Type.Unknown()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.field, json.prop, json.value);
}
}
export class HostDetails {
constructor(
public agent: string,
public version: string,
public name: string,
public id: number,
@@ -17,7 +72,6 @@ export class HostDetails {
) { }
static jsonSchema = Type.Object({
"agent": Type.String(),
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
@@ -30,7 +84,6 @@ export class HostDetails {
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.agent,
json.version,
json.name,
json.id,
@@ -52,6 +105,44 @@ export class HostListRequest {
static Response = jsonArray(HostDetails);
}
export class HostInfoUpdate {
constructor(
public name: string,
public publicAddress: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"publicAddress": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.publicAddress,
);
}
}
export class HostInfoUpdateEvent {
declare ["constructor"]: typeof HostInfoUpdateEvent;
static type = "event" as const;
static src = "host" as const;
static dst = "controller" as const;
constructor(
public update: HostInfoUpdate,
) { }
static jsonSchema = Type.Object({
"update": HostInfoUpdate.jsonSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(HostInfoUpdate.fromJSON(json.update));
}
}
export class HostUpdatesEvent {
declare ["constructor"]: typeof HostUpdatesEvent;
static type = "event" as const;
+4
View File
@@ -32,7 +32,11 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
subscriptions.SubscriptionRequest,
host.HostConfigGetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
host.HostListRequest,
host.HostInfoUpdateEvent,
host.HostUpdatesEvent,
host.HostMetricsRequest,
host.ControllerConnectionEvent,
+10
View File
@@ -136,6 +136,16 @@ definePermission({
"Subscribe to be notified when the system metrics detailing cpu, memory and disk usage for the " +
"controller and hosts are updated.",
});
definePermission({
name: "core.host.get_config",
title: "Get host configs",
description: "Get the config of hosts.",
});
definePermission({
name: "core.host.update_config",
title: "Modify host configs",
description: "Modify the host config or entries of the host config.",
});
definePermission({
name: "core.host.list",
title: "List hosts",
+141 -100
View File
@@ -1,7 +1,9 @@
import * as lib from "@clusterio/lib";
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd";
import {
Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography,
} from "antd";
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
const { Title } = Typography;
@@ -25,20 +27,36 @@ function getInitialValues(config: lib.Config<any>, props: BaseConfigTreeProps) {
return initialValues;
}
function renderInput(def: lib.FieldDefinition) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.InstanceConfig;
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => 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());
let [errorFields, setErrorFields] = useState(new Map<string, string>());
let [applying, setApplying] = useState(false);
const available = props.available ?? true;
async function updateConfig() {
let serializedConfig = await props.retrieveConfig();
@@ -47,27 +65,29 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
return newConfig;
}
useEffect(() => {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}, [props.id]);
if (available) {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}
}, [props.id, available]);
function renderInput(def: lib.FieldDefinition) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
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 <Spin/>;
return <>
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
<Card size="small" className="config-container">
<Spin size="large" />;
</Card>
</>;
}
let initialValues: any = {};
@@ -91,12 +111,109 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
}
}
const [propsMap, treeData] = computeTreeData(
form,
initialValues,
errorFields,
config,
setConfig,
props.ConfigClass,
onValuesChange,
);
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
request = props.setProp(fieldName, prop, value);
} else {
request = props.setField(field, value === null ? "" : String(value));
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err: any) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<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(
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,
) {
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[];
@@ -111,7 +228,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
let treeData = [];
let propsMap = new Map();
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
for (let [fieldName, def] of Object.entries(props.ConfigClass.fieldDefinitions)) {
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
const [groupName, field] = lib.splitOn(".", fieldName);
let group = groups.get(groupName);
if (!group) {
@@ -203,7 +320,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
let propName = form.getFieldValue(`${newPropPath}.name`);
let propValue = form.getFieldValue(`${newPropPath}.value`);
if (!Object.prototype.hasOwnProperty.call(value, propName)) {
let newConfig = props.ConfigClass.fromJSON(config!.toJSON(), "control");
let newConfig = ConfigClass.fromJSON(config!.toJSON(), "control");
(newConfig as lib.Config<any>).setProp(fieldName, propName, null);
setConfig(newConfig);
}
@@ -246,81 +363,5 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
treeData.push(treeNode);
}
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
request = props.setProp(fieldName, prop, value);
} else {
request = props.setField(field, value === null ? "" : String(value));
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err: any) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<Form
size="small"
form={form}
initialValues={initialValues}
onValuesChange={onValuesChange}
>
<Tree
treeData={treeData}
selectable={false}
defaultExpandAll={true}
filterTreeNode={(node) => changedFields.has(node.key)}
/>
</Form>
</Card>
</>;
return [propsMap, treeData] as [typeof propsMap, typeof treeData];
}
@@ -0,0 +1,39 @@
import React, { useContext } from "react";
import * as lib from "@clusterio/lib";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
export default function HostConfigTree(props: { id: number, available: boolean }) {
let control = useContext(ControlContext);
async function retrieveConfig() {
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
}
async function setField(field: string, value: any) {
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
}
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
}
return <BaseConfigTree
ConfigClass={lib.HostConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
id={props.id}
available={props.available}
/>;
}
@@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
@@ -83,7 +84,6 @@ export default function HostViewPage() {
<Descriptions.Item label="Memory"><MetricMemoryUsed metrics={metrics} /></Descriptions.Item>
<Descriptions.Item label="Disk Usage"><MetricDiskRatio metrics={metrics} /></Descriptions.Item>
<Descriptions.Item label="Disk"><MetricDiskUsed metrics={metrics} /></Descriptions.Item>
<Descriptions.Item label="Agent">{host["agent"]}</Descriptions.Item>
<Descriptions.Item label="Version">{host["version"]}</Descriptions.Item>
{
host.tokenValidAfter
@@ -101,6 +101,7 @@ export default function HostViewPage() {
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole hosts={[hostId]} />
</>}
{account.hasPermission("core.host.get_config") && <HostConfigTree id={hostId} available={host.connected} />}
<PluginExtra component="HostViewPage" host={host} />
</PageLayout>;
}
@@ -251,12 +251,6 @@ export default function HostsPage() {
render: (_, host) => <MetricDiskUsed metrics={metrics.get(host.id)} />,
hidden: !showNumbers,
},
{
title: "Agent",
dataIndex: "agent",
sorter: (a, b) => strcmp(a.agent, b.agent),
responsive: ["lg"],
},
{
title: "Version",
dataIndex: "version",
+1 -1
View File
@@ -287,7 +287,7 @@ body {
color: #000;
}
.config-container {
.config-container, .config-container .ant-tree {
background-color: #111111;
}
-1
View File
@@ -30,7 +30,6 @@ export class ControlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this.token,
"Clusterio Web UI",
packageJson.version,
)
)
+23
View File
@@ -230,6 +230,29 @@ describe("Integration of Clusterio", function() {
await execCtl("host list");
});
});
describe("host config", function() {
it("changes host config", async function() {
await execCtl("host config set 4 host.name My-Host");
const result = await execCtl("host config list 4");
assert(/My-Host/.test(result.stdout), "New host name not in config output");
});
it("should not allow setting host.controller_token", async function() {
await assert.rejects(
execCtl("host config set 4 host.controller_token xyz"),
/Field 'host\.controller_token' is not accessible/,
);
});
it("should not allow setting host.id", async function() {
await assert.rejects(
execCtl("host config set 4 host.id 200"),
/Setting 'host\.id' while host is running is not supported/,
);
});
it("should not leak host.controller_token", async function() {
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
});
});
describe("host generate-token", function() {
it("runs", async function() {
await execCtl("host generate-token --id 42");
+1 -1
View File
@@ -16,7 +16,7 @@ describe("lib/command", function() {
let testControl = new mock.MockControl(controlConnector);
let testController = new lib.Link(controllerConnector);
testController.handle(
lib.HostListRequest, () => [new lib.HostDetails("test", "0.1", "Test Host", 11, false)]
lib.HostListRequest, () => [new lib.HostDetails("0.1", "Test Host", 11, false)]
);
testController.handle(
lib.InstanceDetailsListRequest, () => [new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped")]