From 2c74a67738fb9ae87ac983ce1a580c042df7dec4 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 15:46:19 +0100 Subject: [PATCH 1/6] Add location access control to config class Add a per field access parameter to the config class which specifies which of master, slave and control can access the given field. A location is added to the config class to specify which party owns it and fields which do not have an access parameter are not present in the config and will throw if attempted to be read from or writen to. The serialize, load, update, get, set and setProp are ammended with a location parameter which specifiy where the data is from or destined to and the access checks are performed to ensure remote access to the config is still limited by the access parameter of field. With this system in place the master config can be shared with control and slave connections without compromising secrets that should stay private. This is primarily the master authentication secret, but other plugins may define secrets it does not want to have shared with slave and/or control connections. --- docs/config-system.md | 10 + docs/writing-plugins.md | 10 +- packages/ctl/ctl.js | 6 +- packages/lib/config/classes.js | 197 ++++++++++++---- packages/lib/config/definitions.js | 8 + packages/master/master.js | 28 +-- packages/slave/slave.js | 14 +- .../src/components/InstanceViewPage.jsx | 12 +- .../web_ui/src/components/InstancesPage.jsx | 4 +- plugins/statistics_exporter/info.js | 1 + plugins/subspace_storage/info.js | 2 + test/lib/config/classes.js | 210 +++++++++++++++--- test/slave.js | 6 +- 13 files changed, 399 insertions(+), 109 deletions(-) diff --git a/docs/config-system.md b/docs/config-system.md index 058dba45..3b9c8278 100644 --- a/docs/config-system.md +++ b/docs/config-system.md @@ -28,6 +28,16 @@ after the groupName have been set and before the class has been finalized. The `define` method takes an object as argument with the following properties: +**access**: + The locations this config field can be read and modified from. This + is an array of strings which can contain the values `"master"`, + `"slave"` and `"control"`. If a party is missing in this array then + that party will not receive the value of this field when the config + is shared, and will be unable to modify the field. Takes the value + of `defaultAccess` set on the config group class if not passed. + Note that instance config groups should have both `"master"` and + `"slave"` for all of its fields or unexpected behaviour may occur. + **type**: The type of config value this field will support. The supported values are boolean, string, number, and object, and if an attept is diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 62b8c854..1f624fad 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -224,12 +224,13 @@ but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately. -Defining Configuration ----------------------- +Plugin Configuration +-------------------- Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you. You can -take advantage of it by subclassing `PluginConfigGroup`, setting the +take advantage of it by subclassing `PluginConfigGroup`, setting +`defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export. For example in info.js: @@ -237,6 +238,7 @@ the `info.js` export. For example in info.js: const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "foo_frobber"; MasterConfigGroup.define({ name: "level", @@ -261,7 +263,7 @@ for example in the MasterPluginClass: } The same applies for instance configs, replace "master" with "instance" -where appropriate. See [Configuration System](configuration-system.md) +where appropriate. See [Configuration System](config-system.md) for more details on how this system works. diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index beb4159a..a72ab6cd 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -104,13 +104,13 @@ instanceCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); if (args.id) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, })); @@ -792,7 +792,7 @@ async function startControl() { ; logger.verbose(`Loading config from ${args.config}`); - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); try { await controlConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/lib/config/classes.js b/packages/lib/config/classes.js index df5f2ae2..0394564a 100644 --- a/packages/lib/config/classes.js +++ b/packages/lib/config/classes.js @@ -7,6 +7,17 @@ const isDeepStrictEqual = require("../is_deep_strict_equal"); const { basicType } = require("@clusterio/lib/helpers"); +/** + * Invalid Access exception + * + * Exception class for when an access attempt is made to a field that + * does not permitt this access is done. + * + * @extends Error + * @memberof module:lib/config + */ +class InvalidAccess extends Error { }; + /** * Invalid Value exception * @@ -69,13 +80,24 @@ function splitOn(separator, string) { * @memberof module:lib/config */ class Config extends events.EventEmitter { - constructor() { + /** + * Create a new instance of the given config + * + * @param {string} location - + * Location to evaluate access for this instance from + */ + constructor(location) { super(); if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); } + if (typeof location !== "string") { + throw new Error("location must be a string"); + } + + this.location = location; this._groups = new Map(); this._unknownGroups = new Map(); } @@ -83,8 +105,8 @@ class Config extends events.EventEmitter { async init() { for (let [name, GroupClass] of this.constructor._groups) { if (!this._groups.has(name)) { - let group = new GroupClass(); - await group.init(this); + let group = new GroupClass(this); + await group.init(); this._groups.set(name, group); } } @@ -101,7 +123,13 @@ class Config extends events.EventEmitter { } } - async load(serializedConfig) { + /** + * Load config from a serialized object + * + * @param {object} serializedConfig - Serialized config to load. + * @param {string=} location - Location used for access control. + */ + async load(serializedConfig, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let GroupClass = this.constructor._groups.get(serializedGroup.name); @@ -110,8 +138,8 @@ class Config extends events.EventEmitter { continue; } - let group = new GroupClass(); - await group.load(this, serializedGroup); + let group = new GroupClass(this); + await group.load(serializedGroup, location); this._groups.set(group.name, group); } @@ -121,12 +149,13 @@ class Config extends events.EventEmitter { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the config. */ - serialize() { + serialize(location = this.location) { let groups = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { - groups.push(group.serialize()); + groups.push(group.serialize(location)); } return { groups }; @@ -140,28 +169,42 @@ class Config extends events.EventEmitter { * * @param {Object} serializedConfig - Output from .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedConfig, notify) { + update(serializedConfig, notify, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { - group.update(serializedGroup, notify); + group.update(serializedGroup, notify, location); } else { this._unknownGroups.set(serializedGroup.name, serializedGroup); } } } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of the field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.location) { + let [group, field] = splitOn(".", name); + return this.group(group).canAccess(field, location); + } + /** * Get the value for a config field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} the value for the field. */ - get(name) { + get(name, location = this.location) { let [group, field] = splitOn(".", name); - return this.group(group).get(field); + return this.group(group).get(field, location); } /** @@ -169,10 +212,11 @@ class Config extends events.EventEmitter { * * @param {string} name - Name of field to set. * @param {*} value - the value to set on field + * @param {string=} location - Location used for access control. */ - set(name, value) { + set(name, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).set(field, value); + this.group(group).set(field, value, location); } /** @@ -181,10 +225,11 @@ class Config extends events.EventEmitter { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).setProp(field, prop, value); + this.group(group).setProp(field, prop, value, location); } /** @@ -280,15 +325,42 @@ class ConfigGroup { * * After the creation of the new config group you have to call * either .init() or .load() on it in order to fully initialize it. + * + * @param {module:lib/config.Config} config - + * Parent config for this group instance. */ - constructor() { + constructor(config) { if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`); } this._fields = new Map(); this._unknownFields = new Map(); - this._config = null; + this.config = config; + } + + /** + * Check access for a field + * + * @param {object} def - definition for for the field to check. + * @param {string} remote - location of remote access to check for. + * @param {boolean} error - throw on failure to pass check. + * @throws {module:lib/config.InvalidAccess} + * if access is not granted and error is true. + * @returns {boolean} true if access is granted + * @private + */ + _checkAccess(def, remote, error) { + for (let loc of [remote, this.config.location]) { + if (!def.access.includes(loc)) { + if (error) { + throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`); + } else { + return false; + } + } + } + return true; } /** @@ -296,16 +368,15 @@ class ConfigGroup { * * Computes and assigns the initial values for all fields of the * config group. - * - * @param {module:lib/config.Config} config - Config group belongs to. */ - async init(config) { - this._config = config; - + async init() { for (let [name, def] of this.constructor._definitions) { if (this._fields.has(name)) { continue; } + if (!def.access.includes(this.config.location)) { + continue; + } let value = null; if (typeof def.initial_value === "function") { @@ -326,13 +397,13 @@ class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. * - * @param {module:lib/config.Config} config - Config group belongs to. * @param {Object} serializedGroup - * Result from a previous call to .serialize(). + * @param {string=} location - Location used for access control. */ - async load(config, serializedGroup) { - this.update(serializedGroup, false); - await this.init(config); + async load(serializedGroup, location = this.config.location) { + this.update(serializedGroup, false, location); + await this.init(); } /** @@ -344,16 +415,34 @@ class ConfigGroup { return this.constructor.groupName; } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { + throw new InvalidField(`No field named '${name}'`); + } + return this._checkAccess(def, location, false); + } + /** * Get value for field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} Value stored for the field. */ - get(name) { - if (!this.constructor._definitions.has(name)) { + get(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); return this._fields.get(name); } @@ -366,14 +455,16 @@ class ConfigGroup { * * @param {string} name - Name of field to set. * @param {*} value - Value to set for field. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if value is not allowed for the field. */ - set(name, value) { + set(name, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); // Empty strings are treates as null if (value === "") { @@ -418,8 +509,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -432,14 +523,16 @@ class ConfigGroup { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if field is not an object. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); if (def.type !== "object") { throw new InvalidValue(`Cannot set property on non-object field '${name}'`); @@ -454,8 +547,8 @@ class ConfigGroup { delete updated[prop]; } this._fields.set(name, updated); - if (this._config && !isDeepStrictEqual(updated, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(updated, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -468,8 +561,9 @@ class ConfigGroup { * @param {Object} serializedGroup - * Result from a previous call to .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedGroup, notify) { + update(serializedGroup, notify, location = this.config.location) { if (basicType(serializedGroup) !== "object") { throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); } @@ -489,6 +583,10 @@ class ConfigGroup { continue; } + if (!this._checkAccess(def, location, false)) { + continue; + } + if (def.optional) { if (value !== null && basicType(value) !== def.type) { continue; @@ -504,8 +602,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (notify && this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (notify && this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } } @@ -513,11 +611,17 @@ class ConfigGroup { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the group. */ - serialize() { + serialize(location = this.config.location) { let fields = {}; for (let [name, value] of this._fields) { + let def = this.constructor._definitions.get(name); + if (!this._checkAccess(def, location, false)) { + continue; + } + fields[name] = value; } @@ -557,6 +661,9 @@ class ConfigGroup { * Define a new configuration field * * @param {Object} item - Field definition. + * @param {Array=} item.access - + * Array of strings declaring where this config entry can be read + * and modified from. * @param {string} item.type - * string declaring the type of the config value, supports * boolean, string, number and object. @@ -609,6 +716,12 @@ class ConfigGroup { } break; + case "access": + if (valueType !== "array") { + throw new Error("access must be an array"); + } + break; + case "enum": if (valueType !== "array") { throw new Error("enum must be an array"); @@ -655,11 +768,16 @@ class ConfigGroup { } let computed = { + access: this.defaultAccess, optional: false, fullName: `${this.groupName}.${item.name}`, ...item, }; + if (!computed.access) { + throw new Error(`access not set and no defaultAccess defined for ${this.name}`); + } + if (!computed.optional && computed.initial_value === undefined) { throw new Error(`Non-optional field ${item.name} needs an initial_value`); } @@ -706,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup { module.exports = { + InvalidAccess, InvalidField, InvalidValue, diff --git a/packages/lib/config/definitions.js b/packages/lib/config/definitions.js index 5c6fff11..5b5aacc8 100644 --- a/packages/lib/config/definitions.js +++ b/packages/lib/config/definitions.js @@ -10,6 +10,7 @@ const classes = require("./classes"); * @memberof module:lib/config */ class MasterGroup extends classes.ConfigGroup { } +MasterGroup.defaultAccess = ["master", "slave", "control"]; MasterGroup.groupName = "master"; MasterGroup.define({ name: "name", @@ -69,6 +70,7 @@ MasterGroup.define({ optional: true, }); MasterGroup.define({ + access: ["master"], name: "auth_secret", title: "Master Authentication Secret", description: @@ -124,6 +126,7 @@ MasterConfig.registerGroup(MasterGroup); * @memberof module:lib/config */ class SlaveGroup extends classes.ConfigGroup {} +SlaveGroup.defaultAccess = ["master", "slave", "control"]; SlaveGroup.groupName = "slave"; SlaveGroup.define({ name: "name", @@ -197,6 +200,7 @@ SlaveConfig.registerGroup(SlaveGroup); * @memberof module:lib/config */ class InstanceGroup extends classes.ConfigGroup { } +InstanceGroup.defaultAccess = ["master", "slave", "control"]; InstanceGroup.groupName = "instance"; InstanceGroup.define({ name: "name", @@ -228,6 +232,7 @@ InstanceGroup.finalize(); * @memberof module:lib/config */ class FactorioGroup extends classes.ConfigGroup { } +FactorioGroup.defaultAccess = ["master", "slave", "control"]; FactorioGroup.groupName = "factorio"; FactorioGroup.define({ name: "version", @@ -325,6 +330,7 @@ InstanceConfig.registerGroup(FactorioGroup); * @memberof module:lib/config */ class ControlGroup extends classes.ConfigGroup {} +ControlGroup.defaultAccess = ["control"]; ControlGroup.groupName = "control"; ControlGroup.define({ name: "master_url", @@ -390,6 +396,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class MasterConfigGroup extends classes.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = pluginInfo.name; MasterConfigGroup.finalize(); MasterConfig.registerGroup(MasterConfigGroup); @@ -402,6 +409,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class InstanceConfigGroup extends classes.PluginConfigGroup { } + InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = pluginInfo.name; InstanceConfigGroup.finalize(); InstanceConfig.registerGroup(InstanceConfigGroup); diff --git a/packages/master/master.js b/packages/master/master.js index e5a2a1aa..9bb49643 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -372,7 +372,7 @@ async function loadInstances(databaseDirectory, file) { try { let serialized = JSON.parse(await fs.readFile(filePath)); for (let serializedConfig of serialized) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(serializedConfig); let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown"; instances.set(instanceConfig.get("instance.id"), { config: instanceConfig, status }); @@ -707,7 +707,7 @@ class ControlConnection extends BaseConnection { } async createSlaveConfigRequestHandler(message) { - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("control"); await slaveConfig.init(); slaveConfig.set("slave.master_url", getMasterUrl()); @@ -739,7 +739,7 @@ class ControlConnection extends BaseConnection { // XXX should probably add a hook for slave reuqests? async createInstanceRequestHandler(message) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(message.data.serialized_config); let instanceId = instanceConfig.get("instance.id"); @@ -795,7 +795,7 @@ class ControlConnection extends BaseConnection { } return { - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("control"), }; } @@ -806,7 +806,7 @@ class ControlConnection extends BaseConnection { if (connection) { await libLink.messages.assignInstance.send(connection, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -827,7 +827,7 @@ class ControlConnection extends BaseConnection { throw new libErrors.RequestError("Setting instance.id is not supported"); } - instance.config.set(message.data.field, message.data.value); + instance.config.set(message.data.field, message.data.value, "control"); await this.updateInstanceConfig(instance); } @@ -838,7 +838,7 @@ class ControlConnection extends BaseConnection { } let { field, prop, value } = message.data; - instance.config.setProp(field, prop, value); + instance.config.setProp(field, prop, value, "control"); await this.updateInstanceConfig(instance); } @@ -877,7 +877,7 @@ class ControlConnection extends BaseConnection { if (slave_id !== null) { await libLink.messages.assignInstance.send(newSlaveConnection, { instance_id, - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -1202,15 +1202,15 @@ class SlaveConnection extends BaseConnection { if (instance.config.get("instance.assigned_slave") === this._id) { await libLink.messages.assignInstance.send(this, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } // Assign instances the slave has but master does not for (let instance of message.data.instances) { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(instance.serialized_config); + let instanceConfig = new libConfig.InstanceConfig("master"); + await instanceConfig.load(instance.serialized_config, "slave"); let masterInstance = db.instances.get(instanceConfig.get("instance.id")); if (masterInstance) { @@ -1239,7 +1239,7 @@ class SlaveConnection extends BaseConnection { }); await libLink.messages.assignInstance.send(this, { instance_id: instanceConfig.get("instance.id"), - serialized_config: instanceConfig.serialize(), + serialized_config: instanceConfig.serialize("slave"), }); } @@ -1797,7 +1797,7 @@ async function handleBootstrapCommand(args) { process.exitCode = 1; return; } - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); await controlConfig.init(); controlConfig.set("control.master_url", getMasterUrl()); @@ -1909,7 +1909,7 @@ async function initialize() { masterConfigPath = args.config; logger.info(`Loading config from ${masterConfigPath}`); - masterConfig = new libConfig.MasterConfig(); + masterConfig = new libConfig.MasterConfig("master"); try { await masterConfig.load(JSON.parse(await fs.readFile(masterConfigPath))); diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 4e2ad729..5461a2c3 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -799,7 +799,7 @@ async function discoverInstances(instancesDir) { let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); let configPath = path.join(instancesDir, entry.name, "instance.json"); try { @@ -1175,17 +1175,17 @@ class Slave extends libLink.Link { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); logger.verbose(`Updated config for ${instanceInfo.path}`); } else { instanceInfo = this.discoveredInstanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); } else { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(serialized_config); + let instanceConfig = new libConfig.InstanceConfig("slave"); + await instanceConfig.load(serialized_config, "master"); // XXX: race condition on multiple simultanious calls let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name")); @@ -1348,7 +1348,7 @@ class Slave extends libLink.Link { for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push({ - serialized_config: instanceInfo.config.serialize(), + serialized_config: instanceInfo.config.serialize("master"), status: instanceConnection ? instanceConnection.status : "stopped", }); } @@ -1561,7 +1561,7 @@ async function startSlave() { libConfig.finalizeConfigs(); logger.info(`Loading config from ${args.config}`); - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("slave"); try { await slaveConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index af981afa..6b15a635 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -333,14 +333,14 @@ function InstanceConfigTree(props) { instance_id: props.id, }); - let newConfig = new libConfig.InstanceConfig(); + let newConfig = new libConfig.InstanceConfig("control"); await newConfig.load(result.serialized_config); setConfig(newConfig); } useEffect(() => { updateConfig(); }, [props.id]); - function renderInput(group, def) { + function renderInput(def) { if (def.type === "boolean") { return ; } @@ -387,6 +387,10 @@ function InstanceConfigTree(props) { }; for (let def of GroupClass.definitions.values()) { + if (!group.canAccess(def.name)) { + continue; + } + let value = group.get(def.name); let fieldName = `${group.name}.${def.name}`; let childNode = { @@ -445,7 +449,7 @@ function InstanceConfigTree(props) { let propName = form.getFieldValue(`${newPropPath}.name`); let propValue = form.getFieldValue(`${newPropPath}.value`); if (!Object.prototype.hasOwnProperty.call(value, propName)) { - let newConfig = new libConfig.InstanceConfig(); + let newConfig = new libConfig.InstanceConfig("control"); await newConfig.load(config.serialize()); newConfig.setProp(fieldName, propName, null); setConfig(newConfig); @@ -475,7 +479,7 @@ function InstanceConfigTree(props) { tooltip={def.description} valuePropName={def.type === "boolean" ? "checked" : "value"} > - {renderInput(group, def)} + {renderInput(def)} ; initialValues[fieldName] = value; } diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx index 39315a10..0d4f9031 100644 --- a/packages/web_ui/src/components/InstancesPage.jsx +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -33,10 +33,10 @@ export default function InstancesPage() { title: "Name", }], insert: async args => { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, }} diff --git a/plugins/statistics_exporter/info.js b/plugins/statistics_exporter/info.js index c89f5c1e..001f039e 100644 --- a/plugins/statistics_exporter/info.js +++ b/plugins/statistics_exporter/info.js @@ -2,6 +2,7 @@ const libConfig = require("@clusterio/lib/config"); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "statistics_exporter"; InstanceConfigGroup.define({ name: "command_timeout", diff --git a/plugins/subspace_storage/info.js b/plugins/subspace_storage/info.js index b60fe71d..52534a65 100644 --- a/plugins/subspace_storage/info.js +++ b/plugins/subspace_storage/info.js @@ -3,6 +3,7 @@ const libLink = require("@clusterio/lib/link"); const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup {} +MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "subspace_storage"; MasterConfigGroup.define({ name: "autosave_interval", @@ -29,6 +30,7 @@ MasterConfigGroup.define({ MasterConfigGroup.finalize(); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "subspace_storage"; InstanceConfigGroup.define({ name: "log_item_transfers", diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index e566713e..49d5d224 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -1,11 +1,15 @@ "use strict"; const classes = require("@clusterio/lib/config/classes"); const assert = require("assert").strict; +const events = require("events"); describe("lib/config/classes", function() { describe("ConfigGroup", function() { + let mockConfig = new events.EventEmitter(); + mockConfig.location = "local"; class TestGroup extends classes.ConfigGroup { } + TestGroup.defaultAccess = ["local", "remote"]; TestGroup.groupName = "test_group"; describe(".define()", function() { it("should throw if groupName is not set", function() { @@ -23,6 +27,10 @@ describe("lib/config/classes", function() { }); it("should throw on invalid properties", function() { for (let [def, error] of [ + [ + { access: "local", name: "a", type: "string", optional: true }, + "access must be an array", + ], [ { name: "a", type: "invalid", optional: true }, "invalid is not a valid type", @@ -59,6 +67,14 @@ describe("lib/config/classes", function() { assert.throws(() => TestGroup.define(def), new Error(error)); } }); + it("should throw on defaultAccess not set", function() { + class NoAccessGroup extends classes.ConfigGroup { } + NoAccessGroup.groupName = "no_access"; + assert.throws( + () => NoAccessGroup.define({ name: "a", type: "string", optional: true }), + new Error("access not set and no defaultAccess defined for NoAccessGroup") + ); + }); it("should throw on missing properties", function() { assert.throws( () => TestGroup.define({ type: "string", optional: true }), @@ -74,11 +90,13 @@ describe("lib/config/classes", function() { TestGroup.define({ name: "func", type: "number", initial_value: () => 42 }); TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true }); TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true }); + TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true }); assert(TestGroup._definitions.has("test"), "field test was not defined"); assert(TestGroup._definitions.has("func"), "field func was not defined"); assert(TestGroup._definitions.has("bool"), "field bool was not defined"); assert(TestGroup._definitions.has("json"), "field json was not defined"); + assert(TestGroup._definitions.has("priv"), "field priv was not defined"); }); it("should throw on already defined entries", function() { @@ -90,6 +108,7 @@ describe("lib/config/classes", function() { it("should set the entries on the class", function() { let field = { + access: ["local", "remote"], type: "string", name: "enum", title: "Enum", @@ -122,7 +141,7 @@ describe("lib/config/classes", function() { }); it("should throw when creating an instance before finalize", function() { assert.throws( - () => new TestGroup(), + () => new TestGroup(mockConfig), new Error("Cannot instantiate incomplete ConfigGroup class TestGroup") ); }); @@ -141,24 +160,31 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should create an instance", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); }); }); describe(".init()", function() { it("should initialize all fields with defaults", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.get("enum"), "b"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); + assert.equal(testInstance.get("priv"), null); + }); + it("should not initalize inaccessible fields", async function() { + let testInstance = new TestGroup({ location: "remote" }); + await testInstance.init(); + assert.equal(testInstance._fields.get("enum"), "b"); + assert.equal(testInstance._fields.get("priv"), undefined); }); }); describe("get name", function() { it("should give the name of the group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.name, "test_group"); }); @@ -166,10 +192,26 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { + name: "test_group", + fields: { + enum: "b", + test: null, + func: 42, + bool: false, + json: {}, + priv: null, + }, + }); + }); + it("should leave out inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + + assert.deepEqual(testInstance.serialize("remote"), { name: "test_group", fields: { enum: "b", @@ -184,29 +226,36 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should load a basic group", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "c", test: "blah", func: 22, bool: null, json: { valid: true }, + priv: "bar", }}); assert.equal(testInstance.get("enum"), "c"); assert.equal(testInstance.get("test"), "blah"); assert.equal(testInstance.get("func"), 22); assert.equal(testInstance.get("bool"), null); + assert.equal(testInstance.get("priv"), "bar"); assert.deepEqual(testInstance.get("json"), { valid: true }); }); it("should load defaults for missing serialized fields", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { enum: "a" } }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a" } }); assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should ignore inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { priv: "bad" } }, "remote"); + assert.equal(testInstance.get("priv"), null); + }); it("should preserve unknown fields when serialized back", async function() { let testFields = { @@ -215,13 +264,14 @@ describe("lib/config/classes", function() { func: 50, bool: false, json: {}, + priv: null, alpha: null, beta: "decay", gamma: 99, }; - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: testFields }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: testFields }); assert.deepEqual(testInstance.serialize().fields, testFields); }); @@ -229,7 +279,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should skip updating invalid values", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false); @@ -237,9 +287,19 @@ describe("lib/config/classes", function() { assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should skip updating inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + testInstance.update( + { name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote" + ); + + assert.equal(testInstance.get("test"), "a"); + assert.equal(testInstance.get("priv"), null); + }); it("should throw on invalid input", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); assert.throws( () => testInstance.update(), new Error("Expected object, not undefined for ConfigGroup") @@ -263,13 +323,52 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should return true for fields that are accessible", function() { + for (let field of ["enum", "test", "func", "bool", "json"]) { + assert.equal(testInstance.canAccess(field), true); + assert.equal(testInstance.canAccess(field, "local"), true); + assert.equal(testInstance.canAccess(field, "remote"), true); + } + assert.equal(testInstance.canAccess("priv"), true); + assert.equal(testInstance.canAccess("priv", "local"), true); + assert.equal(testInstance.canAccess("priv", "remote"), false); + }); + }); + + describe(".get()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should retreive value of field", function() { + assert.equal(testInstance.get("enum"), "a"); + assert.equal(testInstance.get("priv"), "blah"); + assert.equal(testInstance.get("func"), 27); + }); + it("should throw if field is inaccessible", function() { + assert.throws( + () => testInstance.get("priv", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + }); + describe(".set()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { - enum: "a", test: "blah", func: 27, - }}); + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}); }); it("should throw if field does not exist", function() { @@ -279,6 +378,13 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.set("priv", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not in enum", function() { assert.throws( () => testInstance.set("enum", "bar"), @@ -366,8 +472,8 @@ describe("lib/config/classes", function() { describe(".setProp()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27, }}); }); @@ -379,9 +485,16 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.setProp("priv", "prop", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not an object", function() { assert.throws( - () => testInstance.setProp("enum", "a"), + () => testInstance.setProp("enum", "prop", "a"), new classes.InvalidField("Cannot set property on non-object field 'enum'") ); }); @@ -408,8 +521,10 @@ describe("lib/config/classes", function() { describe("Config", function() { class AlphaGroup extends classes.ConfigGroup { } + AlphaGroup.defaultAccess = ["local", "remote"]; AlphaGroup.groupName = "alpha"; class BetaGroup extends classes.ConfigGroup { } + BetaGroup.defaultAccess = ["local", "remote"]; BetaGroup.groupName = "beta"; class TestConfig extends classes.Config { } @@ -454,20 +569,26 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should throw if config is not finalized", function() { assert.throws( - () => new TestConfig(), + () => new TestConfig("local"), new Error("Cannot instantiate incomplete Config class TestConfig") ); }); it("should construct a finalized class", function() { TestConfig.finalize(); - let config = new TestConfig(); + let config = new TestConfig("local"); assert(config); }); + it("should throw if location is not passed", function() { + assert.throws( + () => new TestConfig(), + new Error("location must be a string") + ); + }); }); describe(".init()", function() { it("should initialize all fields", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.equal(testInstance.get("alpha.foo"), null); assert.deepEqual(testInstance.get("beta.bar"), {}); @@ -476,7 +597,7 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { groups: [ @@ -495,7 +616,7 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should reject on incorrect input passed", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await assert.rejects( testInstance.load(null), new Error("Expected object, not null for config") @@ -511,7 +632,7 @@ describe("lib/config/classes", function() { }); it("should load defaults for missing serialized groups", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: [{ name: "alpha", fields: { foo: "a" }}] }); assert.equal(testInstance.get("alpha.foo"), "a"); @@ -534,7 +655,7 @@ describe("lib/config/classes", function() { }, ]; - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: testGroups }); assert.deepEqual(testInstance.serialize().groups, testGroups); @@ -543,7 +664,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should update a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.update({ groups: [ { @@ -577,7 +698,7 @@ describe("lib/config/classes", function() { describe(".group()", function() { it("should throw if instance is not initialized", function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); assert.throws( () => testInstance.group("test"), new Error("TestConfig instance is uninitialized") @@ -585,16 +706,39 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + it("should throw if group does not exist", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'")); + }); + + it("should throw if no field is specified", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''")); + }); + + it("should return true for an accessible field", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.equal(testInstance.canAccess("alpha.foo"), true); + }); + }); + describe(".get()", function() { it("should throw if group does not exist", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'")); }); it("should throw if no field is specified", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("alpha"), new Error("No field named ''")); @@ -603,7 +747,7 @@ describe("lib/config/classes", function() { describe(".set()", function() { it("should set the value", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.set("alpha.foo", "new value"); @@ -621,7 +765,7 @@ describe("lib/config/classes", function() { let testInstance; let called; beforeEach(async function() { - testInstance = new TestConfig(); + testInstance = new TestConfig("local"); await testInstance.init(); called = false; diff --git a/test/slave.js b/test/slave.js index f436b312..e54e5a0b 100644 --- a/test/slave.js +++ b/test/slave.js @@ -15,7 +15,7 @@ describe("Slave testing", function() { describe("class Instance", function() { let instance; before(async function() { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "foo"); instance = new slave._Instance({}, new libLink.VirtualConnector(), "dir", "factorioDir", instanceConfig); @@ -96,7 +96,7 @@ describe("Slave testing", function() { await fs.outputFile(path.join(testDir, "shared", "mod_b.zip"), "b"); await fs.outputFile(path.join(testDir, "shared", "mod.dat"), "c"); - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "test"); instance = new slave._Instance( @@ -149,7 +149,7 @@ describe("Slave testing", function() { let instancePath = path.join("test", "file", "instances"); let instanceInfos = await slave._discoverInstances(instancePath); - let referenceConfig = new libConfig.InstanceConfig(); + let referenceConfig = new libConfig.InstanceConfig("slave"); await referenceConfig.init(); referenceConfig.set("instance.id", 1); referenceConfig.set("instance.name", "test"); From ac17b8497acc2787585144d5a24fe6fa306f833c Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 18:47:03 +0100 Subject: [PATCH 2/6] Split InstanceViewPage into separate components Pull out the components in InstanceViewPage.jsx into separate source files. --- .../src/components/AssignInstanceModal.jsx | 91 +++ .../src/components/InstanceConfigTree.jsx | 277 +++++++++ .../web_ui/src/components/InstanceConsole.jsx | 94 +++ .../web_ui/src/components/InstanceRcon.jsx | 49 ++ .../src/components/InstanceViewPage.jsx | 585 +----------------- .../components/StartStopInstanceButton.jsx | 55 ++ packages/web_ui/src/model/instance.jsx | 33 + packages/web_ui/src/model/slave.jsx | 33 + 8 files changed, 641 insertions(+), 576 deletions(-) create mode 100644 packages/web_ui/src/components/AssignInstanceModal.jsx create mode 100644 packages/web_ui/src/components/InstanceConfigTree.jsx create mode 100644 packages/web_ui/src/components/InstanceConsole.jsx create mode 100644 packages/web_ui/src/components/InstanceRcon.jsx create mode 100644 packages/web_ui/src/components/StartStopInstanceButton.jsx create mode 100644 packages/web_ui/src/model/instance.jsx create mode 100644 packages/web_ui/src/model/slave.jsx diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.jsx new file mode 100644 index 00000000..8a0b2399 --- /dev/null +++ b/packages/web_ui/src/components/AssignInstanceModal.jsx @@ -0,0 +1,91 @@ +import React, { useContext, useState } from "react"; +import { Button, Form, Modal, Select, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Paragraph } = Typography; + + +export default function AssignInstanceModal(props) { + let [visible, setVisible] = useState(false); + let [slaves, setSlaves] = useState([]); + let [loading, setLoading] = useState(true); + let [applying, setApplying] = useState(false); + let [form] = Form.useForm(); + let control = useContext(ControlContext); + + function open() { + setVisible(true); + libLink.messages.listSlaves.send(control).then((result) => { + setSlaves(result.list); + setLoading(false); + }); + } + + function handleAssign() { + let slaveId = form.getFieldValue("slave"); + if (slaveId === undefined) { + setVisible(false); + return; + } + + setApplying(true); + libLink.messages.assignInstanceCommand.send(control, { + instance_id: props.id, + slave_id: slaveId, + }).then(() => { + setVisible(false); + if (props.onFinish) { + props.onFinish(); + } + }).catch( + notifyErrorHandler("Error assigning instance") + ).finally( + () => setApplying(false) + ); + } + + function handleCancel() { + setVisible(false); + } + + return <> + + + + Select a Slave to assign this instance to. Assignment + creates the necessary files on the slave to start up + the instance. Note that reassigning an instance from + one slave to another will not move the server save over. + +
+ + + +
+
+ ; +} + diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx new file mode 100644 index 00000000..e5a29865 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -0,0 +1,277 @@ +import React, { useEffect, useContext, useState } from "react"; +import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import ControlContext from "./ControlContext"; + +const { Title } = Typography; + + +export default function InstanceConfigTree(props) { + let control = useContext(ControlContext); + let [config, setConfig] = useState(null); + let [form] = Form.useForm(); + let [changedFields, setChangedFields] = useState(new Set()); + let [errorFields, setErrorFields] = useState(new Map()); + let [applying, setApplying] = useState(false); + + async function updateConfig() { + let result = await libLink.messages.getInstanceConfig.send(control, { + instance_id: props.id, + }); + + let newConfig = new libConfig.InstanceConfig("control"); + await newConfig.load(result.serialized_config); + setConfig(newConfig); + } + + useEffect(() => { updateConfig(); }, [props.id]); + + function renderInput(def) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; + } + + if (config === null) { + return ; + } + + let initialValues = {}; + function onValuesChange(changedValues) { + let newChangedFields = new Set(changedFields); + let changed = false; + for (let [key, value] of Object.entries(changedValues)) { + if (initialValues[key] === value) { + changed = newChangedFields.delete(key) || changed; + } else { + changed = !newChangedFields.has(key) || changed; + newChangedFields.add(key); + } + } + + if (changed) { + setChangedFields(newChangedFields); + } + } + + let treeData = []; + let propsMap = new Map(); + for (let [name, GroupClass] of libConfig.InstanceConfig.groups) { + let group = config.group(name); + let treeNode = { + key: group.name, + title: group.name, + children: [], + }; + + for (let def of GroupClass.definitions.values()) { + if (!group.canAccess(def.name)) { + continue; + } + + let value = group.get(def.name); + let fieldName = `${group.name}.${def.name}`; + let childNode = { + key: fieldName, + children: [], + }; + + if (def.type === "object") { + childNode.title = ; + for (let prop of Object.keys(value)) { + let propPath = `${group.name}.${def.name}.${prop}`; + childNode.children.push({ + key: propPath, + title: { + if (!fieldValue.length) { + return Promise.reject(new Error("Will be removed")); + } + try { + JSON.parse(fieldValue); + } catch (err) { + return Promise.reject(new Error("Must be valid json")); + } + return Promise.resolve(); + }}]} + > + + , + }); + + initialValues[propPath] = JSON.stringify(value[prop]); + propsMap.set(propPath, [fieldName, prop]); + } + + let newPropPath = `${group.name}.${def.name}:add`; + childNode.children.push({ + key: newPropPath, + title: + + + : + + + + + , + }); + + } else { + childNode.title = + {renderInput(def)} + ; + initialValues[fieldName] = value; + } + + treeNode.children.push(childNode); + } + + treeData.push(treeNode); + } + + return <> + + 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); + let data = { + instance_id: props.id, + field: fieldName, + prop, + }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + continue; + } + } + + request = libLink.messages.setInstanceConfigProp.send(control, data); + + } else { + request = libLink.messages.setInstanceConfigField.send(control, { + instance_id: props.id, + field, + value: String(value), + }); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err) { + 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> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} + diff --git a/packages/web_ui/src/components/InstanceConsole.jsx b/packages/web_ui/src/components/InstanceConsole.jsx new file mode 100644 index 00000000..07542676 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConsole.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useContext, useRef, useState } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; + +const { Paragraph } = Typography; + + +function formatParsedOutput(parsed, key) { + let time = ""; + if (parsed.format === "seconds") { + time = {parsed.time.padStart(8)} ; + } else if (parsed.format === "date") { + time = {parsed.time} ; + } + + let info = ""; + if (parsed.type === "log") { + let level = parsed.level; + if (level === "Script") { + level = {level}; + } else if (level === "Verbose") { + level = {level}; + } else if (level === "Info") { + level = {level}; + } else if (parsed.level === "Warning") { + level = {level}; + } else if (parsed.level === "Error") { + level = {level}; + } + + info = <>{level} {parsed.file}: ; + + } else if (parsed.type === "action") { + info = <>[{parsed.action}] ; + } + + return {time}{info}{parsed.message}
; +} + +function formatLog(info, key) { + if (info.level === "server" && info.parsed) { + return formatParsedOutput(info.parsed, key); + } + let level = {info.level}; + return [{level}] {info.message}
; +} + +export default function InstanceConsole(props) { + let control = useContext(ControlContext); + let anchor = useRef(); + let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); + let [lines, setLines] = useState([]); + + useEffect(() => { + // Scroll view to the anchor so it sticks to the bottom + let parent = anchor.current.parentElement; + parent.scrollTop = parent.scrollHeight - parent.clientHeight; + + libLink.messages.queryLog.send(control, { + all: false, + master: false, + slave_ids: [], + instance_ids: [props.id], + max_level: null, + }).then(result => { + setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); + }).catch(err => { + setPastLines([{`Error loading log: ${err.message}`}
]); + }); + + function logHandler(info) { + setLines(currentLines => currentLines.concat( + [formatLog(info, currentLines.length)] + )); + } + + control.onInstanceLog(props.id, logHandler); + return () => { + control.offInstanceLog(props.id, logHandler); + }; + }, [props.id]); + + return <> + + {pastLines} + {lines} +
+ + ; +} + diff --git a/packages/web_ui/src/components/InstanceRcon.jsx b/packages/web_ui/src/components/InstanceRcon.jsx new file mode 100644 index 00000000..ce1e07ee --- /dev/null +++ b/packages/web_ui/src/components/InstanceRcon.jsx @@ -0,0 +1,49 @@ +import React, { useContext, useState } from "react"; +import { Input, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Title, Paragraph } = Typography; + + +export default function InstanceRcon(props) { + let control = useContext(ControlContext); + let [output, setOutput] = useState(null); + let [running, setRunning] = useState(false); + + async function sendCommand(command) { + if (!command) { + setOutput(null); + return; + } + + setRunning(true); + try { + let result = await libLink.messages.sendRcon.send(control, { + instance_id: props.id, + command: command, + }); + setOutput(result.result); + } finally { + setRunning(false); + } + } + + return <> + {output && <> + Rcon result + {output} + } + sendCommand(command).catch(notifyErrorHandler("Error sending command"))} + loading={running} + /> + ; +} + diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 6b15a635..f44b5c34 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -1,591 +1,24 @@ -import React, { useEffect, useContext, useRef, useState } from "react"; +import React, { useContext, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; -import { - notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal, - Popconfirm, Row, Select, Space, Spin, Tree, Typography, -} from "antd"; +import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import libLink from "@clusterio/lib/link"; -import libConfig from "@clusterio/lib/config"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; +import InstanceConfigTree from "./InstanceConfigTree"; +import InstanceConsole from "./InstanceConsole"; +import InstanceRcon from "./InstanceRcon"; +import AssignInstanceModal from "./AssignInstanceModal"; +import StartStopInstanceButton from "./StartStopInstanceButton"; import { notifyErrorHandler } from "../util/notify"; +import { useInstance } from "../model/instance"; +import { useSlave } from "../model/slave"; const { Title, Paragraph } = Typography; -function useInstance(id) { - let control = useContext(ControlContext); - let [instance, setInstance] = useState({ loading: true }); - - function updateInstance() { - if (!Number.isInteger(id)) { - setInstance({ missing: true }); - return; - } - - // XXX optimize by requesting only the instance in question - libLink.messages.listInstances.send(control).then(result => { - let match = result.list.find(i => i.id === id); - if (!match) { - setInstance({ missing: true }); - } else { - setInstance({ ...match, present: true }); - } - }); - } - - useEffect(() => { - updateInstance(); - }, [id]); - - return [instance, updateInstance]; -} - -function useSlave(id) { - let control = useContext(ControlContext); - let [slave, setSlave] = useState({ loading: true }); - - function updateSlave() { - if (!Number.isInteger(id)) { - setSlave({ missing: true }); - return; - } - - // XXX optimize by requesting only the slave in question - libLink.messages.listSlaves.send(control).then(result => { - let match = result.list.find(i => i.id === id); - if (!match) { - setSlave({ missing: true }); - } else { - setSlave({ ...match, present: true }); - } - }); - } - - useEffect(() => { - updateSlave(); - }, [id]); - - return [slave, updateSlave]; -} - -function formatParsedOutput(parsed, key) { - let time = ""; - if (parsed.format === "seconds") { - time = {parsed.time.padStart(8)} ; - } else if (parsed.format === "date") { - time = {parsed.time} ; - } - - let info = ""; - if (parsed.type === "log") { - let level = parsed.level; - if (level === "Script") { - level = {level}; - } else if (level === "Verbose") { - level = {level}; - } else if (level === "Info") { - level = {level}; - } else if (parsed.level === "Warning") { - level = {level}; - } else if (parsed.level === "Error") { - level = {level}; - } - - info = <>{level} {parsed.file}: ; - - } else if (parsed.type === "action") { - info = <>[{parsed.action}] ; - } - - return {time}{info}{parsed.message}
; -} - -function formatLog(info, key) { - if (info.level === "server" && info.parsed) { - return formatParsedOutput(info.parsed, key); - } - let level = {info.level}; - return [{level}] {info.message}
; -} - -function InstanceConsole(props) { - let control = useContext(ControlContext); - let anchor = useRef(); - let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); - let [lines, setLines] = useState([]); - - useEffect(() => { - // Scroll view to the anchor so it sticks to the bottom - let parent = anchor.current.parentElement; - parent.scrollTop = parent.scrollHeight - parent.clientHeight; - - libLink.messages.queryLog.send(control, { - all: false, - master: false, - slave_ids: [], - instance_ids: [props.id], - max_level: null, - }).then(result => { - setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); - }).catch(err => { - setPastLines([{`Error loading log: ${err.message}`}
]); - }); - - function logHandler(info) { - setLines(currentLines => currentLines.concat( - [formatLog(info, currentLines.length)] - )); - } - - control.onInstanceLog(props.id, logHandler); - return () => { - control.offInstanceLog(props.id, logHandler); - }; - }, [props.id]); - - return <> - - {pastLines} - {lines} -
- - ; -} - -function InstanceRcon(props) { - let control = useContext(ControlContext); - let [output, setOutput] = useState(null); - let [running, setRunning] = useState(false); - - async function sendCommand(command) { - if (!command) { - setOutput(null); - return; - } - - setRunning(true); - try { - let result = await libLink.messages.sendRcon.send(control, { - instance_id: props.id, - command: command, - }); - setOutput(result.result); - } finally { - setRunning(false); - } - } - - return <> - {output && <> - Rcon result - {output} - } - sendCommand(command).catch(notifyErrorHandler("Error sending command"))} - loading={running} - /> - ; -} - -function AssignInstanceModal(props) { - let [visible, setVisible] = useState(false); - let [slaves, setSlaves] = useState([]); - let [loading, setLoading] = useState(true); - let [applying, setApplying] = useState(false); - let [form] = Form.useForm(); - let control = useContext(ControlContext); - - function open() { - setVisible(true); - libLink.messages.listSlaves.send(control).then((result) => { - setSlaves(result.list); - setLoading(false); - }); - } - - function handleAssign() { - let slaveId = form.getFieldValue("slave"); - if (slaveId === undefined) { - setVisible(false); - return; - } - - setApplying(true); - libLink.messages.assignInstanceCommand.send(control, { - instance_id: props.id, - slave_id: slaveId, - }).then(() => { - setVisible(false); - if (props.onFinish) { - props.onFinish(); - } - }).catch( - notifyErrorHandler("Error assigning instance") - ).finally( - () => setApplying(false) - ); - } - - function handleCancel() { - setVisible(false); - } - - return <> - - - - Select a Slave to assign this instance to. Assignment - creates the necessary files on the slave to start up - the instance. Note that reassigning an instance from - one slave to another will not move the server save over. - -
- - - -
-
- ; -} - -function StartStopInstanceButton(props) { - let control = useContext(ControlContext); - let [switching, setSwitching] = useState(false); - - function onClick() { - setSwitching(true); - let action; - if (props.instance["status"] === "stopped") { - action = libLink.messages.startInstance.send( - control, { instance_id: props.instance["id"], save: null } - ).catch( - notifyErrorHandler("Error starting instance") - ); - - } else if (["starting", "running"].includes(props.instance["status"])) { - action = libLink.messages.stopInstance.send( - control, { instance_id: props.instance["id"] } - ).catch( - notifyErrorHandler("Error stopping instance") - ); - - } else { - setSwitching(false); - return; - } - - action.finally(() => { - setSwitching(false); - if (props.onFinish) { - props.onFinish(); - } - }); - } - - return ; -} - - -function InstanceConfigTree(props) { - let control = useContext(ControlContext); - let [config, setConfig] = useState(null); - let [form] = Form.useForm(); - let [changedFields, setChangedFields] = useState(new Set()); - let [errorFields, setErrorFields] = useState(new Map()); - let [applying, setApplying] = useState(false); - - async function updateConfig() { - let result = await libLink.messages.getInstanceConfig.send(control, { - instance_id: props.id, - }); - - let newConfig = new libConfig.InstanceConfig("control"); - await newConfig.load(result.serialized_config); - setConfig(newConfig); - } - - useEffect(() => { updateConfig(); }, [props.id]); - - function renderInput(def) { - if (def.type === "boolean") { - return ; - } - if (def.type === "string") { - return ; - } - if (def.type === "number") { - return ; - } - - return `Unknown type ${def.type}`; - } - - if (config === null) { - return ; - } - - let initialValues = {}; - function onValuesChange(changedValues) { - let newChangedFields = new Set(changedFields); - let changed = false; - for (let [key, value] of Object.entries(changedValues)) { - if (initialValues[key] === value) { - changed = newChangedFields.delete(key) || changed; - } else { - changed = !newChangedFields.has(key) || changed; - newChangedFields.add(key); - } - } - - if (changed) { - setChangedFields(newChangedFields); - } - } - - let treeData = []; - let propsMap = new Map(); - for (let [name, GroupClass] of libConfig.InstanceConfig.groups) { - let group = config.group(name); - let treeNode = { - key: group.name, - title: group.name, - children: [], - }; - - for (let def of GroupClass.definitions.values()) { - if (!group.canAccess(def.name)) { - continue; - } - - let value = group.get(def.name); - let fieldName = `${group.name}.${def.name}`; - let childNode = { - key: fieldName, - children: [], - }; - - if (def.type === "object") { - childNode.title = ; - for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; - childNode.children.push({ - key: propPath, - title: { - if (!fieldValue.length) { - return Promise.reject(new Error("Will be removed")); - } - try { - JSON.parse(fieldValue); - } catch (err) { - return Promise.reject(new Error("Must be valid json")); - } - return Promise.resolve(); - }}]} - > - - , - }); - - initialValues[propPath] = JSON.stringify(value[prop]); - propsMap.set(propPath, [fieldName, prop]); - } - - let newPropPath = `${group.name}.${def.name}:add`; - childNode.children.push({ - key: newPropPath, - title: - - - : - - - - - , - }); - - } else { - childNode.title = - {renderInput(def)} - ; - initialValues[fieldName] = value; - } - - treeNode.children.push(childNode); - } - - treeData.push(treeNode); - } - - return <> - - 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); - let data = { - instance_id: props.id, - field: fieldName, - prop, - }; - if (value) { - try { - data.value = JSON.parse(value); - } catch (err) { - continue; - } - } - - request = libLink.messages.setInstanceConfigProp.send(control, data); - - } else { - request = libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value: String(value), - }); - } - - try { - await request; - setChangedFields(changed => { - let newChanged = new Set(changed); - newChanged.delete(field); - return newChanged; - }); - - } catch (err) { - 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> - - -
- changedFields.has(node.key)} - /> - -
- ; -} - export default function InstanceViewPage(props) { let params = useParams(); let instanceId = Number(params.id); diff --git a/packages/web_ui/src/components/StartStopInstanceButton.jsx b/packages/web_ui/src/components/StartStopInstanceButton.jsx new file mode 100644 index 00000000..1e7f7162 --- /dev/null +++ b/packages/web_ui/src/components/StartStopInstanceButton.jsx @@ -0,0 +1,55 @@ +import React, { useContext, useState } from "react"; +import { Button } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + + +export default function StartStopInstanceButton(props) { + let control = useContext(ControlContext); + let [switching, setSwitching] = useState(false); + + function onClick() { + setSwitching(true); + let action; + if (props.instance["status"] === "stopped") { + action = libLink.messages.startInstance.send( + control, { instance_id: props.instance["id"], save: null } + ).catch( + notifyErrorHandler("Error starting instance") + ); + + } else if (["starting", "running"].includes(props.instance["status"])) { + action = libLink.messages.stopInstance.send( + control, { instance_id: props.instance["id"] } + ).catch( + notifyErrorHandler("Error stopping instance") + ); + + } else { + setSwitching(false); + return; + } + + action.finally(() => { + setSwitching(false); + if (props.onFinish) { + props.onFinish(); + } + }); + } + + return ; +} + + diff --git a/packages/web_ui/src/model/instance.jsx b/packages/web_ui/src/model/instance.jsx new file mode 100644 index 00000000..c30227ab --- /dev/null +++ b/packages/web_ui/src/model/instance.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export function useInstance(id) { + let control = useContext(ControlContext); + let [instance, setInstance] = useState({ loading: true }); + + function updateInstance() { + if (!Number.isInteger(id)) { + setInstance({ missing: true }); + return; + } + + // XXX optimize by requesting only the instance in question + libLink.messages.listInstances.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setInstance({ missing: true }); + } else { + setInstance({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateInstance(); + }, [id]); + + return [instance, updateInstance]; +} diff --git a/packages/web_ui/src/model/slave.jsx b/packages/web_ui/src/model/slave.jsx new file mode 100644 index 00000000..ca6ee136 --- /dev/null +++ b/packages/web_ui/src/model/slave.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export function useSlave(id) { + let control = useContext(ControlContext); + let [slave, setSlave] = useState({ loading: true }); + + function updateSlave() { + if (!Number.isInteger(id)) { + setSlave({ missing: true }); + return; + } + + // XXX optimize by requesting only the slave in question + libLink.messages.listSlaves.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setSlave({ missing: true }); + } else { + setSlave({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateSlave(); + }, [id]); + + return [slave, updateSlave]; +} From b538960592f8d0b6ce723741595d6e805ee67103 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 19:48:43 +0100 Subject: [PATCH 3/6] Add ErrorBoundary to page content Prevent the page from turning into a black screen when page rendering fails by adding an error boundary to the display of the page content in SiteLayout.jsx. --- .../web_ui/src/components/ErrorBoundary.jsx | 20 +++++++++++++++++++ packages/web_ui/src/components/ErrorPage.jsx | 16 +++++++++++++++ packages/web_ui/src/components/SiteLayout.jsx | 7 ++++++- packages/web_ui/src/index.css | 8 ++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/web_ui/src/components/ErrorBoundary.jsx create mode 100644 packages/web_ui/src/components/ErrorPage.jsx diff --git a/packages/web_ui/src/components/ErrorBoundary.jsx b/packages/web_ui/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..4fed84e1 --- /dev/null +++ b/packages/web_ui/src/components/ErrorBoundary.jsx @@ -0,0 +1,20 @@ +import React from "react"; + + +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } +} diff --git a/packages/web_ui/src/components/ErrorPage.jsx b/packages/web_ui/src/components/ErrorPage.jsx new file mode 100644 index 00000000..855bfce7 --- /dev/null +++ b/packages/web_ui/src/components/ErrorPage.jsx @@ -0,0 +1,16 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import PageLayout from "./PageLayout"; + +const { Paragraph } = Typography; + + +export default function ErrorPage(props) { + return +

An unexpected error occured

+ {props.error.stack} +
; +}; diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index d82ef9e5..751a56e4 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -3,6 +3,9 @@ import { Switch, Route, useHistory } from "react-router-dom"; import { Layout, Menu } from "antd"; import webUiPackage from "../../package.json"; +import ErrorBoundary from "./ErrorBoundary"; +import ErrorPage from "./ErrorPage"; + const { Header, Sider } = Layout; @@ -74,7 +77,9 @@ export default function SiteLayout(props) { )} {pages.map(page => - {page.content} + + {page.content} + )} diff --git a/packages/web_ui/src/index.css b/packages/web_ui/src/index.css index ba26fe24..dda791ca 100644 --- a/packages/web_ui/src/index.css +++ b/packages/web_ui/src/index.css @@ -82,6 +82,14 @@ body { background-size: 64px; } +.error-traceback code { + display: block; + margin: 0; + background: #000; + border-color: #252525; + white-space: pre-wrap; +} + .rcon-result code { display: block; background: #000; From 0366334e7c9dd9e5f9cae12fc9f01e8965f657a0 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 20:17:29 +0100 Subject: [PATCH 4/6] Generalize InstanceConfigTree Pull out the instance specific logic from InstanceConfigTree and turn it into a general BaseConfigTree component that can be used for master and slave configs. --- .../web_ui/src/components/BaseConfigTree.jsx | 249 +++++++++++++++ .../src/components/InstanceConfigTree.jsx | 283 ++---------------- 2 files changed, 278 insertions(+), 254 deletions(-) create mode 100644 packages/web_ui/src/components/BaseConfigTree.jsx diff --git a/packages/web_ui/src/components/BaseConfigTree.jsx b/packages/web_ui/src/components/BaseConfigTree.jsx new file mode 100644 index 00000000..7fcc1548 --- /dev/null +++ b/packages/web_ui/src/components/BaseConfigTree.jsx @@ -0,0 +1,249 @@ +import React, { useEffect, useContext, useState } from "react"; +import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; + +const { Title } = Typography; + + +export default function BaseConfigTree(props) { + let [config, setConfig] = useState(null); + let [form] = Form.useForm(); + let [changedFields, setChangedFields] = useState(new Set()); + let [errorFields, setErrorFields] = useState(new Map()); + let [applying, setApplying] = useState(false); + + async function updateConfig() { + let serializedConfig = await props.retrieveConfig(); + let newConfig = new props.ConfigClass("control"); + await newConfig.load(serializedConfig); + setConfig(newConfig); + } + useEffect(() => { updateConfig(); }, [props.id]); + + function renderInput(def) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; + } + + if (config === null) { + return ; + } + + let initialValues = {}; + function onValuesChange(changedValues) { + let newChangedFields = new Set(changedFields); + let changed = false; + for (let [key, value] of Object.entries(changedValues)) { + if (initialValues[key] === value) { + changed = newChangedFields.delete(key) || changed; + } else { + changed = !newChangedFields.has(key) || changed; + newChangedFields.add(key); + } + } + + if (changed) { + setChangedFields(newChangedFields); + } + } + + let treeData = []; + let propsMap = new Map(); + for (let [name, GroupClass] of props.ConfigClass.groups) { + let group = config.group(name); + let treeNode = { + key: group.name, + title: group.name, + children: [], + }; + + for (let def of GroupClass.definitions.values()) { + if (!group.canAccess(def.name)) { + continue; + } + + let value = group.get(def.name); + let fieldName = `${group.name}.${def.name}`; + let childNode = { + key: fieldName, + children: [], + }; + + if (def.type === "object") { + childNode.title = ; + for (let prop of Object.keys(value)) { + let propPath = `${group.name}.${def.name}.${prop}`; + childNode.children.push({ + key: propPath, + title: { + if (!fieldValue.length) { + return Promise.reject(new Error("Will be removed")); + } + try { + JSON.parse(fieldValue); + } catch (err) { + return Promise.reject(new Error("Must be valid json")); + } + return Promise.resolve(); + }}]} + > + + , + }); + + initialValues[propPath] = JSON.stringify(value[prop]); + propsMap.set(propPath, [fieldName, prop]); + } + + let newPropPath = `${group.name}.${def.name}:add`; + childNode.children.push({ + key: newPropPath, + title: + + + : + + + + + , + }); + + } else { + childNode.title = + {renderInput(def)} + ; + initialValues[fieldName] = value; + } + + treeNode.children.push(childNode); + } + + treeData.push(treeNode); + } + + return <> + + 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); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err) { + 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> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx index e5a29865..eaefa3c2 100644 --- a/packages/web_ui/src/components/InstanceConfigTree.jsx +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -1,277 +1,52 @@ -import React, { useEffect, useContext, useState } from "react"; -import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; +import React, { useContext } from "react"; +import { Typography } from "antd"; import libLink from "@clusterio/lib/link"; import libConfig from "@clusterio/lib/config"; +import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; -const { Title } = Typography; - export default function InstanceConfigTree(props) { let control = useContext(ControlContext); - let [config, setConfig] = useState(null); - let [form] = Form.useForm(); - let [changedFields, setChangedFields] = useState(new Set()); - let [errorFields, setErrorFields] = useState(new Map()); - let [applying, setApplying] = useState(false); - async function updateConfig() { + async function retrieveConfig() { let result = await libLink.messages.getInstanceConfig.send(control, { instance_id: props.id, }); - - let newConfig = new libConfig.InstanceConfig("control"); - await newConfig.load(result.serialized_config); - setConfig(newConfig); + return result.serialized_config; } - useEffect(() => { updateConfig(); }, [props.id]); - - function renderInput(def) { - if (def.type === "boolean") { - return ; - } - if (def.type === "string") { - return ; - } - if (def.type === "number") { - return ; - } - - return `Unknown type ${def.type}`; + async function setField(field, value) { + await libLink.messages.setInstanceConfigField.send(control, { + instance_id: props.id, + field, + value: String(value), + }); } - if (config === null) { - return ; - } - - let initialValues = {}; - function onValuesChange(changedValues) { - let newChangedFields = new Set(changedFields); - let changed = false; - for (let [key, value] of Object.entries(changedValues)) { - if (initialValues[key] === value) { - changed = newChangedFields.delete(key) || changed; - } else { - changed = !newChangedFields.has(key) || changed; - newChangedFields.add(key); - } - } - - if (changed) { - setChangedFields(newChangedFields); - } - } - - let treeData = []; - let propsMap = new Map(); - for (let [name, GroupClass] of libConfig.InstanceConfig.groups) { - let group = config.group(name); - let treeNode = { - key: group.name, - title: group.name, - children: [], + async function setProp(field, prop, value) { + let data = { + instance_id: props.id, + field, + prop, }; - - for (let def of GroupClass.definitions.values()) { - if (!group.canAccess(def.name)) { - continue; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + return; } - - let value = group.get(def.name); - let fieldName = `${group.name}.${def.name}`; - let childNode = { - key: fieldName, - children: [], - }; - - if (def.type === "object") { - childNode.title = ; - for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; - childNode.children.push({ - key: propPath, - title: { - if (!fieldValue.length) { - return Promise.reject(new Error("Will be removed")); - } - try { - JSON.parse(fieldValue); - } catch (err) { - return Promise.reject(new Error("Must be valid json")); - } - return Promise.resolve(); - }}]} - > - - , - }); - - initialValues[propPath] = JSON.stringify(value[prop]); - propsMap.set(propPath, [fieldName, prop]); - } - - let newPropPath = `${group.name}.${def.name}:add`; - childNode.children.push({ - key: newPropPath, - title: - - - : - - - - - , - }); - - } else { - childNode.title = - {renderInput(def)} - ; - initialValues[fieldName] = value; - } - - treeNode.children.push(childNode); } - - treeData.push(treeNode); + await libLink.messages.setInstanceConfigProp.send(control, data); } - return <> - - 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); - let data = { - instance_id: props.id, - field: fieldName, - prop, - }; - if (value) { - try { - data.value = JSON.parse(value); - } catch (err) { - continue; - } - } - - request = libLink.messages.setInstanceConfigProp.send(control, data); - - } else { - request = libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value: String(value), - }); - } - - try { - await request; - setChangedFields(changed => { - let newChanged = new Set(changed); - newChanged.delete(field); - return newChanged; - }); - - } catch (err) { - 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> - - -
- changedFields.has(node.key)} - /> - -
- ; + return ; } - From 38b7d0f600947f45213e4971ff4f3b669d320b9d Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 20:18:41 +0100 Subject: [PATCH 5/6] Fix web config trying to set the new prop value as field Fix the BaseConfigTree treating the nex property name and value boxes as fields to be applied to the config when pressing Apply. --- packages/web_ui/src/components/BaseConfigTree.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web_ui/src/components/BaseConfigTree.jsx b/packages/web_ui/src/components/BaseConfigTree.jsx index 7fcc1548..7f6fd457 100644 --- a/packages/web_ui/src/components/BaseConfigTree.jsx +++ b/packages/web_ui/src/components/BaseConfigTree.jsx @@ -42,6 +42,9 @@ export default function BaseConfigTree(props) { 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; + } if (initialValues[key] === value) { changed = newChangedFields.delete(key) || changed; } else { From bec30c9927665713af36af022d2e37baa14953c3 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 24 Feb 2021 22:16:03 +0100 Subject: [PATCH 6/6] Add remote editing of master config Add commands and web UI for editing the master configuration remotely. --- packages/ctl/ctl.js | 74 +++++++++++++++++++ packages/lib/link/messages.js | 31 ++++++++ packages/lib/users.js | 10 +++ packages/master/master.js | 13 ++++ packages/master/src/routes.js | 7 +- .../src/components/MasterConfigTree.jsx | 44 +++++++++++ packages/web_ui/src/components/MasterPage.jsx | 12 +++ packages/web_ui/src/components/SiteLayout.jsx | 2 + test/integration/clusterio.js | 36 +++++++++ 9 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 packages/web_ui/src/components/MasterConfigTree.jsx create mode 100644 packages/web_ui/src/components/MasterPage.jsx diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index a72ab6cd..7ca51d56 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -31,6 +31,79 @@ function print(...content) { console.log(...content); } + +const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" }); +const masterConfigCommands = new libCommand.CommandTree({ + name: "config", alias: ["c"], description: "master config management", +}); +masterConfigCommands.add(new libCommand.Command({ + definition: ["list", "List master configuration"], + handler: async function(args, control) { + let response = await libLink.messages.getMasterConfig.send(control); + + for (let group of response.serialized_config.groups) { + for (let [name, value] of Object.entries(group.fields)) { + print(`${group.name}.${name} ${JSON.stringify(value)}`); + } + } + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set [value]", "Set field in master config", (yargs) => { + 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, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await libLink.messages.setMasterConfigField.send(control, { + field: args.field, + value: args.value, + }); + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set-prop [value]", "Set property of field in master config", (yargs) => { + 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, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let request = { + field: args.field, + prop: args.prop, + }; + try { + if (args.value !== undefined) { + request.value = JSON.parse(args.value); + } + } catch (err) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { + throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + request.value = args.value; + } + await libLink.messages.setMasterConfigProp.send(control, request); + }, +})); +masterCommands.add(masterConfigCommands); + + const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); slaveCommands.add(new libCommand.Command({ definition: [["list", "l"], "List slaves connected to the master"], @@ -701,6 +774,7 @@ async function loadPlugins(pluginList) { async function registerCommands(controlPlugins, yargs) { const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); + rootCommands.add(masterCommands); rootCommands.add(slaveCommands); rootCommands.add(instanceCommands); rootCommands.add(permissionCommands); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index a68ae538..89a6cbdd 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -300,6 +300,37 @@ messages.debugDumpWs = new Request({ // Management requests +messages.getMasterConfig = new Request({ + type: "get_master_config", + links: ["control-master"], + permission: "core.master.get_config", + responseProperties: { + "serialized_config": { type: "object" }, + }, +}); + +messages.setMasterConfigField = new Request({ + type: "set_master_config_field", + links: ["control-master"], + permission: "core.master.update_config", + requestProperties: { + "field": { type: "string" }, + "value": { type: "string" }, + }, +}); + +messages.setMasterConfigProp = new Request({ + type: "set_master_config_prop", + links: ["control-master"], + permission: "core.master.update_config", + requestRequired: ["field", "prop"], + requestProperties: { + "field": { type: "string" }, + "prop": { type: "string" }, + "value": {}, + }, +}); + messages.listSlaves = new Request({ type: "list_slaves", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index a55ef972..3bb8f60a 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -302,6 +302,16 @@ definePermission({ grantByDefault: true, }); +definePermission({ + name: "core.master.get_config", + title: "Get master config", + description: "Get the config of master.", +}); +definePermission({ + name: "core.master.update_config", + title: "Modify master config", + description: "Modify the master config or entries of the master config.", +}); definePermission({ name: "core.slave.list", title: "List slaves", diff --git a/packages/master/master.js b/packages/master/master.js index 9bb49643..2474c0d6 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -684,6 +684,19 @@ class ControlConnection extends BaseConnection { }); } + async getMasterConfigRequestHandler() { + return { serialized_config: masterConfig.serialize("control") }; + } + + async setMasterConfigFieldRequestHandler(message) { + masterConfig.set(message.data.field, message.data.value, "control"); + } + + async setMasterConfigPropRequestHandler(message) { + let { field, prop, value } = message.data; + masterConfig.setProp(field, prop, value, "control"); + } + async listSlavesRequestHandler(message) { let list = []; for (let slave of db.slaves.values()) { diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index 7e0ca52a..bcfde4ee 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -2,15 +2,14 @@ // Routes used in the web interface and served by the master server module.exports = [ "/", - "/instances", - "/instances/:id/view", + "/master", "/slaves", "/slaves/:id/view", + "/instances", + "/instances/:id/view", "/users", "/users/:id/view", "/roles", "/roles/:id/view", - "/permissions", - "/permissions/:id/view", "/settings", ]; diff --git a/packages/web_ui/src/components/MasterConfigTree.jsx b/packages/web_ui/src/components/MasterConfigTree.jsx new file mode 100644 index 00000000..fc817b90 --- /dev/null +++ b/packages/web_ui/src/components/MasterConfigTree.jsx @@ -0,0 +1,44 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import BaseConfigTree from "./BaseConfigTree"; +import ControlContext from "./ControlContext"; + + +export default function MasterConfigTree(props) { + let control = useContext(ControlContext); + + async function retrieveConfig() { + let result = await libLink.messages.getMasterConfig.send(control); + return result.serialized_config; + } + + async function setField(field, value) { + await libLink.messages.setMasterConfigField.send(control, { + field, + value: String(value), + }); + } + + async function setProp(field, prop, value) { + let data = { field, prop }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + return; + } + } + await libLink.messages.setMasterConfigProp.send(control, data); + } + + return ; +} diff --git a/packages/web_ui/src/components/MasterPage.jsx b/packages/web_ui/src/components/MasterPage.jsx new file mode 100644 index 00000000..3f14b548 --- /dev/null +++ b/packages/web_ui/src/components/MasterPage.jsx @@ -0,0 +1,12 @@ +import React, { useContext } from "react"; + +import MasterConfigTree from "./MasterConfigTree"; +import PageLayout from "./PageLayout"; + + +export default function SlavesPage() { + return +

Master

+ +
; +}; diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index 751a56e4..f2ef9358 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -9,6 +9,7 @@ import ErrorPage from "./ErrorPage"; const { Header, Sider } = Layout; +import MasterPage from "./MasterPage"; import SlavesPage from "./SlavesPage"; import InstancesPage from "./InstancesPage"; import InstanceViewPage from "./InstanceViewPage"; @@ -24,6 +25,7 @@ const pages = [ ]; const sidebar = [ + { name: "Master", path: "/master", content: }, { name: "Slaves", path: "/slaves", content: }, { name: "Instances", path: "/instances", content: }, { name: "Users", path: "/users", content: }, diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3ab0ffb4..8d3be325 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -35,6 +35,42 @@ describe("Integration of Clusterio", function() { }); describe("clusterioctl", function() { + describe("master config list", function() { + it("runs", async function() { + await execCtl("master config list"); + }); + it("should not leak auth_secret", async function() { + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(Object.prototype.hasOwnProperty.call(group.fields, "auth_secret"), false); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + }); + describe("master config set", function() { + it("sets given config option", async function() { + await execCtl('master config set master.name "Test Cluster"'); + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(group.fields.name, "Test Cluster"); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + it("should not allow setting auth_secret", async function() { + await assert.rejects(execCtl("master config set master.auth_secret root")); + }); + }); + describe("slave list", function() { it("runs", async function() { await execCtl("slave list");