mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Merge pull request #360 from Hornwitser/edit-master-config
Remote master config management
This commit is contained in:
31 files changed
+1334
-682
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
+77
-3
@@ -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 <field> [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 <field> <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"],
|
||||
@@ -104,13 +177,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 });
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
@@ -792,7 +866,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)));
|
||||
|
||||
|
||||
+158
-39
@@ -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<string>=} 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,
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
+27
-14
@@ -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 });
|
||||
@@ -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()) {
|
||||
@@ -707,7 +720,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 +752,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 +808,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
return {
|
||||
serialized_config: instance.config.serialize(),
|
||||
serialized_config: instance.config.serialize("control"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -806,7 +819,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 +840,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 +851,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 +890,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 +1215,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 +1252,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 +1810,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 +1922,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)));
|
||||
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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 <>
|
||||
<Button {...props.buttonProps} onClick={open}>
|
||||
{props.buttonContent || "Assign"}
|
||||
</Button>
|
||||
<Modal
|
||||
title="Assign Instance"
|
||||
okText="Assign"
|
||||
visible={visible}
|
||||
confirmLoading={applying}
|
||||
onOk={handleAssign}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
>
|
||||
<Paragraph style={{ maxWidth: "30em" }}>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Form form={form} initialValues={{ slave: props.slaveId }}>
|
||||
<Form.Item name="slave" label="Slave">
|
||||
<Select loading={loading}>
|
||||
{slaves.map((slave) => <Select.Option
|
||||
key={slave["id"]}
|
||||
value={slave["id"]}
|
||||
disabled={!slave["connected"]}
|
||||
>
|
||||
{slave["name"]}
|
||||
{!slave["connected"] && " (offline)"}
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
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 <Checkbox/>;
|
||||
}
|
||||
if (def.type === "string") {
|
||||
return <Input/>;
|
||||
}
|
||||
if (def.type === "number") {
|
||||
return <InputNumber/>;
|
||||
}
|
||||
|
||||
return `Unknown type ${def.type}`;
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return <Spin/>;
|
||||
}
|
||||
|
||||
let initialValues = {};
|
||||
function onValuesChange(changedValues) {
|
||||
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 {
|
||||
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 = <Form.Item label={def.title || def.name} tooltip={def.description} />;
|
||||
for (let prop of Object.keys(value)) {
|
||||
let propPath = `${group.name}.${def.name}.${prop}`;
|
||||
childNode.children.push({
|
||||
key: propPath,
|
||||
title: <Form.Item
|
||||
name={propPath}
|
||||
label={`"${prop}"`}
|
||||
rules={[{ validator: (_, fieldValue) => {
|
||||
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();
|
||||
}}]}
|
||||
>
|
||||
<Input className="json-input" />
|
||||
</Form.Item>,
|
||||
});
|
||||
|
||||
initialValues[propPath] = JSON.stringify(value[prop]);
|
||||
propsMap.set(propPath, [fieldName, prop]);
|
||||
}
|
||||
|
||||
let newPropPath = `${group.name}.${def.name}:add`;
|
||||
childNode.children.push({
|
||||
key: newPropPath,
|
||||
title: <Space>
|
||||
<Form.Item
|
||||
name={`${newPropPath}.name`}
|
||||
noStyle={true}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>:
|
||||
<Form.Item
|
||||
name={`${newPropPath}.value`}
|
||||
noStyle={true}
|
||||
>
|
||||
<Input className="json-input" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
let propName = form.getFieldValue(`${newPropPath}.name`);
|
||||
let propValue = form.getFieldValue(`${newPropPath}.value`);
|
||||
if (!Object.prototype.hasOwnProperty.call(value, propName)) {
|
||||
let newConfig = new props.ConfigClass("control");
|
||||
await newConfig.load(config.serialize());
|
||||
newConfig.setProp(fieldName, propName, null);
|
||||
setConfig(newConfig);
|
||||
}
|
||||
|
||||
let propPath = `${group.name}.${def.name}.${propName}`;
|
||||
form.setFieldsValue({
|
||||
[`${newPropPath}.name`]: "",
|
||||
[`${newPropPath}.value`]: "",
|
||||
[propPath]: propValue,
|
||||
});
|
||||
form.validateFields([propPath]);
|
||||
onValuesChange({ [propPath]: propValue });
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Space>,
|
||||
});
|
||||
|
||||
} else {
|
||||
childNode.title = <Form.Item
|
||||
name={fieldName}
|
||||
label={def.title || def.name}
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
tooltip={def.description}
|
||||
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
||||
>
|
||||
{renderInput(def)}
|
||||
</Form.Item>;
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
|
||||
treeNode.children.push(childNode);
|
||||
}
|
||||
|
||||
treeData.push(treeNode);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>
|
||||
Config
|
||||
<Space style={{ float: "right" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={applying}
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
for (let field of changedFields) {
|
||||
let value = form.getFieldValue(field);
|
||||
let request;
|
||||
if (propsMap.has(field)) {
|
||||
let [fieldName, prop] = propsMap.get(field);
|
||||
request = props.setProp(fieldName, prop, value);
|
||||
|
||||
} else {
|
||||
request = props.setField(field, value);
|
||||
}
|
||||
|
||||
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>
|
||||
</Title>
|
||||
<Card size="small" className="config-container">
|
||||
<Form
|
||||
size="small"
|
||||
form={form}
|
||||
initialValues={initialValues}
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
selectable={false}
|
||||
defaultExpandAll={true}
|
||||
filterTreeNode={(node) => changedFields.has(node.key)}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
</>;
|
||||
}
|
||||
@@ -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 <this.props.Component error={this.state.error} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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 <PageLayout nav={[{ name: "Error" }]}>
|
||||
<h2>An unexpected error occured</h2>
|
||||
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
|
||||
</PageLayout>;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
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 InstanceConfigTree(props) {
|
||||
let control = useContext(ControlContext);
|
||||
|
||||
async function retrieveConfig() {
|
||||
let result = await libLink.messages.getInstanceConfig.send(control, {
|
||||
instance_id: props.id,
|
||||
});
|
||||
return result.serialized_config;
|
||||
}
|
||||
|
||||
async function setField(field, value) {
|
||||
await libLink.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: props.id,
|
||||
field,
|
||||
value: String(value),
|
||||
});
|
||||
}
|
||||
|
||||
async function setProp(field, prop, value) {
|
||||
let data = {
|
||||
instance_id: props.id,
|
||||
field,
|
||||
prop,
|
||||
};
|
||||
if (value) {
|
||||
try {
|
||||
data.value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await libLink.messages.setInstanceConfigProp.send(control, data);
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={libConfig.InstanceConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
id={props.id}
|
||||
/>;
|
||||
}
|
||||
@@ -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 = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
|
||||
} else if (parsed.format === "date") {
|
||||
time = <span className="factorio-time">{parsed.time} </span>;
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (parsed.type === "log") {
|
||||
let level = parsed.level;
|
||||
if (level === "Script") {
|
||||
level = <span className="factorio-script">{level}</span>;
|
||||
} else if (level === "Verbose") {
|
||||
level = <span className="factorio-verbose">{level}</span>;
|
||||
} else if (level === "Info") {
|
||||
level = <span className="factorio-info">{level}</span>;
|
||||
} else if (parsed.level === "Warning") {
|
||||
level = <span className="factorio-warning">{level}</span>;
|
||||
} else if (parsed.level === "Error") {
|
||||
level = <span className="factorio-error">{level}</span>;
|
||||
}
|
||||
|
||||
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
|
||||
|
||||
} else if (parsed.type === "action") {
|
||||
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
|
||||
}
|
||||
|
||||
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
|
||||
}
|
||||
|
||||
function formatLog(info, key) {
|
||||
if (info.level === "server" && info.parsed) {
|
||||
return formatParsedOutput(info.parsed, key);
|
||||
}
|
||||
let level = <span className={`log-${info.level}`}>{info.level}</span>;
|
||||
return <span key={key}>[{level}] {info.message}<br/></span>;
|
||||
}
|
||||
|
||||
export default function InstanceConsole(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let anchor = useRef();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
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([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
|
||||
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 <>
|
||||
<Paragraph code className="instance-console">
|
||||
{pastLines}
|
||||
{lines}
|
||||
<div className="scroll-anchor" ref={anchor} />
|
||||
</Paragraph>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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 && <>
|
||||
<Title level={5}>Rcon result</Title>
|
||||
<Paragraph code className="rcon-result">{output}</Paragraph>
|
||||
</>}
|
||||
<Input.Search
|
||||
disabled={props.disabled}
|
||||
placeholder="Send RCON Command"
|
||||
enterButton="Send"
|
||||
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
|
||||
loading={running}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,587 +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 = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
|
||||
} else if (parsed.format === "date") {
|
||||
time = <span className="factorio-time">{parsed.time} </span>;
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (parsed.type === "log") {
|
||||
let level = parsed.level;
|
||||
if (level === "Script") {
|
||||
level = <span className="factorio-script">{level}</span>;
|
||||
} else if (level === "Verbose") {
|
||||
level = <span className="factorio-verbose">{level}</span>;
|
||||
} else if (level === "Info") {
|
||||
level = <span className="factorio-info">{level}</span>;
|
||||
} else if (parsed.level === "Warning") {
|
||||
level = <span className="factorio-warning">{level}</span>;
|
||||
} else if (parsed.level === "Error") {
|
||||
level = <span className="factorio-error">{level}</span>;
|
||||
}
|
||||
|
||||
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
|
||||
|
||||
} else if (parsed.type === "action") {
|
||||
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
|
||||
}
|
||||
|
||||
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
|
||||
}
|
||||
|
||||
function formatLog(info, key) {
|
||||
if (info.level === "server" && info.parsed) {
|
||||
return formatParsedOutput(info.parsed, key);
|
||||
}
|
||||
let level = <span className={`log-${info.level}`}>{info.level}</span>;
|
||||
return <span key={key}>[{level}] {info.message}<br/></span>;
|
||||
}
|
||||
|
||||
function InstanceConsole(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let anchor = useRef();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
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([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
|
||||
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 <>
|
||||
<Paragraph code className="instance-console">
|
||||
{pastLines}
|
||||
{lines}
|
||||
<div className="scroll-anchor" ref={anchor} />
|
||||
</Paragraph>
|
||||
</>;
|
||||
}
|
||||
|
||||
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 && <>
|
||||
<Title level={5}>Rcon result</Title>
|
||||
<Paragraph code className="rcon-result">{output}</Paragraph>
|
||||
</>}
|
||||
<Input.Search
|
||||
disabled={props.disabled}
|
||||
placeholder="Send RCON Command"
|
||||
enterButton="Send"
|
||||
onSearch={(command) => 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 <>
|
||||
<Button {...props.buttonProps} onClick={open}>
|
||||
{props.buttonContent || "Assign"}
|
||||
</Button>
|
||||
<Modal
|
||||
title="Assign Instance"
|
||||
okText="Assign"
|
||||
visible={visible}
|
||||
confirmLoading={applying}
|
||||
onOk={handleAssign}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
>
|
||||
<Paragraph style={{ maxWidth: "30em" }}>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Form form={form} initialValues={{ slave: props.slaveId }}>
|
||||
<Form.Item name="slave" label="Slave">
|
||||
<Select loading={loading}>
|
||||
{slaves.map((slave) => <Select.Option
|
||||
key={slave["id"]}
|
||||
value={slave["id"]}
|
||||
disabled={!slave["connected"]}
|
||||
>
|
||||
{slave["name"]}
|
||||
{!slave["connected"] && " (offline)"}
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
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 <Button
|
||||
{...(props.buttonProps || {})}
|
||||
loading={switching}
|
||||
type="primary"
|
||||
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
await newConfig.load(result.serialized_config);
|
||||
setConfig(newConfig);
|
||||
}
|
||||
|
||||
useEffect(() => { updateConfig(); }, [props.id]);
|
||||
|
||||
function renderInput(group, def) {
|
||||
if (def.type === "boolean") {
|
||||
return <Checkbox/>;
|
||||
}
|
||||
if (def.type === "string") {
|
||||
return <Input/>;
|
||||
}
|
||||
if (def.type === "number") {
|
||||
return <InputNumber/>;
|
||||
}
|
||||
|
||||
return `Unknown type ${def.type}`;
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return <Spin/>;
|
||||
}
|
||||
|
||||
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()) {
|
||||
let value = group.get(def.name);
|
||||
let fieldName = `${group.name}.${def.name}`;
|
||||
let childNode = {
|
||||
key: fieldName,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (def.type === "object") {
|
||||
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
|
||||
for (let prop of Object.keys(value)) {
|
||||
let propPath = `${group.name}.${def.name}.${prop}`;
|
||||
childNode.children.push({
|
||||
key: propPath,
|
||||
title: <Form.Item
|
||||
name={propPath}
|
||||
label={`"${prop}"`}
|
||||
rules={[{ validator: (_, fieldValue) => {
|
||||
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();
|
||||
}}]}
|
||||
>
|
||||
<Input className="json-input" />
|
||||
</Form.Item>,
|
||||
});
|
||||
|
||||
initialValues[propPath] = JSON.stringify(value[prop]);
|
||||
propsMap.set(propPath, [fieldName, prop]);
|
||||
}
|
||||
|
||||
let newPropPath = `${group.name}.${def.name}:add`;
|
||||
childNode.children.push({
|
||||
key: newPropPath,
|
||||
title: <Space>
|
||||
<Form.Item
|
||||
name={`${newPropPath}.name`}
|
||||
noStyle={true}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>:
|
||||
<Form.Item
|
||||
name={`${newPropPath}.value`}
|
||||
noStyle={true}
|
||||
>
|
||||
<Input className="json-input" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
let propName = form.getFieldValue(`${newPropPath}.name`);
|
||||
let propValue = form.getFieldValue(`${newPropPath}.value`);
|
||||
if (!Object.prototype.hasOwnProperty.call(value, propName)) {
|
||||
let newConfig = new libConfig.InstanceConfig();
|
||||
await newConfig.load(config.serialize());
|
||||
newConfig.setProp(fieldName, propName, null);
|
||||
setConfig(newConfig);
|
||||
}
|
||||
|
||||
let propPath = `${group.name}.${def.name}.${propName}`;
|
||||
form.setFieldsValue({
|
||||
[`${newPropPath}.name`]: "",
|
||||
[`${newPropPath}.value`]: "",
|
||||
[propPath]: propValue,
|
||||
});
|
||||
form.validateFields([propPath]);
|
||||
onValuesChange({ [propPath]: propValue });
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Space>,
|
||||
});
|
||||
|
||||
} else {
|
||||
childNode.title = <Form.Item
|
||||
name={fieldName}
|
||||
label={def.title || def.name}
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
tooltip={def.description}
|
||||
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
||||
>
|
||||
{renderInput(group, def)}
|
||||
</Form.Item>;
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
|
||||
treeNode.children.push(childNode);
|
||||
}
|
||||
|
||||
treeData.push(treeNode);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>
|
||||
Config
|
||||
<Space style={{ float: "right" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={applying}
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
for (let field of changedFields) {
|
||||
let value = form.getFieldValue(field);
|
||||
let request;
|
||||
if (propsMap.has(field)) {
|
||||
let [fieldName, prop] = propsMap.get(field);
|
||||
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>
|
||||
</Title>
|
||||
<Card size="small" className="config-container">
|
||||
<Form
|
||||
size="small"
|
||||
form={form}
|
||||
initialValues={initialValues}
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
selectable={false}
|
||||
defaultExpandAll={true}
|
||||
filterTreeNode={(node) => changedFields.has(node.key)}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function InstanceViewPage(props) {
|
||||
let params = useParams();
|
||||
let instanceId = Number(params.id);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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 <BaseConfigTree
|
||||
ConfigClass={libConfig.MasterConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import MasterConfigTree from "./MasterConfigTree";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
|
||||
export default function SlavesPage() {
|
||||
return <PageLayout nav={[{ name: "Master" }]}>
|
||||
<h2>Master</h2>
|
||||
<MasterConfigTree />
|
||||
</PageLayout>;
|
||||
};
|
||||
@@ -3,9 +3,13 @@ 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;
|
||||
|
||||
|
||||
import MasterPage from "./MasterPage";
|
||||
import SlavesPage from "./SlavesPage";
|
||||
import InstancesPage from "./InstancesPage";
|
||||
import InstanceViewPage from "./InstanceViewPage";
|
||||
@@ -21,6 +25,7 @@ const pages = [
|
||||
];
|
||||
|
||||
const sidebar = [
|
||||
{ name: "Master", path: "/master", content: <MasterPage /> },
|
||||
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
|
||||
{ name: "Instances", path: "/instances", content: <InstancesPage />},
|
||||
{ name: "Users", path: "/users", content: <UsersPage /> },
|
||||
@@ -74,7 +79,9 @@ export default function SiteLayout(props) {
|
||||
</Route>)}
|
||||
{pages.map(page => <Route exact path={page.path} key={page.path}>
|
||||
<SetSidebar path={page.sidebarPath} />
|
||||
{page.content}
|
||||
<ErrorBoundary Component={ErrorPage}>
|
||||
{page.content}
|
||||
</ErrorBoundary>
|
||||
</Route>)}
|
||||
<Route>
|
||||
<SetSidebar path={null} />
|
||||
|
||||
@@ -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 <Button
|
||||
{...(props.buttonProps || {})}
|
||||
loading={switching}
|
||||
type="primary"
|
||||
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
+177
-33
@@ -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;
|
||||
|
||||
+3
-3
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user