mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Add instance templates for instance config inheritance (#1142)
* Add base instances for instance config inheritance Instances can be marked as a base with instance.is_base and other instances inherit config values from it through instance.base_id. The controller copies base values into inheriting configs and keeps track of inherited fields in the hidden instance.inherited_fields, so hosts receive resolved configs and need no changes. Setting a field stops inheriting it, `instance config inherit` undoes that. Bases cannot be assigned to a host, are hidden from `instance list` unless --bases is given, and cannot be deleted or unmarked while other instances inherit from them. Chains of bases are allowed and cycles are rejected. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename bases to templates and add per property inheritance Review changes for #1142: - base becomes template everywhere (is_template, template_id, --template, --is-template, --templates, InstanceDetails.isTemplate). - Object fields inherit per property. inherited_fields lists the overridden properties for object fields and the template object is merged with them. - Linking a template also inherits fields that hold the default value. The server name stays per instance. - Ports are never inherited. - InstanceDetails carries templateId. Web UI: inherited values are locked until unlocked, overridden values can be inherited again, templates are listed separately, the create modal can create templates and instances from templates, and the instance page has "Create template" which can link the source. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give templates their own table and keep rcon_password per instance The templates table shows name, inheritor count and a create instance action instead of the instance status and start/stop columns. factorio.rcon_password joins the ports as never inherited. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use link and disconnect icons for template inheritance Overridden values show a disconnect icon. Clicking it stages a link, shown with a link icon, which is applied together with the other changes instead of right away. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show the template value while a link is staged Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review of template inheritance - Unlinking a field enables Apply on its own and sends the current value. - The link button no longer toggles the checkbox of its label. - Link and disconnect icons for all four states, pending ones highlighted. - instance.is_template is hidden. - ctl create takes --as-template. - Clearer names for the InstanceManager inheritance helpers. Part of #200 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0a23af61f9
23 files changed
+1458
-73
No files matched your search
@@ -521,6 +521,31 @@ This allows you to prevent certain instances from being started when using the b
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.is_template
|
||||
|
||||
If enabled this instance is a template other instances can inherit config values from.
|
||||
Templates cannot be assigned to a host and therefore never start.
|
||||
Hidden from `instance list` in clusterioctl unless `--templates` is passed, and listed separately in the web interface.
|
||||
An instance cannot stop being a template while other instances inherit from it.
|
||||
Hidden in the web interface, set when creating the template.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.template_id
|
||||
|
||||
ID of the template to inherit config values from.
|
||||
Inherited fields take their value from the template and follow it when it changes, setting a field on this instance stops inheriting it.
|
||||
Object fields like `factorio.settings` are inherited per property, properties set on this instance override the ones from the template.
|
||||
When set on an existing instance fields with the same value as the template or the default value become inherited, except the server name in `factorio.settings`.
|
||||
`instance.name`, `instance.is_template`, `instance.template_id`, the port fields, `factorio.rcon_password` and hidden fields are never inherited.
|
||||
A template may itself inherit from another template, but not from itself through a chain of templates.
|
||||
The fields currently inherited are stored in the hidden `instance.inherited_fields` field.
|
||||
In the web interface inherited values are linked and read only until they are unlinked.
|
||||
|
||||
Defaults to null meaning no template.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
|
||||
@@ -79,15 +79,16 @@ Marks the host as requiring a restart, the same way a plugin update does. Use it
|
||||
|
||||
### List Instances
|
||||
|
||||
ctl> instance list
|
||||
ctl> instance list [--templates]
|
||||
|
||||
List instances.
|
||||
Templates are only listed if `--templates` is passed.
|
||||
|
||||
### Create instance
|
||||
|
||||
ctl> instance create <name> [--id <instance id>]
|
||||
ctl> instance create <name> [--id <instance id>] [--from <instance id>] [--template <instance>] [--as-template]
|
||||
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id.
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id. `--from` copies the config of an existing instance. `--template` makes the new instance inherit all config values from the given template. `--as-template` creates the instance as a template, see `instance.is_template` in the [configuration docs](configuration.md). Use `--as-template --from <instance>` to make a template out of an existing instance.
|
||||
|
||||
### Kill instance
|
||||
|
||||
@@ -120,7 +121,7 @@ Most notably Clusterio records wall clock time while the save time is recorded i
|
||||
|
||||
ctl> instance config list <instance>
|
||||
|
||||
List config values for an instance. `instance` is the instance whose config values to list.
|
||||
List config values for an instance. `instance` is the instance whose config values to list. Values inherited from a template are marked with `(inherited)`, object fields also list the properties set on the instance.
|
||||
|
||||
#### Set config value
|
||||
|
||||
@@ -132,11 +133,17 @@ Set field in instance config. `instance` is instance to set. `field` is field to
|
||||
|
||||
To be written.
|
||||
|
||||
#### Inherit config value
|
||||
|
||||
ctl> instance config inherit <instance> <field> [prop]
|
||||
|
||||
Make a field in the instance config take its value from the template again, undoing a previous `set`. `instance` is the instance to update, `field` is the field to inherit, `prop` limits it to one property of an object field.
|
||||
|
||||
#### Edit instance config
|
||||
|
||||
ctl> instance config edit <instance> [editor]
|
||||
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, changed config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
|
||||
### Assign instance to host
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { logFilter, logger } from "@clusterio/lib";
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import * as routes from "./routes.js";
|
||||
import Controller from "./Controller.js";
|
||||
|
||||
@@ -94,6 +95,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigInheritFieldRequest, this.handleInstanceConfigInheritFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceRenameSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
|
||||
@@ -315,6 +317,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
}
|
||||
instanceConfig.update(request.config, false, "control");
|
||||
this._controller.instances.checkInheritance(
|
||||
instanceConfig, instanceConfig.get("instance.is_template"), instanceConfig.get("instance.template_id")
|
||||
);
|
||||
if (Object.hasOwn(request.config, "instance.template_id")) {
|
||||
this._controller.instances.applyTemplate(instanceConfig, Object.keys(request.config));
|
||||
}
|
||||
await this._controller.instances.createInstance(instanceConfig);
|
||||
}
|
||||
|
||||
@@ -355,17 +363,41 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
this.checkStagedInheritance(instance);
|
||||
instance.config.commitStaging();
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
const props = typeof fieldValue === "object"
|
||||
? Object.keys(fieldValue).filter(prop => fieldValue[prop] !== undefined)
|
||||
: undefined;
|
||||
this._controller.instances.overrideField(instance, fieldName, props);
|
||||
}
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
private checkStagedInheritance(instance: InstanceRecord) {
|
||||
const staged = instance.config.staging;
|
||||
this._controller.instances.checkInheritance(
|
||||
instance.config, staged.get("instance.is_template"), staged.get("instance.template_id")
|
||||
);
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
const field = request.field as keyof lib.InstanceConfigFields;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
if (["instance.is_template", "instance.template_id"].includes(field)) {
|
||||
try {
|
||||
instance.config.stage(field, request.value, "control");
|
||||
this.checkStagedInheritance(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
instance.config.set(field, request.value, "control");
|
||||
this._controller.instances.overrideField(instance, field);
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
@@ -374,9 +406,18 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { field, prop, value } = request;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
if (value !== undefined) {
|
||||
this._controller.instances.overrideField(instance, field, [prop]);
|
||||
}
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceConfigInheritFieldRequest(request: lib.InstanceConfigInheritFieldRequest) {
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
this._controller.instances.inheritField(request.instanceId, request.field, request.prop);
|
||||
await this._controller.instanceConfigUpdated(this._controller.instances.getForRequest(request.instanceId));
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
|
||||
type InheritedFields = lib.InstanceConfigFields["instance.inherited_fields"];
|
||||
type InheritableField = keyof lib.InstanceConfigFields & string;
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
tags: ["clusterio"],
|
||||
@@ -24,6 +29,9 @@ export default class InstanceManager {
|
||||
non_blocking_saving: false,
|
||||
} as const;
|
||||
|
||||
/** Non-zero while template values are being copied into inheriting instances */
|
||||
private _inheriting = 0;
|
||||
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
|
||||
private readonly _controller: Controller,
|
||||
@@ -106,11 +114,271 @@ export default class InstanceManager {
|
||||
*/
|
||||
addInstanceHooks(instance: InstanceRecord): void {
|
||||
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
|
||||
if (field === "instance.template_id") {
|
||||
this._linkMatchingFields(instance);
|
||||
} else if (!this._inheriting && instance.config.isInherited(field)) {
|
||||
this._unlinkEditedField(instance, field, curr, prev);
|
||||
}
|
||||
this.records.set(instance);
|
||||
this._notifyConfigFieldChanged(instance, field, curr, prev);
|
||||
this._updateInheritors(instance, field, curr);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Inheritance */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the instances that inherit directly from the given template.
|
||||
*
|
||||
* @param templateId - ID of the template.
|
||||
* @returns Instances with `instance.template_id` set to templateId.
|
||||
*/
|
||||
inheritorsOf(templateId: number): InstanceRecord[] {
|
||||
return [...this.records.values()].filter(
|
||||
instance => instance.config.get("instance.template_id") === templateId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that new values for `instance.is_template` and `instance.template_id` are allowed.
|
||||
*
|
||||
* @param config - Current config of the instance.
|
||||
* @param isTemplate - New value for `instance.is_template`.
|
||||
* @param templateId - New value for `instance.template_id`.
|
||||
* @throws {module:lib.ValidationError} if the template is not a template or would create a cycle.
|
||||
* @throws {module:lib.ConflictError} if the instance is assigned or still has inheritors.
|
||||
*/
|
||||
checkInheritance(config: lib.InstanceConfig, isTemplate: boolean, templateId: number | null): void {
|
||||
const instanceId = config.get("instance.id");
|
||||
if (isTemplate && config.get("instance.assigned_host") !== null) {
|
||||
throw new lib.ConflictError("Instance must be unassigned before it can be made a template");
|
||||
}
|
||||
if (!isTemplate && config.get("instance.is_template")) {
|
||||
const count = this.inheritorsOf(instanceId).length;
|
||||
if (count) {
|
||||
throw new lib.ConflictError(`Instance is the template of ${count} other instance(s)`);
|
||||
}
|
||||
}
|
||||
if (templateId === null) {
|
||||
return;
|
||||
}
|
||||
const template = this.records.get(templateId);
|
||||
if (!template) {
|
||||
throw new lib.NotFoundError(`Instance with ID ${templateId} does not exist`);
|
||||
}
|
||||
if (!template.config.get("instance.is_template")) {
|
||||
throw new lib.ValidationError(`Instance ${template.config.get("instance.name")} is not a template`);
|
||||
}
|
||||
const visited = new Set<number>();
|
||||
for (let id: number | null = templateId; id !== null && !visited.has(id);) {
|
||||
if (id === instanceId) {
|
||||
throw new lib.ValidationError("Templates cannot inherit from themselves");
|
||||
}
|
||||
visited.add(id);
|
||||
id = this.records.get(id)?.config.get("instance.template_id") ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the inheritable values from the template set in `instance.template_id`
|
||||
* into a config that is not yet part of the cluster and mark them inherited.
|
||||
*
|
||||
* @param config - Config of the instance to be created.
|
||||
* @param own - Fields set by the creator, object fields keep their properties as overrides.
|
||||
*/
|
||||
applyTemplate(config: lib.InstanceConfig, own: string[] = []): void {
|
||||
const template = this._templateOf(config);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const inherited: InheritedFields = {};
|
||||
for (const name of InstanceManager._inheritableFields()) {
|
||||
const def = lib.InstanceConfig.fieldDefinitions[name];
|
||||
if (def.type === "object") {
|
||||
inherited[name] = own.includes(name) ? Object.keys(config.get(name) as object ?? {}) : [];
|
||||
} else if (!own.includes(name)) {
|
||||
inherited[name] = true;
|
||||
}
|
||||
}
|
||||
config.set("instance.inherited_fields", inherited);
|
||||
config.update(InstanceManager._inheritedValues(config, template.config, Object.keys(inherited)), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a field, or a property of an object field, take its value from the template again.
|
||||
*
|
||||
* @param instanceId - ID of the instance to update.
|
||||
* @param field - Name of the field to inherit.
|
||||
* @param prop - Property of an object field to inherit, inherits all properties if not given.
|
||||
* @throws {module:lib.ValidationError} if the instance has no template or the field cannot be inherited.
|
||||
*/
|
||||
inheritField(instanceId: number, field: string, prop?: string): void {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
if (!this._templateOf(instance.config)) {
|
||||
throw new lib.ValidationError("Instance does not have a template");
|
||||
}
|
||||
if (!lib.InstanceConfig.isInheritable(field)) {
|
||||
throw new lib.ValidationError(`Field ${field} cannot be inherited`);
|
||||
}
|
||||
const name = field as InheritableField;
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields") };
|
||||
if (lib.InstanceConfig.fieldDefinitions[name].type !== "object") {
|
||||
inherited[field] = true;
|
||||
} else if (prop === undefined) {
|
||||
inherited[field] = [];
|
||||
} else {
|
||||
const entry = inherited[field];
|
||||
const overrides = entry instanceof Array ? entry : Object.keys(instance.config.get(name) as object ?? {});
|
||||
inherited[field] = overrides.filter(p => p !== prop);
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop inheriting the given field or properties, used when a value is explicitly set.
|
||||
*
|
||||
* @param instance - Instance the field was set on.
|
||||
* @param field - Name of the field set.
|
||||
* @param props - Properties set on an object field.
|
||||
*/
|
||||
overrideField(instance: InstanceRecord, field: string, props?: string[]): void {
|
||||
const entry = instance.config.get("instance.inherited_fields")[field];
|
||||
if (!instance.config.isInherited(field)) {
|
||||
return;
|
||||
}
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields") };
|
||||
if (entry instanceof Array && props) {
|
||||
inherited[field] = [...new Set([...entry, ...props])];
|
||||
} else {
|
||||
delete inherited[field];
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
}
|
||||
|
||||
private _templateOf(config: lib.InstanceConfig) {
|
||||
const templateId = config.get("instance.template_id");
|
||||
return templateId !== null ? this.records.get(templateId) : undefined;
|
||||
}
|
||||
|
||||
private static _inheritableFields() {
|
||||
return Object.keys(lib.InstanceConfig.fieldDefinitions).filter(
|
||||
name => lib.InstanceConfig.isInheritable(name)
|
||||
) as InheritableField[];
|
||||
}
|
||||
|
||||
/** Default value of a field, or undefined if it is generated */
|
||||
private static _defaultValue(name: InheritableField): unknown {
|
||||
const def = lib.InstanceConfig.fieldDefinitions[name];
|
||||
if (name === "factorio.settings") {
|
||||
return InstanceManager.DefaultFactorioSettings;
|
||||
}
|
||||
return typeof def.initialValue === "function" ? undefined : def.initialValue ?? null;
|
||||
}
|
||||
|
||||
/** Values the given inherited fields take from the template, with object overrides applied */
|
||||
private static _inheritedValues(config: lib.InstanceConfig, template: lib.InstanceConfig, fields: string[]) {
|
||||
const inherited = config.get("instance.inherited_fields");
|
||||
const values: Record<string, any> = {};
|
||||
for (const field of fields) {
|
||||
const name = field as InheritableField;
|
||||
const entry = inherited[field];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const value = structuredClone(template.get(name)) as any;
|
||||
if (entry instanceof Array) {
|
||||
const own = config.get(name) as Record<string, unknown> ?? {};
|
||||
for (const prop of entry) {
|
||||
if (Object.hasOwn(own, prop)) {
|
||||
value[prop] = structuredClone(own[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
values[field] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Update the given inherited fields of a live instance from its template */
|
||||
private _copyInheritedValues(instance: InstanceRecord, fields: string[]): void {
|
||||
const template = this._templateOf(instance.config);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const values = InstanceManager._inheritedValues(instance.config, template.config, fields);
|
||||
this._inheriting += 1;
|
||||
try {
|
||||
instance.config.update(values, true);
|
||||
} finally {
|
||||
this._inheriting -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlink a field, or the changed properties of an object field, edited on the instance */
|
||||
private _unlinkEditedField(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): void {
|
||||
const entry = instance.config.get("instance.inherited_fields")[field];
|
||||
if (!(entry instanceof Array)) {
|
||||
this.overrideField(instance, field);
|
||||
return;
|
||||
}
|
||||
const currObj = curr as Record<string, unknown> ?? {};
|
||||
const prevObj = prev as Record<string, unknown> ?? {};
|
||||
const changed = Object.keys(currObj).filter(p => !isDeepStrictEqual(currObj[p], prevObj[p]));
|
||||
const removed = Object.keys(prevObj).filter(p => !Object.hasOwn(currObj, p));
|
||||
const overrides = [...new Set([...entry, ...changed])].filter(p => !removed.includes(p));
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields"), [field]: overrides };
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
if (removed.length) {
|
||||
// Removed properties fall back to the template value
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute inherited fields after template_id changed, values equal to the template or default are inherited */
|
||||
private _linkMatchingFields(instance: InstanceRecord): void {
|
||||
const template = this._templateOf(instance.config);
|
||||
const inherited: InheritedFields = {};
|
||||
if (template) {
|
||||
for (const name of InstanceManager._inheritableFields()) {
|
||||
const value = instance.config.get(name) as any;
|
||||
const templateValue = template.config.get(name) as any;
|
||||
const defaultValue = InstanceManager._defaultValue(name) as any;
|
||||
if (lib.InstanceConfig.fieldDefinitions[name].type === "object") {
|
||||
inherited[name] = Object.keys(value ?? {}).filter(prop => !(
|
||||
isDeepStrictEqual(value[prop], templateValue?.[prop])
|
||||
|| defaultValue && isDeepStrictEqual(value[prop], defaultValue[prop])
|
||||
) || name === "factorio.settings" && prop === "name");
|
||||
} else if (
|
||||
isDeepStrictEqual(value, templateValue)
|
||||
|| defaultValue !== undefined && isDeepStrictEqual(value, defaultValue)
|
||||
) {
|
||||
inherited[name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
this._copyInheritedValues(instance, Object.keys(inherited));
|
||||
}
|
||||
|
||||
/** Copy a changed value from a template to the instances inheriting it */
|
||||
private _updateInheritors(template: InstanceRecord, field: string, value: unknown): void {
|
||||
if (!lib.InstanceConfig.isInheritable(field)) {
|
||||
return;
|
||||
}
|
||||
for (const instance of this.inheritorsOf(template.id)) {
|
||||
if (!instance.config.isInherited(field)) {
|
||||
continue;
|
||||
}
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
this._controller.instanceConfigUpdated(instance).catch(err => {
|
||||
logger.error(`Failed to send config of instance ${instance.id} to its host:\n${err.stack}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -137,12 +405,23 @@ export default class InstanceManager {
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
});
|
||||
const serverName = `${controllerName} - ${instanceConfig.get("instance.name")}`;
|
||||
const settings = instanceConfig.get("factorio.settings");
|
||||
if (!instanceConfig.isInherited("factorio.settings")) {
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: serverName,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...settings,
|
||||
});
|
||||
} else if (instanceConfig.isInherited("factorio.settings", "name")) {
|
||||
// Each instance gets its own server name
|
||||
instanceConfig.setProp("factorio.settings", "name", serverName);
|
||||
const inherited = instanceConfig.get("instance.inherited_fields");
|
||||
instanceConfig.set("instance.inherited_fields", {
|
||||
...inherited, "factorio.settings": [...inherited["factorio.settings"] as string[], "name"],
|
||||
});
|
||||
}
|
||||
|
||||
const instance = new InstanceRecord(instanceConfig, "unassigned");
|
||||
this.addInstanceHooks(instance);
|
||||
@@ -186,6 +465,9 @@ export default class InstanceManager {
|
||||
if (currentAssignedHost === hostId) {
|
||||
return;
|
||||
}
|
||||
if (hostId !== null && instance.config.get("instance.is_template")) {
|
||||
throw new lib.ValidationError("Templates cannot be assigned to a host");
|
||||
}
|
||||
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== null) {
|
||||
@@ -252,6 +534,10 @@ export default class InstanceManager {
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const inheritors = this.inheritorsOf(instanceId).length;
|
||||
if (inheritors) {
|
||||
throw new lib.ConflictError(`Instance is the template of ${inheritors} other instance(s)`);
|
||||
}
|
||||
|
||||
if (hostId !== null) {
|
||||
await this._controller.sendTo(
|
||||
|
||||
@@ -83,6 +83,8 @@ export default class InstanceRecord {
|
||||
this.startedAtMs,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
this.config.get("instance.is_template"),
|
||||
this.config.get("instance.template_id") ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ export const instanceCommands = new lib.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List instances known to the controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
definition: [["list", "l"], "List instances known to the controller", (yargs) => {
|
||||
yargs.options({
|
||||
"templates": { describe: "Include templates", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { templates: boolean }, ctl: Ctl) {
|
||||
let list = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
print(asTable(list));
|
||||
print(asTable(list.filter(instance => args.templates || !instance.isTemplate)));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -34,18 +38,30 @@ instanceCommands.add(new lib.Command({
|
||||
yargs.options({
|
||||
"id": { type: "number", nargs: 1, describe: "Instance id" },
|
||||
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
|
||||
"template": { type: "string", nargs: 1, describe: "Template to inherit config from" },
|
||||
"as-template": { type: "boolean", nargs: 0, describe: "Create the instance as a template" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
|
||||
handler: async function(
|
||||
args: { name: string, id?: number, from?: number, template?: string, asTemplate?: boolean },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
let fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
|
||||
if (args.id !== undefined) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
if (args.template !== undefined) {
|
||||
instanceConfig.set("instance.template_id", await lib.resolveInstance(ctl, args.template));
|
||||
fields.push("instance.template_id");
|
||||
}
|
||||
if (args.asTemplate) {
|
||||
instanceConfig.set("instance.is_template", true);
|
||||
fields.push("instance.is_template");
|
||||
}
|
||||
await ctl.send(new lib.InstanceCreateRequest(
|
||||
instanceConfig.toRemote("controller", [
|
||||
"instance.id", "instance.name",
|
||||
]),
|
||||
instanceConfig.toRemote("controller", fields),
|
||||
args.from
|
||||
));
|
||||
},
|
||||
@@ -61,13 +77,42 @@ instanceConfigCommands.add(new lib.Command({
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
const inherited = config["instance.template_id"] !== null
|
||||
? config["instance.inherited_fields"] as Record<string, true | string[]> ?? {}
|
||||
: {};
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
if (name === "instance.inherited_fields") {
|
||||
continue;
|
||||
}
|
||||
const entry = inherited[name];
|
||||
let note = "";
|
||||
if (entry instanceof Array && entry.length) {
|
||||
note = ` (inherited except ${entry.join(", ")})`;
|
||||
} else if (entry) {
|
||||
note = " (inherited)";
|
||||
}
|
||||
print(`${name} ${JSON.stringify(value)}${note}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"inherit <instance> <field> [prop]",
|
||||
"Make field or property in instance config use the value of its template",
|
||||
(yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to update config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to inherit", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property of object field to inherit", type: "string" });
|
||||
},
|
||||
],
|
||||
handler: async function(args: { instance: string, field: string, prop?: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceConfigInheritFieldRequest(instanceId, args.field, args.prop));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
@@ -170,8 +215,10 @@ instanceConfigCommands.add(new lib.Command({
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
const original = await configToKeyVal(allConfigElements);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
// Unchanged fields are skipped so they keep inheriting from the template
|
||||
if (index in final && final[index] !== original[index]) {
|
||||
try {
|
||||
await ctl.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
|
||||
@@ -488,6 +488,9 @@ export interface InstanceConfigFields {
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
"instance.is_template": boolean;
|
||||
"instance.template_id": number | null;
|
||||
"instance.inherited_fields": Record<string, true | string[]>;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
[key: `${string}.version`]: string;
|
||||
|
||||
@@ -539,6 +542,38 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Fields that are never inherited from a template, ports clash on a shared host and secrets stay per instance */
|
||||
static uninheritableFields = [
|
||||
"instance.name", "instance.is_template", "instance.template_id",
|
||||
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
|
||||
];
|
||||
|
||||
/** True if the field can take its value from a template */
|
||||
static isInheritable(name: string) {
|
||||
const def = this.fieldDefinitions[name as keyof InstanceConfigFields];
|
||||
return Boolean(def)
|
||||
&& !def.hidden
|
||||
&& (def.access ?? this.defaultAccess).includes("controller")
|
||||
&& !this.uninheritableFields.includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the field, or the given property of an object field, takes its value from the template
|
||||
*
|
||||
* @param name - Name of field to check.
|
||||
* @param prop - Property of an object field to check.
|
||||
*/
|
||||
isInherited(name: string, prop?: string) {
|
||||
if (this.get("instance.template_id") === null) {
|
||||
return false;
|
||||
}
|
||||
const entry = this.get("instance.inherited_fields")[name];
|
||||
if (entry instanceof Array) {
|
||||
return prop === undefined || !entry.includes(prop);
|
||||
}
|
||||
return Boolean(entry);
|
||||
}
|
||||
|
||||
static fieldDefinitions: classes.ConfigDefs<InstanceConfigFields> = {
|
||||
"instance.name": {
|
||||
type: "string",
|
||||
@@ -566,6 +601,33 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.is_template": {
|
||||
description:
|
||||
"Mark this instance as a template other instances can inherit config from. " +
|
||||
"Templates cannot be assigned to a host or started.",
|
||||
access: ["controller", "control"],
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
hidden: true,
|
||||
},
|
||||
"instance.template_id": {
|
||||
title: "Template",
|
||||
description: "ID of the template to inherit config values from",
|
||||
access: ["controller", "control"],
|
||||
inputComponent: "instance_template",
|
||||
type: "number",
|
||||
optional: true,
|
||||
// Checked by the controller
|
||||
},
|
||||
"instance.inherited_fields": {
|
||||
description:
|
||||
"Fields whose value comes from the template, for object fields the properties set on this instance",
|
||||
access: ["controller", "control"],
|
||||
readonly: ["controller"],
|
||||
type: "object",
|
||||
initialValue: {},
|
||||
hidden: true,
|
||||
},
|
||||
"factorio.version": {
|
||||
description:
|
||||
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
|
||||
|
||||
@@ -41,7 +41,9 @@ export default class InstanceDetails {
|
||||
public startedAtMs = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false
|
||||
public excludeFromStartAll = false,
|
||||
public isTemplate = false,
|
||||
public templateId?: number,
|
||||
) {}
|
||||
|
||||
get isDeleted() {
|
||||
@@ -61,6 +63,8 @@ export default class InstanceDetails {
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
"isTemplate": Type.Optional(Type.Boolean()),
|
||||
"templateId": Type.Optional(Type.Integer()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -73,7 +77,9 @@ export default class InstanceDetails {
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll
|
||||
json.excludeFromStartAll,
|
||||
json.isTemplate,
|
||||
json.templateId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +108,12 @@ export default class InstanceDetails {
|
||||
if (this.excludeFromStartAll !== false) {
|
||||
json.excludeFromStartAll = this.excludeFromStartAll;
|
||||
}
|
||||
if (this.isTemplate !== false) {
|
||||
json.isTemplate = this.isTemplate;
|
||||
}
|
||||
if (this.templateId !== undefined) {
|
||||
json.templateId = this.templateId;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -176,6 +176,30 @@ export class InstanceConfigSetPropRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigInheritFieldRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigInheritFieldRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.instance.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
public field: string,
|
||||
public prop?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instanceId": Type.Integer(),
|
||||
"field": Type.String(),
|
||||
"prop": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.instanceId, json.field, json.prop);
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceAssignRequest {
|
||||
declare ["constructor"]: typeof InstanceAssignRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceConfigSetRequest,
|
||||
instance.InstanceConfigSetFieldRequest,
|
||||
instance.InstanceConfigSetPropRequest,
|
||||
instance.InstanceConfigInheritFieldRequest,
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as lib from "@clusterio/lib";
|
||||
import App from "./components/App";
|
||||
import InputRole from "./components/InputRole";
|
||||
import InputModPack from "./components/InputModPack";
|
||||
import { InputInstanceTemplate } from "./components/InputInstance";
|
||||
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
|
||||
import { Control, ControlConnector } from "./util/websocket";
|
||||
import { loadedPluginSetKey } from "./util/pluginSet";
|
||||
@@ -148,6 +149,7 @@ async function inputComponentsFromHooks(control: Control) {
|
||||
"target_version": InputTargetVersion,
|
||||
"mod_pack": InputModPack,
|
||||
"role": InputRole,
|
||||
"instance_template": InputInstanceTemplate,
|
||||
}]);
|
||||
|
||||
const iterable = result.map(
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography,
|
||||
} from "antd";
|
||||
import DisconnectOutlined from "@ant-design/icons/DisconnectOutlined";
|
||||
import LinkOutlined from "@ant-design/icons/LinkOutlined";
|
||||
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -65,8 +67,28 @@ function renderInput(inputComponents: Map<string, InputComponent>, def: lib.Fiel
|
||||
return `Unknown type ${def.type}`;
|
||||
}
|
||||
|
||||
/** Lets fields follow the value of another config, inherited values are locked until unlinking */
|
||||
export type ConfigInheritance = {
|
||||
canInherit: (config: lib.Config<any>, field: string) => boolean;
|
||||
isInherited: (config: lib.Config<any>, field: string, prop?: string) => boolean;
|
||||
inherit: (field: string, prop?: string) => Promise<void>;
|
||||
templateValue: (field: string, prop?: string) => unknown;
|
||||
notice?: (config: lib.Config<any>) => React.ReactNode;
|
||||
};
|
||||
|
||||
type Linking = Map<string, [field: string, prop?: string]>;
|
||||
|
||||
type LinkState = {
|
||||
inheritance?: ConfigInheritance;
|
||||
unlinking: Set<string>;
|
||||
setUnlinking: (unlinking: Set<string>) => void;
|
||||
linking: Linking;
|
||||
setLinking: (linking: Linking) => void;
|
||||
};
|
||||
|
||||
type BaseConfigTreeProps = {
|
||||
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
|
||||
inheritance?: ConfigInheritance;
|
||||
retrieveConfig: () => Promise<lib.ConfigSchema>;
|
||||
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
|
||||
id?: number;
|
||||
@@ -79,6 +101,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
|
||||
let [errorFields, setErrorFields] = useState(new Map<string, string>());
|
||||
let [applying, setApplying] = useState(false);
|
||||
let [unlinking, setUnlinking] = useState<Set<string>>(new Set());
|
||||
let [linking, setLinking] = useState<Linking>(new Map());
|
||||
const available = props.available ?? true;
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
@@ -136,6 +160,18 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChanges(fields: Record<string, string | Record<string, string>>) {
|
||||
if (Object.keys(fields).length) {
|
||||
await props.setConfig(fields);
|
||||
}
|
||||
for (const [field, prop] of linking.values()) {
|
||||
await props.inheritance!.inherit(field, prop);
|
||||
}
|
||||
setChangedFields(new Set());
|
||||
setUnlinking(new Set());
|
||||
setLinking(new Map());
|
||||
}
|
||||
|
||||
const [propsMap, treeData] = computeTreeData(
|
||||
control,
|
||||
form,
|
||||
@@ -145,6 +181,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
setConfig,
|
||||
props.ConfigClass,
|
||||
onValuesChange,
|
||||
{ inheritance: props.inheritance, unlinking, setUnlinking, linking, setLinking },
|
||||
);
|
||||
|
||||
return <>
|
||||
@@ -158,7 +195,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
const fields: Record<string, string | Record<string, string>> = {};
|
||||
for (const field of changedFields) {
|
||||
// Unlinked fields are sent even when unchanged so they stop following the template
|
||||
for (const field of new Set([...changedFields, ...unlinking])) {
|
||||
const value = form.getFieldValue(field);
|
||||
if (propsMap.has(field)) {
|
||||
const [fieldName, prop] = propsMap.get(field)!;
|
||||
@@ -179,21 +217,26 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
await props.setConfig(fields);
|
||||
setChangedFields(new Set());
|
||||
await applyChanges(fields);
|
||||
applied = true;
|
||||
} catch (err: any) {
|
||||
notify(err.message, "error");
|
||||
}
|
||||
|
||||
await updateConfig();
|
||||
const newConfig = await updateConfig();
|
||||
if (applied) {
|
||||
// Linking can change other values
|
||||
form.setFieldsValue(getInitialValues(newConfig, props));
|
||||
}
|
||||
setApplying(false);
|
||||
|
||||
if (props.onApply) {
|
||||
props.onApply();
|
||||
}
|
||||
}}
|
||||
disabled={changedFields.size === 0}
|
||||
disabled={changedFields.size === 0 && linking.size === 0 && unlinking.size === 0}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
@@ -202,6 +245,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setChangedFields(new Set());
|
||||
setUnlinking(new Set());
|
||||
setLinking(new Map());
|
||||
setErrorFields(new Map());
|
||||
}}
|
||||
>
|
||||
@@ -210,6 +255,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
</Space>
|
||||
</Title>
|
||||
<Card size="small" className="config-container">
|
||||
{props.inheritance?.notice?.(config)}
|
||||
<Form
|
||||
size="small"
|
||||
form={form}
|
||||
@@ -237,6 +283,7 @@ function computeTreeData(
|
||||
setConfig: (newConfig: lib.Config<any>) => void,
|
||||
ConfigClass: BaseConfigTreeProps["ConfigClass"],
|
||||
onValuesChange: (changedValues: object) => void,
|
||||
link: LinkState,
|
||||
) {
|
||||
let restartTip = <Tooltip
|
||||
className="ant-form-item-tooltip"
|
||||
@@ -254,6 +301,64 @@ function computeTreeData(
|
||||
children: ChildNode[];
|
||||
};
|
||||
|
||||
function linkButton(key: string, fieldName: string, prop?: string) {
|
||||
const { inheritance, unlinking, setUnlinking, linking, setLinking } = link;
|
||||
if (!inheritance?.canInherit(config, fieldName) || !config.canAccess(fieldName, lib.ConfigAccess.write)) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
title: string, icon: React.ReactNode, label: string, pending: boolean, onClick: () => void,
|
||||
) => <Tooltip title={title}>
|
||||
<Button
|
||||
type={pending ? "link" : "text"} size="small" icon={icon} aria-label={`${label} ${key}`}
|
||||
onClick={e => {
|
||||
// The button sits in the label, which would toggle checkboxes
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>;
|
||||
const revert = () => {
|
||||
form.setFieldValue(key, initialValues[key]);
|
||||
onValuesChange({ [key]: initialValues[key] });
|
||||
};
|
||||
if (inheritance.isInherited(config, fieldName, prop)) {
|
||||
if (!unlinking.has(key)) {
|
||||
return button(
|
||||
"Linked to the template. Click to unlink it and set your own value.", <LinkOutlined />, "Unlink",
|
||||
false, () => setUnlinking(new Set([...unlinking, key])),
|
||||
);
|
||||
}
|
||||
return button(
|
||||
"Unlinked from the template when applied. Click to keep it linked.", <DisconnectOutlined />,
|
||||
"Keep linked", true,
|
||||
() => { setUnlinking(new Set([...unlinking].filter(k => k !== key))); revert(); },
|
||||
);
|
||||
}
|
||||
if (!linking.has(key)) {
|
||||
return button(
|
||||
"Not linked to the template. Click to link it again.", <DisconnectOutlined />, "Link", false,
|
||||
() => {
|
||||
setLinking(new Map([...linking, [key, [fieldName, prop]]]));
|
||||
revert();
|
||||
// Show the value the field takes after apply
|
||||
const value = inheritance.templateValue(fieldName, prop);
|
||||
form.setFieldValue(key, prop === undefined ? value : JSON.stringify(value));
|
||||
},
|
||||
);
|
||||
}
|
||||
return button(
|
||||
"Linked to the template when applied. Click to keep this value instead.", <LinkOutlined />,
|
||||
"Keep unlinked", true,
|
||||
() => { setLinking(new Map([...linking].filter(([k]) => k !== key))); revert(); },
|
||||
);
|
||||
}
|
||||
function isReadonly(canWrite: boolean, key: string, fieldName: string, prop?: string) {
|
||||
return !canWrite || link.linking.has(key) || Boolean(link.inheritance?.canInherit(config, fieldName))
|
||||
&& link.inheritance!.isInherited(config, fieldName, prop)
|
||||
&& !link.unlinking.has(key);
|
||||
}
|
||||
|
||||
let treeData = [];
|
||||
const propsMap = new Map<string, [fieldName: string, prop: string]>();
|
||||
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
|
||||
@@ -312,6 +417,7 @@ function computeTreeData(
|
||||
label={<>
|
||||
{`"${prop}"`}
|
||||
{restart && restartTip}
|
||||
{linkButton(propPath, fieldName, prop)}
|
||||
</>}
|
||||
dependencies={def.dependsOn}
|
||||
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
|
||||
@@ -345,7 +451,7 @@ function computeTreeData(
|
||||
def.validator(fieldValue, config.staging);
|
||||
}})]}
|
||||
>
|
||||
<Input className="json-input" disabled={!canWrite} />
|
||||
<Input className="json-input" disabled={isReadonly(canWrite, propPath, fieldName, prop)} />
|
||||
</Form.Item>,
|
||||
});
|
||||
|
||||
@@ -382,6 +488,7 @@ function computeTreeData(
|
||||
}
|
||||
|
||||
let propPath = `${groupName}.${field}.${propName}`;
|
||||
link.setUnlinking(new Set([...link.unlinking, propPath]));
|
||||
form.setFieldsValue({
|
||||
[`${newPropPath}.name`]: "",
|
||||
[`${newPropPath}.value`]: "",
|
||||
@@ -407,6 +514,7 @@ function computeTreeData(
|
||||
label={<>
|
||||
{def.title || field}
|
||||
{def.restartRequired && restartTip}
|
||||
{linkButton(fieldName, fieldName)}
|
||||
</>}
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
@@ -436,7 +544,7 @@ function computeTreeData(
|
||||
},
|
||||
})]}
|
||||
>
|
||||
{renderInput(control.inputComponents, def, !canWrite)}
|
||||
{renderInput(control.inputComponents, def, isReadonly(canWrite, fieldName, fieldName))}
|
||||
</Form.Item>;
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useInstances } from "../model/instance";
|
||||
|
||||
interface BaseInputInstanceProps extends InputComponentProps {
|
||||
includeAll?: boolean;
|
||||
templates?: boolean;
|
||||
}
|
||||
|
||||
function BaseInputInstance(props: BaseInputInstanceProps) {
|
||||
@@ -12,7 +13,9 @@ function BaseInputInstance(props: BaseInputInstanceProps) {
|
||||
|
||||
const options = [
|
||||
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
|
||||
...[...instances.values()].map(instance => ({
|
||||
...[...instances.values()].filter(
|
||||
instance => Boolean(instance.isTemplate) === Boolean(props.templates)
|
||||
).map(instance => ({
|
||||
label: instance.name ?? `Instance ${instance.id}`,
|
||||
value: instance.id,
|
||||
})),
|
||||
@@ -40,3 +43,7 @@ export function InputInstanceWithAll(props: InputComponentProps) {
|
||||
export function InputInstance(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} />;
|
||||
}
|
||||
|
||||
export function InputInstanceTemplate(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} templates />;
|
||||
}
|
||||
@@ -1,30 +1,73 @@
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useRef } from "react";
|
||||
import { Alert } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import BaseConfigTree from "./BaseConfigTree";
|
||||
import BaseConfigTree, { ConfigInheritance } from "./BaseConfigTree";
|
||||
import ControlContext from "./ControlContext";
|
||||
import Link from "./Link";
|
||||
import { useInstance } from "../model/instance";
|
||||
|
||||
|
||||
type InstanceConfigTreeProps = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
function TemplateNotice(props: { templateId: number }) {
|
||||
const [template] = useInstance(props.templateId);
|
||||
return <Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 8 }}
|
||||
message={<>
|
||||
Linked values are inherited from the template{" "}
|
||||
<Link to={`/instances/${props.templateId}/view`}>{template?.name ?? props.templateId}</Link>.
|
||||
Unlink a value to set your own on this instance.
|
||||
</>}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
|
||||
let control = useContext(ControlContext);
|
||||
const templateConfig = useRef<lib.ConfigSchema>({});
|
||||
|
||||
async function retrieveConfig() {
|
||||
return await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
const config = await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
const templateId = config["instance.template_id"];
|
||||
templateConfig.current = typeof templateId === "number"
|
||||
? await control.send(new lib.InstanceConfigGetRequest(templateId))
|
||||
: {};
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.InstanceConfigSetRequest(props.id, fields));
|
||||
}
|
||||
|
||||
const inheritance: ConfigInheritance = {
|
||||
canInherit: (config, field) => (
|
||||
(config as lib.InstanceConfig).get("instance.template_id") !== null
|
||||
&& lib.InstanceConfig.isInheritable(field)
|
||||
),
|
||||
isInherited: (config, field, prop) => (config as lib.InstanceConfig).isInherited(field, prop),
|
||||
inherit: async (field, prop) => {
|
||||
await control.send(new lib.InstanceConfigInheritFieldRequest(props.id, field, prop));
|
||||
},
|
||||
templateValue: (field, prop) => {
|
||||
const value = templateConfig.current[field] as any;
|
||||
return prop === undefined ? value : value?.[prop];
|
||||
},
|
||||
notice: (config) => {
|
||||
const templateId = (config as lib.InstanceConfig).get("instance.template_id");
|
||||
return templateId !== null ? <TemplateNotice templateId={templateId} /> : null;
|
||||
},
|
||||
};
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.InstanceConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setConfig={setConfig}
|
||||
inheritance={inheritance}
|
||||
id={props.id}
|
||||
/>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tooltip, Typography,
|
||||
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tag, Tooltip, Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
@@ -26,7 +26,8 @@ import LoadScenarioModal from "./LoadScenarioModal";
|
||||
import SavesList from "./SavesList";
|
||||
import OnlinePlayersList from "./OnlinePlayersList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useInstance, useInstances } from "../model/instance";
|
||||
import { CreateInstanceButton } from "./InstancesPage";
|
||||
import { useHost } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
@@ -45,6 +46,28 @@ type InstanceDescriptionProps = {
|
||||
host?: Readonly<lib.HostDetails>;
|
||||
instance: Readonly<lib.InstanceDetails>;
|
||||
};
|
||||
function TemplateDescription(props: { instance: Readonly<lib.InstanceDetails> }) {
|
||||
const [instances] = useInstances();
|
||||
const inheritors = [...instances.values()].filter(i => i.templateId === props.instance.id);
|
||||
return <Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Inherited by">
|
||||
{inheritors.length
|
||||
? <Space wrap>
|
||||
{inheritors.map(i => <Link key={i.id} to={`/instances/${i.id}/view`}>{i.name}</Link>)}
|
||||
</Space>
|
||||
: <em>No instances</em>}
|
||||
</Descriptions.Item>
|
||||
{props.instance.templateId !== undefined && <Descriptions.Item label="Template">
|
||||
<TemplateLink id={props.instance.templateId} />
|
||||
</Descriptions.Item>}
|
||||
</Descriptions>;
|
||||
}
|
||||
|
||||
function TemplateLink(props: { id: number }) {
|
||||
const [template] = useInstance(props.id);
|
||||
return <Link to={`/instances/${props.id}/view`}>{template?.name ?? props.id}</Link>;
|
||||
}
|
||||
|
||||
function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
let account = useAccount();
|
||||
|
||||
@@ -97,6 +120,9 @@ function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
<Descriptions.Item label="Last Start Time">
|
||||
<MetricRelativeDate timeMs={instance.startedAtMs}/>
|
||||
</Descriptions.Item>
|
||||
{instance.templateId !== undefined && <Descriptions.Item label="Template">
|
||||
<TemplateLink id={instance.templateId} />
|
||||
</Descriptions.Item>}
|
||||
</Descriptions>;
|
||||
}
|
||||
|
||||
@@ -108,6 +134,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
let instance = props.instance;
|
||||
let instanceId = instance.id!;
|
||||
let [host] = useHost(instance.assignedHost);
|
||||
const [instances] = useInstances();
|
||||
const canCreate = account.hasPermission("core.instance.create");
|
||||
|
||||
let instanceButtonMenuItems: MenuItem[] = [];
|
||||
if (account.hasPermission("core.instance.export_data")) {
|
||||
@@ -136,6 +164,21 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
label: "Delete",
|
||||
});
|
||||
}
|
||||
function confirmDelete() {
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
content: "Permamently delete instance and server saves?",
|
||||
okText: "Delete",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteRequest(instanceId)
|
||||
).then(() => {
|
||||
navigate("/instances");
|
||||
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||
},
|
||||
});
|
||||
}
|
||||
let instanceButtonsMenuProps: MenuProps = {
|
||||
items: instanceButtonMenuItems,
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
@@ -157,22 +200,25 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
).catch(notifyErrorHandler("Error extracting player data"));
|
||||
|
||||
} else if (key === "delete") {
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
content: "Permamently delete instance and server saves?",
|
||||
okText: "Delete",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteRequest(instanceId)
|
||||
).then(() => {
|
||||
navigate("/instances");
|
||||
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||
},
|
||||
});
|
||||
confirmDelete();
|
||||
}
|
||||
},
|
||||
};
|
||||
if (instance.isTemplate) {
|
||||
return <Space wrap>
|
||||
{canCreate && <CreateInstanceButton
|
||||
instances={instances}
|
||||
initial={{ template: instanceId }}
|
||||
buttonText="Create instance from template"
|
||||
/>}
|
||||
{account.hasPermission("core.instance.delete") && <Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={confirmDelete}
|
||||
>Delete</Button>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
return <Space wrap>
|
||||
{
|
||||
account.hasAnyPermission(...InstanceControlButtonPermissions)
|
||||
@@ -190,6 +236,12 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
Connect via Steam
|
||||
</Button>
|
||||
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
|
||||
{canCreate && <CreateInstanceButton
|
||||
instances={instances}
|
||||
initial={{ instanceClone: instanceId, asTemplate: true, linkSource: true }}
|
||||
buttonText="Create template"
|
||||
buttonType="default"
|
||||
/>}
|
||||
{account.hasAnyPermission(
|
||||
"core.instance.export_data",
|
||||
"core.instance.extract_players",
|
||||
@@ -224,11 +276,16 @@ export default function InstanceViewPage() {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
title={instance.name ?? ""}
|
||||
status={<InstanceStatusTag status={instance.status} />}
|
||||
status={instance.isTemplate
|
||||
? <Tag color="purple">Template</Tag>
|
||||
: <InstanceStatusTag status={instance.status} />}
|
||||
extra={<InstanceButtons instance={instance} />}
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
{instance.isTemplate
|
||||
? <TemplateDescription instance={instance} />
|
||||
: <InstanceDescription host={host} instance={instance} />}
|
||||
<ResourceNotes resourceType="instance" resourceId={instanceId} />
|
||||
{!instance.isTemplate && <>
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
@@ -275,6 +332,7 @@ export default function InstanceViewPage() {
|
||||
account.hasPermission("core.instance.send_rcon")
|
||||
&& <InstanceRcon id={instanceId} disabled={instance.status !== "running"} />
|
||||
}
|
||||
</>}
|
||||
|
||||
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Space, Tooltip, Typography } from "antd";
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Tooltip, Typography } from "antd";
|
||||
import type { ColumnType } from "antd/es/table/interface";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -13,13 +14,89 @@ import { useHosts } from "../model/host";
|
||||
import { useInstanceConfig, useInstances } from "../model/instance";
|
||||
import { useSaves } from "../model/saves";
|
||||
import InstanceList from "./InstanceList";
|
||||
import Link from "./Link";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import type { Control } from "../util/websocket";
|
||||
|
||||
type SaveMode = "none" | "copy" | "save_and_copy";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
|
||||
function ConfigSourceFields(props: {
|
||||
instances: ReturnType<typeof useInstances>[0];
|
||||
templateId?: number | null;
|
||||
source?: Readonly<lib.InstanceDetails>;
|
||||
asTemplate: boolean;
|
||||
}) {
|
||||
const templates = [...props.instances.values()].filter(i => i.isTemplate);
|
||||
const hasTemplate = typeof props.templateId === "number";
|
||||
return <>
|
||||
{(templates.length > 0 || hasTemplate) && <Tooltip
|
||||
title="Inherit config from a template, later changes to the template apply to this instance too"
|
||||
>
|
||||
<Form.Item name="template" label="Template">
|
||||
<Select
|
||||
disabled={props.source !== undefined}
|
||||
options={[{ value: null, label: "None" }, ...templates.map(i => ({ value: i.id, label: i.name }))]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>}
|
||||
<Tooltip title="Perform a one time copy of the config of an existing instance">
|
||||
<Form.Item name="instanceClone" label="Copy Config">
|
||||
<Select
|
||||
disabled={hasTemplate}
|
||||
options={[
|
||||
{ id: -1, name: "Default Config" },
|
||||
...[...props.instances.values()].filter(i => !i.isTemplate),
|
||||
].map(i => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{props.asTemplate && props.source && <Form.Item name="linkSource" valuePropName="checked">
|
||||
<Checkbox>Make {props.source.name} inherit from the new template</Checkbox>
|
||||
</Form.Item>}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function linkToTemplate(control: Control, source: lib.InstanceDetails | undefined, templateId: number) {
|
||||
if (source) {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(
|
||||
source.id, "instance.template_id", String(templateId),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function setTemplateFields(
|
||||
config: lib.InstanceConfig,
|
||||
fields: (keyof lib.InstanceConfigFields)[],
|
||||
templateId: unknown,
|
||||
asTemplate: boolean,
|
||||
) {
|
||||
if (typeof templateId === "number") {
|
||||
config.set("instance.template_id", templateId);
|
||||
fields.push("instance.template_id");
|
||||
}
|
||||
if (asTemplate) {
|
||||
config.set("instance.is_template", true);
|
||||
fields.push("instance.is_template");
|
||||
}
|
||||
}
|
||||
|
||||
type CreateInstanceInitial = {
|
||||
instanceClone?: number;
|
||||
template?: number;
|
||||
asTemplate?: boolean;
|
||||
linkSource?: boolean;
|
||||
};
|
||||
|
||||
export function CreateInstanceButton({ buttonText = "Create", buttonType = "primary", ...props }: {
|
||||
instances: ReturnType<typeof useInstances>[0];
|
||||
initial?: CreateInstanceInitial;
|
||||
buttonText?: string;
|
||||
buttonType?: "primary" | "default";
|
||||
buttonSize?: "small";
|
||||
}) {
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
@@ -31,9 +108,11 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
|
||||
const canAssign = account.hasPermission("core.instance.assign");
|
||||
const cloneId: number | undefined = Form.useWatch("instanceClone", form);
|
||||
const templateId: number | null | undefined = Form.useWatch("template", form);
|
||||
const asTemplate = Boolean(Form.useWatch("asTemplate", form));
|
||||
const host: number | string | undefined = Form.useWatch("host", form);
|
||||
const saveMode: SaveMode = Form.useWatch("saveMode", form) ?? "none";
|
||||
const hostId = typeof host === "number" ? host : undefined;
|
||||
const hostId = typeof host === "number" && !asTemplate ? host : undefined;
|
||||
const source = cloneId !== undefined && cloneId >= 0 ? props.instances.get(cloneId) : undefined;
|
||||
const sourceRunning = source?.status === "running";
|
||||
const activeSave = source ? [...saves.values()].find(
|
||||
@@ -42,6 +121,7 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
const sourceConfig = useInstanceConfig(source?.id);
|
||||
const sourceSettings = sourceConfig?.["factorio.settings"] as Record<string, unknown> | undefined;
|
||||
const sourceServerName = typeof sourceSettings?.name === "string" ? sourceSettings.name : undefined;
|
||||
const copySettings = sourceSettings !== undefined && !asTemplate;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && sourceServerName !== undefined) {
|
||||
@@ -68,7 +148,8 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
instanceConfig.set("instance.name", values.instanceName);
|
||||
const fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
|
||||
if (source && sourceSettings) {
|
||||
setTemplateFields(instanceConfig, fields, values.template, asTemplate);
|
||||
if (copySettings) {
|
||||
if (!values.serverName) {
|
||||
form.setFields([{ name: "serverName", errors: ["Server name is required"] }]);
|
||||
return;
|
||||
@@ -87,6 +168,9 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
));
|
||||
created = true;
|
||||
|
||||
if (asTemplate && values.linkSource) {
|
||||
await linkToTemplate(control, source, instanceId);
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
|
||||
}
|
||||
@@ -114,13 +198,14 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="primary"
|
||||
type={buttonType}
|
||||
size={props.buttonSize}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>Create</Button>
|
||||
>{buttonText}</Button>
|
||||
<Modal
|
||||
title="Create Instance"
|
||||
title={asTemplate ? "Create Template" : "Create Instance"}
|
||||
okText="Create"
|
||||
open={open}
|
||||
confirmLoading={creating}
|
||||
@@ -130,12 +215,20 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{ host: "null", instanceClone: -1, saveMode: "none", startAfter: false }}
|
||||
initialValues={{
|
||||
host: "null", instanceClone: -1, saveMode: "none", startAfter: false,
|
||||
template: null, asTemplate: false, linkSource: true, ...props.initial,
|
||||
}}
|
||||
>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{canAssign && <Form.Item name="host" label="Host">
|
||||
<Tooltip title="Templates hold shared config for other instances and cannot be started">
|
||||
<Form.Item name="asTemplate" valuePropName="checked">
|
||||
<Checkbox>Create as template</Checkbox>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{canAssign && !asTemplate && <Form.Item name="host" label="Host">
|
||||
<Select showSearch optionFilterProp="name">
|
||||
<Select.Option value={"null"}>
|
||||
<Typography.Text italic>Unassigned</Typography.Text>
|
||||
@@ -151,16 +244,15 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>}
|
||||
<Tooltip title="Perform a one time copy of the config of an existing instance">
|
||||
<Form.Item name="instanceClone" label="Copy Config">
|
||||
<Select
|
||||
options={[{ id: -1, name: "Default Config" }, ...props.instances.values()]
|
||||
.map(i => ({ value: i.id, label: i.name }))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{source && sourceSettings && <Tooltip title="Name of the server as shown in the in-game server browser">
|
||||
<ConfigSourceFields
|
||||
instances={props.instances}
|
||||
templateId={templateId}
|
||||
source={source}
|
||||
asTemplate={asTemplate}
|
||||
/>
|
||||
{copySettings && <Tooltip
|
||||
title="Name of the server as shown in the in-game server browser"
|
||||
>
|
||||
<Form.Item name="serverName" label="Server Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
@@ -194,10 +286,59 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
</>;
|
||||
}
|
||||
|
||||
function TemplateList(props: { instances: ReturnType<typeof useInstances>[0] }) {
|
||||
const account = useAccount();
|
||||
const rowNav = useRowNavigation();
|
||||
const all = [...props.instances.values()];
|
||||
const inheritors = (id: number) => all.filter(instance => instance.templateId === id).length;
|
||||
const columns: ColumnType<lib.InstanceDetails>[] = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
render: (_, template) => <Link to={`/instances/${template.id}/view`} style={{ color: "inherit" }}>
|
||||
{template.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "Inherited by",
|
||||
key: "inheritors",
|
||||
render: (_, template) => `${inheritors(template.id)} instance(s)`,
|
||||
sorter: (a, b) => inheritors(a.id) - inheritors(b.id),
|
||||
},
|
||||
];
|
||||
if (account.hasPermission("core.instance.create")) {
|
||||
columns.push({
|
||||
key: "action",
|
||||
align: "right",
|
||||
// Clicks inside the modal bubble through the React tree to the row
|
||||
render: (_, template) => <span onClick={e => e.stopPropagation()}>
|
||||
<CreateInstanceButton
|
||||
instances={props.instances}
|
||||
initial={{ template: template.id }}
|
||||
buttonText="Create instance"
|
||||
buttonType="default"
|
||||
buttonSize="small"
|
||||
/>
|
||||
</span>,
|
||||
});
|
||||
}
|
||||
return <Table
|
||||
columns={columns}
|
||||
dataSource={all.filter(instance => instance.isTemplate)}
|
||||
rowKey={template => template.id}
|
||||
pagination={false}
|
||||
onRow={record => rowNav(`/instances/${record.id}/view`)}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function InstancesPage() {
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [instances] = useInstances();
|
||||
const plainInstances = new Map([...instances].filter(([, instance]) => !instance.isTemplate));
|
||||
const hasTemplates = plainInstances.size !== instances.size;
|
||||
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
@@ -231,7 +372,11 @@ export default function InstancesPage() {
|
||||
</Space>}
|
||||
/>
|
||||
|
||||
<InstanceList instances={instances} />
|
||||
<InstanceList instances={plainInstances} />
|
||||
{hasTemplates && <>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>Templates</Typography.Title>
|
||||
<TemplateList instances={instances} />
|
||||
</>}
|
||||
<PluginExtra component="InstancesPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -156,6 +156,142 @@ describe("controller/src/ControlConnection", function() {
|
||||
assert.equal(clone.config.get("instance.name"), "clone");
|
||||
assert.equal(clone.config.get("instance.assigned_host"), null);
|
||||
});
|
||||
|
||||
it("creates an instance inheriting from a template", async function() {
|
||||
await create({ "instance.id": 4001, "instance.name": "template", "instance.is_template": true });
|
||||
mockController.instances.getMutable(4001).config.set("factorio.version", "2.0");
|
||||
|
||||
await create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 });
|
||||
const child = mockController.instances.get(4002);
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.equal(child.config.isInherited("instance.name"), false);
|
||||
});
|
||||
|
||||
it("rejects a template that is not a template", async function() {
|
||||
await create({ "instance.id": 4001, "instance.name": "plain" });
|
||||
await assert.rejects(
|
||||
create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 }),
|
||||
new lib.ValidationError("Instance plain is not a template"),
|
||||
);
|
||||
assert.equal(mockController.instances.has(4002), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance config set handlers", function() {
|
||||
let mockController;
|
||||
let connection;
|
||||
|
||||
beforeEach(async function() {
|
||||
mockController = new MockController();
|
||||
mockController.mockConfigEntries.set("controller.name", "Test");
|
||||
mockController.instances = new InstanceManager(new lib.SubscribableDatastore(), mockController);
|
||||
mockController.instanceConfigUpdated = async () => {};
|
||||
connection = {
|
||||
_controller: mockController,
|
||||
checkStagedInheritance: ControlConnection.prototype.checkStagedInheritance,
|
||||
};
|
||||
for (const [id, fields] of [
|
||||
[1, { "instance.is_template": true, "factorio.version": "2.0" }],
|
||||
[2, { "instance.template_id": 1 }],
|
||||
]) {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": id, "instance.name": `i${id}`, ...fields,
|
||||
});
|
||||
if (fields["instance.template_id"]) {
|
||||
mockController.instances.applyTemplate(
|
||||
config, ["instance.id", "instance.name", "instance.template_id"],
|
||||
);
|
||||
}
|
||||
await mockController.instances.createInstance(config);
|
||||
}
|
||||
});
|
||||
|
||||
function call(handler, request) {
|
||||
return ControlConnection.prototype[handler].call(connection, request);
|
||||
}
|
||||
async function create(config, cloneFromId) {
|
||||
await ControlConnection.prototype.handleInstanceCreateRequest.call(
|
||||
connection, new lib.InstanceCreateRequest(config, cloneFromId),
|
||||
);
|
||||
}
|
||||
const setField = (...args) => new lib.InstanceConfigSetFieldRequest(...args);
|
||||
const setFields = (...args) => new lib.InstanceConfigSetRequest(...args);
|
||||
|
||||
it("stops inheriting a field set to the inherited value", async function() {
|
||||
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.0"));
|
||||
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
|
||||
});
|
||||
|
||||
it("stops inheriting a field set through set request", async function() {
|
||||
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.version": "2.0" }));
|
||||
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
|
||||
});
|
||||
|
||||
it("stops inheriting a property set through set-prop", async function() {
|
||||
await call(
|
||||
"handleInstanceConfigSetPropRequest",
|
||||
new lib.InstanceConfigSetPropRequest(2, "factorio.settings", "max_players", 0),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
|
||||
it("rejects template_id pointing at a non-template", async function() {
|
||||
await assert.rejects(
|
||||
call("handleInstanceConfigSetFieldRequest", setField(1, "instance.template_id", "2")),
|
||||
new lib.ValidationError("Instance i2 is not a template"),
|
||||
);
|
||||
assert.equal(mockController.instances.get(1).config.get("instance.template_id"), null);
|
||||
});
|
||||
|
||||
it("rejects unmarking a template with inheritors through set request", async function() {
|
||||
await assert.rejects(
|
||||
call("handleInstanceConfigSetRequest", setFields(1, { "instance.is_template": "false" })),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
assert.equal(mockController.instances.get(1).config.get("instance.is_template"), true);
|
||||
});
|
||||
|
||||
it("stops inheriting only the properties set through set request", async function() {
|
||||
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.settings": { max_players: 3 } }));
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
|
||||
it("inherits a property again", async function() {
|
||||
await call("handleInstanceConfigSetPropRequest", new lib.InstanceConfigSetPropRequest(
|
||||
2, "factorio.settings", "max_players", 3,
|
||||
));
|
||||
await call(
|
||||
"handleInstanceConfigInheritFieldRequest",
|
||||
new lib.InstanceConfigInheritFieldRequest(2, "factorio.settings", "max_players"),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.get("factorio.settings").max_players, 0);
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
|
||||
it("creates a template from an existing instance", async function() {
|
||||
await create({ "instance.id": 3, "instance.name": "copy", "instance.is_template": true }, 2);
|
||||
const config = mockController.instances.get(3).config;
|
||||
assert.equal(config.get("instance.is_template"), true);
|
||||
assert.equal(config.get("instance.template_id"), 1);
|
||||
assert.equal(config.get("factorio.version"), "2.0");
|
||||
});
|
||||
|
||||
it("inherits a field again", async function() {
|
||||
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.1"));
|
||||
await call(
|
||||
"handleInstanceConfigInheritFieldRequest",
|
||||
new lib.InstanceConfigInheritFieldRequest(2, "factorio.version"),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.get("factorio.version"), "2.0");
|
||||
assert.equal(config.isInherited("factorio.version"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close cleanup", function() {
|
||||
|
||||
@@ -34,6 +34,10 @@ class MockInstanceConfig extends EventEmitter {
|
||||
this._data.set(field, value);
|
||||
this.emit("fieldChanged", field, value, prev);
|
||||
}
|
||||
|
||||
isInherited() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("controller/src/Controller", function() {
|
||||
|
||||
@@ -393,4 +393,253 @@ describe("controller/InstanceManager", function () {
|
||||
assert.equal(sentRequest.instanceId, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inheritance", function () {
|
||||
/** @type {InstanceRecord} */
|
||||
let template;
|
||||
/** @type {InstanceRecord} */
|
||||
let child;
|
||||
let updated;
|
||||
|
||||
async function createInheritor(id, templateId, fields = {}) {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": id, "instance.name": `Instance ${id}`, "instance.template_id": templateId, ...fields,
|
||||
});
|
||||
instances.applyTemplate(
|
||||
config, ["instance.id", "instance.name", "instance.template_id", ...Object.keys(fields)]
|
||||
);
|
||||
return await instances.createInstance(config);
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
template = await instances.createInstance(new lib.InstanceConfig("controller", {
|
||||
"instance.id": 10,
|
||||
"instance.name": "Template",
|
||||
"instance.is_template": true,
|
||||
"factorio.version": "2.0",
|
||||
"factorio.game_port": 34190,
|
||||
"factorio.settings": { name: "Template server", max_players: 5 },
|
||||
}));
|
||||
child = await createInheritor(11, 10, { "factorio.game_port": 34197 });
|
||||
updated = [];
|
||||
controller.instanceConfigUpdated = async (inst) => { updated.push(inst.id); };
|
||||
});
|
||||
|
||||
describe(".applyTemplate()", function () {
|
||||
it("should copy template values and mark them inherited", function () {
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.equal(child.config.get("factorio.game_port"), 34197);
|
||||
assert.equal(child.config.isInherited("factorio.game_port"), false);
|
||||
assert.equal(child.config.isInherited("instance.name"), false);
|
||||
});
|
||||
it("should never inherit ports or the rcon password", function () {
|
||||
template.config.set("factorio.rcon_password", "secret");
|
||||
const config = new lib.InstanceConfig("controller", { "instance.id": 12, "instance.template_id": 10 });
|
||||
instances.applyTemplate(config, ["instance.id", "instance.template_id"]);
|
||||
assert.equal(config.get("factorio.game_port"), null);
|
||||
assert.equal(config.isInherited("factorio.game_port"), false);
|
||||
assert.equal(config.get("factorio.rcon_password"), null);
|
||||
assert.equal(config.isInherited("factorio.rcon_password"), false);
|
||||
});
|
||||
it("should inherit factorio.settings per property with its own server name", function () {
|
||||
const settings = child.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 5);
|
||||
assert.equal(settings.name, "TestController - Instance 11");
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "name"), false);
|
||||
});
|
||||
it("should keep properties given by the creator as overrides", async function () {
|
||||
const other = await createInheritor(12, 10, { "factorio.settings": { max_players: 8 } });
|
||||
assert.equal(other.config.get("factorio.settings").max_players, 8);
|
||||
assert.equal(other.config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(other.config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template changes", function () {
|
||||
it("should propagate to inherited fields and send the config to the host", function () {
|
||||
template.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.get("factorio.version"), "2.1");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.deepEqual(updated, [11]);
|
||||
});
|
||||
it("should mark the inheritor as requiring restart", function () {
|
||||
child.config.restartRequired = false;
|
||||
template.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.restartRequired, true);
|
||||
});
|
||||
it("should not touch fields the inheritor has set", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
template.config.set("factorio.version", "2.2");
|
||||
assert.equal(child.config.get("factorio.version"), "2.1");
|
||||
});
|
||||
it("should merge object properties and keep overrides", function () {
|
||||
template.config.setProp("factorio.settings", "max_players", 10);
|
||||
template.config.setProp("factorio.settings", "name", "Renamed");
|
||||
template.config.setProp("factorio.settings", "auto_pause", true);
|
||||
const settings = child.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 10);
|
||||
assert.equal(settings.auto_pause, true);
|
||||
assert.equal(settings.name, "TestController - Instance 11");
|
||||
});
|
||||
it("should propagate through several levels", async function () {
|
||||
child.config.set("instance.is_template", true);
|
||||
const grandchild = await createInheritor(12, 11);
|
||||
template.config.setProp("factorio.settings", "max_players", 10);
|
||||
assert.equal(grandchild.config.get("factorio.settings").max_players, 10);
|
||||
assert.deepEqual(updated.sort(), [11, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inheritor changes", function () {
|
||||
it("should stop inheriting a field that is changed", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.isInherited("factorio.version"), false);
|
||||
});
|
||||
it("should only override the properties changed in an object", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
it("should restore the template value of a removed property", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
child.config.setProp("factorio.settings", "max_players");
|
||||
assert.equal(child.config.get("factorio.settings").max_players, 5);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
it("should stop inheriting with overrideField() even if the value is unchanged", function () {
|
||||
instances.overrideField(child, "factorio.version");
|
||||
instances.overrideField(child, "factorio.settings", ["auto_pause"]);
|
||||
assert.equal(child.config.isInherited("factorio.version"), false);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".inheritField()", function () {
|
||||
it("should restore the template value", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
instances.inheritField(11, "factorio.version");
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
});
|
||||
it("should restore a single property", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
instances.inheritField(11, "factorio.settings", "max_players");
|
||||
assert.equal(child.config.get("factorio.settings").max_players, 5);
|
||||
assert.equal(child.config.get("factorio.settings").name, "TestController - Instance 11");
|
||||
});
|
||||
it("should restore all properties", function () {
|
||||
instances.inheritField(11, "factorio.settings");
|
||||
assert.equal(child.config.get("factorio.settings").name, "Template server");
|
||||
});
|
||||
it("should throw if the instance has no template", function () {
|
||||
assert.throws(
|
||||
() => instances.inheritField(1, "factorio.version"),
|
||||
new lib.ValidationError("Instance does not have a template"),
|
||||
);
|
||||
});
|
||||
it("should throw for fields that cannot be inherited", function () {
|
||||
assert.throws(
|
||||
() => instances.inheritField(11, "instance.name"),
|
||||
new lib.ValidationError("Field instance.name cannot be inherited"),
|
||||
);
|
||||
assert.throws(
|
||||
() => instances.inheritField(11, "factorio.rcon_port"),
|
||||
new lib.ValidationError("Field factorio.rcon_port cannot be inherited"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template_id changes", function () {
|
||||
it("should inherit fields equal to the template or the default", function () {
|
||||
instance.config.set("factorio.version", "2.0");
|
||||
instance.config.set("factorio.verbose_logging", true);
|
||||
template.config.set("instance.auto_start", true);
|
||||
instance.config.set("instance.template_id", 10);
|
||||
assert.equal(instance.config.isInherited("factorio.version"), true);
|
||||
assert.equal(instance.config.isInherited("instance.auto_start"), true);
|
||||
assert.equal(instance.config.get("instance.auto_start"), true);
|
||||
assert.equal(instance.config.isInherited("factorio.verbose_logging"), false);
|
||||
});
|
||||
it("should inherit object properties equal to the template or the default", function () {
|
||||
instance.config.setProp("factorio.settings", "max_players", 0);
|
||||
instance.config.setProp("factorio.settings", "game_password", "secret");
|
||||
instance.config.set("instance.template_id", 10);
|
||||
const settings = instance.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 5);
|
||||
assert.equal(settings.game_password, "secret");
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "game_password"), false);
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
it("should keep the server name of the instance", function () {
|
||||
instance.config.setProp("factorio.settings", "name", "Template server");
|
||||
instance.config.set("instance.template_id", 10);
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "name"), false);
|
||||
});
|
||||
it("should clear inherited fields when the template is removed", function () {
|
||||
child.config.set("instance.template_id", null);
|
||||
assert.deepEqual(child.config.get("instance.inherited_fields"), {});
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".checkInheritance()", function () {
|
||||
it("should accept a valid template", function () {
|
||||
instances.checkInheritance(instance.config, false, 10);
|
||||
});
|
||||
it("should reject a missing template", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, false, 99),
|
||||
new lib.NotFoundError("Instance with ID 99 does not exist"),
|
||||
);
|
||||
});
|
||||
it("should reject an instance that is not a template", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, false, 11),
|
||||
new lib.ValidationError("Instance Instance 11 is not a template"),
|
||||
);
|
||||
});
|
||||
it("should reject cycles", function () {
|
||||
child.config.set("instance.is_template", true);
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(template.config, true, 11),
|
||||
new lib.ValidationError("Templates cannot inherit from themselves"),
|
||||
);
|
||||
});
|
||||
it("should reject making an assigned instance a template", function () {
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, true, null),
|
||||
new lib.ConflictError("Instance must be unassigned before it can be made a template"),
|
||||
);
|
||||
});
|
||||
it("should reject unmarking a template that has inheritors", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(template.config, false, null),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not assign a template to a host", async function () {
|
||||
controller.wsServer.hostConnections.set(5, { send: async () => {}, connector: { closing: false } });
|
||||
await assert.rejects(
|
||||
instances.assignInstance(10, 5),
|
||||
new lib.ValidationError("Templates cannot be assigned to a host"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not delete a template that has inheritors", async function () {
|
||||
await assert.rejects(
|
||||
instances.deleteInstance(10),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should report templates in instance details", function () {
|
||||
assert.equal(template.toInstanceDetails().isTemplate, true);
|
||||
assert.equal(child.toInstanceDetails().isTemplate, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -971,6 +971,76 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance template inheritance", function() {
|
||||
after(async function() {
|
||||
for (const id of [4403, 4402, 4401, 4400]) {
|
||||
await getControl().send(new lib.InstanceDeleteRequest(id)).catch(() => {});
|
||||
}
|
||||
});
|
||||
it("creates a template and an instance inheriting from it", async function() {
|
||||
await execCtl("instance create inheritTemplate --id 4400 --as-template");
|
||||
await execCtl("instance config set inheritTemplate factorio.version 2.0");
|
||||
await execCtl("instance create inheritChild --id 4401 --template inheritTemplate");
|
||||
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
|
||||
assert.equal(config["instance.template_id"], 4400);
|
||||
assert.equal(config["factorio.version"], "2.0");
|
||||
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
|
||||
assert.equal((await getInstances()).get(4400).isTemplate, true);
|
||||
});
|
||||
it("propagates template changes per property", async function() {
|
||||
await execCtl("instance config set inheritTemplate factorio.version 2.1");
|
||||
await execCtl("instance config set-prop inheritChild factorio.settings max_players 3");
|
||||
await execCtl("instance config set-prop inheritTemplate factorio.settings max_players 7");
|
||||
await execCtl("instance config set-prop inheritTemplate factorio.settings afk_autokick_interval 9");
|
||||
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
|
||||
assert.equal(config["factorio.version"], "2.1");
|
||||
assert.equal(config["factorio.settings"].max_players, 3);
|
||||
assert.equal(config["factorio.settings"].afk_autokick_interval, 9);
|
||||
});
|
||||
it("creates a template from an existing instance", async function() {
|
||||
await execCtl("instance create inheritCopy --id 4403 --as-template --from 4401");
|
||||
const config = await getControl().send(new lib.InstanceConfigGetRequest(4403));
|
||||
assert.equal(config["instance.is_template"], true);
|
||||
assert.equal(config["factorio.settings"].max_players, 3);
|
||||
});
|
||||
it("rejects inheriting from an instance that is not a template", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("instance create inheritBad --id 4402 --template inheritChild"),
|
||||
new lib.ValidationError("Instance inheritChild is not a template"),
|
||||
);
|
||||
});
|
||||
it("rejects assigning a template to a host", async function() {
|
||||
await assert.rejects(
|
||||
execCtl(`instance assign inheritTemplate ${hostId}`),
|
||||
new lib.ValidationError("Templates cannot be assigned to a host"),
|
||||
);
|
||||
});
|
||||
describe("instance list --templates", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("instance list --templates");
|
||||
});
|
||||
});
|
||||
describe("instance config list", function() {
|
||||
it("runs on an inheriting instance", async function() {
|
||||
await execCtl("instance config list inheritChild");
|
||||
});
|
||||
});
|
||||
describe("instance config inherit", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("instance config set inheritChild factorio.version 2.0");
|
||||
let config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
|
||||
assert.equal(config["instance.inherited_fields"]["factorio.version"], undefined);
|
||||
|
||||
await execCtl("instance config inherit inheritChild factorio.version");
|
||||
await execCtl("instance config inherit inheritChild factorio.settings max_players");
|
||||
config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
|
||||
assert.equal(config["factorio.version"], "2.1");
|
||||
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
|
||||
assert.equal(config["factorio.settings"].max_players, 7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance assign", function() {
|
||||
it("creates the instance files", async function() {
|
||||
await execCtl(`instance assign test ${hostId}`);
|
||||
|
||||
@@ -41,7 +41,8 @@ function connectInstance(controller, host, instanceId) {
|
||||
instanceConfig.set("instance.id", instanceId);
|
||||
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
|
||||
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
|
||||
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
|
||||
const controllerConfig = new lib.InstanceConfig("controller", instanceConfig.toRemote("controller"));
|
||||
controller.instances.records.set(new InstanceRecord(controllerConfig, "stopped"));
|
||||
const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer());
|
||||
const instanceConnection = new InstanceConnection(hostSide, host, instance);
|
||||
host.instanceConnections.set(instanceId, instanceConnection);
|
||||
|
||||
@@ -4,6 +4,59 @@ import * as lib from "@clusterio/lib";
|
||||
|
||||
|
||||
describe("lib/config/definitions", function() {
|
||||
describe("InstanceConfig", function() {
|
||||
describe("isInheritable()", function() {
|
||||
it("should allow regular fields", function() {
|
||||
assert.equal(lib.InstanceConfig.isInheritable("factorio.version"), true);
|
||||
assert.equal(lib.InstanceConfig.isInheritable("instance.auto_start"), true);
|
||||
});
|
||||
it("should reject identity, hidden, host only and unknown fields", function() {
|
||||
for (const name of [
|
||||
"instance.name", "instance.id", "instance.assigned_host", "instance.is_template",
|
||||
"instance.template_id", "instance.inherited_fields", "factorio.host_assigned_game_port",
|
||||
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
|
||||
"no.field",
|
||||
]) {
|
||||
assert.equal(lib.InstanceConfig.isInheritable(name), false, name);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe(".isInherited()", function() {
|
||||
it("should only report fields as inherited when a template is set", function() {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.inherited_fields": { "factorio.version": true },
|
||||
});
|
||||
assert.equal(config.isInherited("factorio.version"), false);
|
||||
config.set("instance.template_id", 1);
|
||||
assert.equal(config.isInherited("factorio.version"), true);
|
||||
assert.equal(config.isInherited("factorio.game_port"), false);
|
||||
});
|
||||
it("should report object properties not overridden as inherited", function() {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.template_id": 1,
|
||||
"instance.inherited_fields": { "factorio.settings": ["name"] },
|
||||
});
|
||||
assert.equal(config.isInherited("factorio.settings"), true);
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), true);
|
||||
assert.equal(config.isInherited("factorio.settings", "name"), false);
|
||||
});
|
||||
});
|
||||
it("should not send inheritance fields to the host", function() {
|
||||
const config = new lib.InstanceConfig("controller", { "instance.template_id": 1 });
|
||||
const remote = config.toRemote("host");
|
||||
for (const name of ["instance.is_template", "instance.template_id", "instance.inherited_fields"]) {
|
||||
assert(!Object.hasOwn(remote, name), name);
|
||||
}
|
||||
});
|
||||
it("should not let control write inherited_fields", function() {
|
||||
const config = new lib.InstanceConfig("controller");
|
||||
assert.throws(
|
||||
() => config.set("instance.inherited_fields", { "factorio.version": true }, "control"),
|
||||
new lib.InvalidAccessError("Field 'instance.inherited_fields' is not accessible from control"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPluginConfigFields()", function() {
|
||||
after(function() {
|
||||
delete lib.CtlConfig.fieldDefinitions["ctl_test.field"];
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("lib/data/InstanceDetails", function () {
|
||||
[undefined, 1234], // startedAtMs
|
||||
[undefined, 9999], // updatedAtMs
|
||||
[undefined, true], // excludeFromStartAll
|
||||
[undefined, true], // isTemplate
|
||||
[undefined, 7], // templateId
|
||||
));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user