diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 32d18588..9997d74d 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -228,49 +228,11 @@ export default class ControlConnection extends BaseConnection { } async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { - let instance = this._controller.getRequestInstance(request.instanceId); - - let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); - if (assigned_host === null) { - assigned_host = undefined; - } - - let game_port: number|null|undefined = instance.game_port; - if (game_port === null) { - game_port = undefined; - } - - return new lib.InstanceDetails( - instance.config.get("instance.name"), - instance.id, - assigned_host, - game_port, - instance.status, - ); + return this._controller.getRequestInstance(request.instanceId).toInstanceDetails(); } async handleInstanceDetailsListRequest() { - let list = []; - for (let instance of this._controller.instances.values()) { - let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); - if (assigned_host === null) { - assigned_host = undefined; - } - - let game_port: number|null|undefined = instance.game_port; - if (game_port === null) { - game_port = undefined; - } - - list.push(new lib.InstanceDetails( - instance.config.get("instance.name"), - instance.id, - assigned_host, - game_port, - instance.status, - )); - } - return list; + return [...this._controller.instances.values()].map(instance => instance.toInstanceDetails()); } // XXX should probably add a hook for host reuqests? diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a69cce75..d96bc957 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -17,7 +17,7 @@ import * as lib from "@clusterio/lib"; const { logger, Summary, Gauge } = lib; import HttpCloser from "./HttpCloser"; -import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; +import InstanceInfo from "./InstanceInfo"; import * as metrics from "./metrics"; import * as routes from "./routes"; import ControllerUser from "./ControllerUser"; @@ -478,16 +478,22 @@ export default class Controller { let instances = new Map(); try { - let serialized = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" })); - for (let serializedConfig of serialized) { + let serialized = JSON.parse( + await fs.readFile(filePath, { encoding: "utf8" }) + ) as Static[]; + for (let json of serialized) { + if (!json.config) { // migrate: from pre Alpha 14 format. + json = { config: json, status: "running" }; // Use running to force updatedAt + } let instanceConfig = new lib.InstanceConfig("controller"); - await instanceConfig.load(serializedConfig); - let status = instanceConfig.get("instance.assigned_host") === null ? "unassigned" : "unknown"; - let instance = new InstanceInfo({ - config: instanceConfig, - status: status as InstanceStatus, - }); - instances.set(instanceConfig.get("instance.id"), instance); + await instanceConfig.load(json.config as lib.SerializedConfig); + let instance = InstanceInfo.fromJSON(json, instanceConfig); + const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown"; + if (instance.status !== status) { + instance.status = status; + instance.updatedAt = Date.now(); + } + instances.set(instance.id, instance); } } catch (err: any) { @@ -501,11 +507,7 @@ export default class Controller { } static async saveInstances(filePath: string, instances: Map) { - let serialized = []; - for (const instance of instances.values()) { - serialized.push(instance.config.serialize()); - } - + let serialized = [...instances.values()].map(instance => instance.toJSON()); await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t")); } @@ -692,7 +694,7 @@ export default class Controller { }; instanceConfig.set("factorio.settings", settings); - let instance = new InstanceInfo({ config: instanceConfig, status: "unassigned" }); + let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, Date.now()); this.instances.set(instanceId, instance); this.instancesDirty = true; await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance); @@ -772,6 +774,7 @@ export default class Controller { let prev = instance.status; instance.status = "deleted"; + instance.updatedAt = Date.now(); this.instanceDetailsUpdated([instance]); await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev); } @@ -799,6 +802,7 @@ export default class Controller { addInstanceHooks(instance: InstanceInfo) { instance.config.on("fieldChanged", (group: lib.ConfigGroup, field: string, prev: any) => { + instance.updatedAt = Date.now(); if (group.name === "instance" && field === "name") { this.instanceDetailsUpdated([instance]); } @@ -809,29 +813,7 @@ export default class Controller { } instanceDetailsUpdated(instances: InstanceInfo[]) { - const now = Date.now(); - const updates: lib.InstanceDetails[] = []; - for (const instance of instances) { - let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); - if (assigned_host === null) { - assigned_host = undefined; - } - - let game_port: number|null|undefined = instance.game_port; - if (game_port === null) { - game_port = undefined; - } - - updates.push(new lib.InstanceDetails( - instance.config.get("instance.name"), - instance.id, - assigned_host, - game_port, - instance.status, - now, - )); - } - + const updates = instances.map(instance => instance.toInstanceDetails()); this.subscriptions.broadcast(new lib.InstanceDetailsUpdatesEvent(updates)); } diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 93ec5417..31844cd7 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -5,7 +5,7 @@ import * as lib from "@clusterio/lib"; const { logger } = lib; import BaseConnection from "./BaseConnection"; -import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; +import InstanceInfo from "./InstanceInfo"; import HostInfo from "./HostInfo"; @@ -176,8 +176,9 @@ export default class HostConnection extends BaseConnection { } let prev = instance.status; - instance.status = request.status as lib.InstanceStatus; - instance.game_port = request.gamePort || null; + instance.status = request.status; + instance.gamePort = request.gamePort; + instance.updatedAt = Date.now(); logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); this._controller.instanceDetailsUpdated([instance]); await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); @@ -212,7 +213,9 @@ export default class HostConnection extends BaseConnection { // Already have this instance, update state instead if (controllerInstance.status !== instanceData.status) { let prev = controllerInstance.status; - controllerInstance.status = instanceData.status as InstanceStatus; + controllerInstance.status = instanceData.status; + controllerInstance.gamePort = instanceData.gamePort; + controllerInstance.updatedAt = Date.now(); logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); instanceUpdates.push(controllerInstance); await lib.invokeHook( @@ -224,7 +227,10 @@ export default class HostConnection extends BaseConnection { instanceConfig.set("instance.assigned_host", this._id); let newInstance = new InstanceInfo( - { config: instanceConfig, status: instanceData.status as InstanceStatus } + instanceConfig, + instanceData.status, + instanceData.gamePort, + Date.now(), ); this._controller.instances.set(instanceConfig.get("instance.id"), newInstance); this._controller.instancesDirty = true; diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index e33f43f3..320fa32c 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -1,22 +1,5 @@ -import type * as lib from "@clusterio/lib"; - -/** - * Current status of the instance. One of: - * - `unknown`: Instance is assigned to a host but this host is currently - * not connected to the contreller. - * - `unassigned`: Instance is not assigned to a a host and exists only on - * the controller. - * - `stopped`: Instance is stopped. - * - `starting`: Instance is in the process of starting up. - * - `running`: Instance is running normally. - * - `stopping`: Instance is in the process of stopping. - * - `creating_save`: Instance is in the process of creating a save. - * - `exporting_data`: Instance is in the process of exporting game data. - * - `deleted`: Instance has been deleted. - */ -export type InstanceStatus = - "unknown" | "unassigned" | "stopped" | "starting" | "running" | - "stopping" | "creating_save" | "exporting_data" | "deleted"; +import * as lib from "@clusterio/lib"; +import { Static, Type } from "@sinclair/typebox"; /** @@ -24,25 +7,56 @@ export type InstanceStatus = * @alias module:controller/src/InstanceInfo */ export default class InstanceInfo { - config: lib.InstanceConfig; - status: InstanceStatus; - game_port: number | null = null; - constructor( - { config, status }: - { config: lib.InstanceConfig, status: InstanceStatus } + public config: lib.InstanceConfig, + public status: lib.InstanceStatus, + public gamePort?: number, + public updatedAt = 0, ) { this.config = config; this.status = status; } - toJSON() { + static jsonSchema = Type.Object({ + "config": Type.Object({}), + "status": lib.InstanceStatus, + "gamePort": Type.Optional(Type.Number()), + "updatedAt": Type.Optional(Type.Number()), + }); + + static fromJSON(json: Static, config: lib.InstanceConfig) { + return new this( + config, + json.status, + json.gamePort, + json.updatedAt, + ); + } + + toJSON(): Static { return { config: this.config.serialize(), status: this.status, + gamePort: this.gamePort, + updatedAt: this.updatedAt, }; } + toInstanceDetails() { + return new lib.InstanceDetails( + this.config.get("instance.name"), + this.id, + this.config.get("instance.assigned_host") ?? undefined, + this.gamePort, + this.status, + this.updatedAt, + ); + } + + get isDeleted() { + return this.status === "deleted"; + } + /** Shorthand for `instance.config.get("instance.id")` */ get id():number { return this.config.get("instance.id"); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index c9395724..222b8bde 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -913,6 +913,7 @@ export default class Host extends lib.Link { list.push(new lib.RawInstanceInfo( instanceInfo.config.serialize("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", + instanceConnection ? instanceConnection.instance.server.gamePort : undefined, )); } await this.send(new lib.InstancesUpdateRequest(list)); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index aab54cd1..7b860475 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -446,7 +446,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); new lib.InstanceStatusChangedEvent( this.id, status, - this.server.gamePort || this.config.get("factorio.game_port") || undefined, + status !== "stopped" ? this.server.gamePort : undefined, ), ); } diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index ca7ac30c..91303cd6 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -7,10 +7,34 @@ import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; -export type InstanceStatus = - "unknown" | "unassigned" | "stopped" | "starting" | "running" - | "stopping" | "creating_save" | "exporting_data" | "deleted" -; +export const InstanceStatus = StringEnum([ + "unknown", + "unassigned", + "stopped", + "starting", + "running", + "stopping", + "creating_save", + "exporting_data", + "deleted", +]); + +/** + * Current status of the instance. One of: + * - `unknown`: Instance is assigned to a host but this host is currently + * not connected to the contreller. + * - `unassigned`: Instance is not assigned to a a host and exists only on + * the controller. + * - `stopped`: Instance is stopped. + * - `starting`: Instance is in the process of starting up. + * - `running`: Instance is running normally. + * - `stopping`: Instance is in the process of stopping. + * - `creating_save`: Instance is in the process of creating a save. + * - `exporting_data`: Instance is in the process of exporting game data. + * - `deleted`: Instance has been deleted. + */ +export type InstanceStatus = Static; + export class InstanceDetails { constructor( public name: string, @@ -653,7 +677,8 @@ export class RawInstanceInfo { constructor( public config: object, - public status: string, + public status: InstanceStatus, + public gamePort: undefined | number, ) { } static jsonSchema = Type.Object({ @@ -661,10 +686,11 @@ export class RawInstanceInfo { "status": StringEnum([ "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), + "gamePort": Type.Optional(Type.Number()), }); static fromJSON(json: Static) { - return new this(json.config, json.status); + return new this(json.config, json.status, json.gamePort); } } @@ -757,7 +783,7 @@ export class InstanceStatusChangedEvent { constructor( public instanceId: number, - public status: string, + public status: InstanceStatus, public gamePort?: number, ) { }