From a96fae5ffd66e65c765cd418a8fe5c10f14044cf Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 26 May 2021 15:55:22 +0200 Subject: [PATCH] Add save listing and subscriptions Add link messages for listing saves and listening for changes to the list of saves for instances along with a table in the instance view page showing the save list for the instance and a list-saves command to ctl. --- packages/ctl/ctl.js | 17 +++++ packages/lib/link/messages.js | 52 +++++++++++++- packages/lib/users.js | 12 +++- packages/master/src/ControlConnection.js | 18 +++++ packages/master/src/Master.js | 10 +++ packages/master/src/SlaveConnection.js | 4 ++ packages/slave/slave.js | 72 +++++++++++++++++++ .../src/components/InstanceViewPage.jsx | 5 +- packages/web_ui/src/components/SavesList.jsx | 57 +++++++++++++++ .../web_ui/src/components/SectionHeader.jsx | 8 +++ packages/web_ui/src/index.css | 21 ++++++ packages/web_ui/src/index.jsx | 2 + packages/web_ui/src/model/saves.jsx | 34 +++++++++ packages/web_ui/src/util/websocket.jsx | 58 +++++++++++++++ test/integration/clusterio.js | 15 ++++ test/integration/index.js | 8 +++ 16 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 packages/web_ui/src/components/SavesList.jsx create mode 100644 packages/web_ui/src/components/SectionHeader.jsx create mode 100644 packages/web_ui/src/model/saves.jsx diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 7f464d22..c510e4db 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -338,6 +338,21 @@ async function loadMapSettings(args) { }; } +instanceCommands.add(new libCommand.Command({ + definition: ["list-saves ", "list saves on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); + for (let entry of response.list) { + entry.mtime = new Date(entry.mtime_ms).toLocaleString(); + delete entry.mtime_ms; + } + print(asTable(response.list)); + }, +})); + instanceCommands.add(new libCommand.Command({ definition: ["create-save [name]", "Create a new save on an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to create on", type: "string" }); @@ -794,6 +809,8 @@ class Control extends libLink.Link { async instanceUpdateEventHandler() { } + async saveListUpdateEventHandler() { } + async setLogSubscriptions({ all = false, master = false, diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 672777b8..7e390fed 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -493,10 +493,37 @@ messages.startInstance = new Request({ forwardTo: "instance", }); +let saveList = { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["type", "name", "size", "mtime_ms", "loaded"], + properties: { + "type": { enum: ["file", "directory", "special"] }, + "name": { type: "string" }, + "size": { type: "integer" }, + "mtime_ms": { type: "number" }, + "loaded": { type: "boolean" }, + "default": { type: "boolean" }, + }, + }, +}; + +messages.listSaves = new Request({ + type: "list_saves", + links: ["control-master", "master-slave", "slave-instance"], + permission: "core.instance.save.list", + forwardTo: "instance", + responseProperties: { + "list": saveList, + }, +}); + messages.createSave = new Request({ type: "create_save", links: ["control-master", "master-slave", "slave-instance"], - permission: "core.instance.create_save", + permission: "core.instance.save.create", forwardTo: "instance", requestProperties: { "name": { type: "string" }, @@ -506,6 +533,19 @@ messages.createSave = new Request({ }, }); +messages.setSaveListSubscriptions = new Request({ + type: "set_save_list_subscriptions", + links: ["control-master"], + permission: "core.instance.save.list_subscribe", + requestProperties: { + "all": { type: "boolean" }, + "instance_ids": { + type: "array", + items: { type: "integer" }, + }, + }, +}); + messages.loadScenario = new Request({ type: "load_scenario", links: ["control-master", "master-slave", "slave-instance"], @@ -1025,6 +1065,16 @@ messages.instanceUpdate = new Event({ eventProperties: instanceProperties, }); +messages.saveListUpdate = new Event({ + type: "save_list_update", + links: ["instance-slave", "slave-master", "master-control"], + forwardTo: "master", + eventProperties: { + "instance_id": { type: "integer" }, + "list": saveList, + }, +}); + messages.masterConnectionEvent = new Event({ type: "master_connection_event", links: ["slave-instance"], diff --git a/packages/lib/users.js b/packages/lib/users.js index 3ba6f39e..947bc807 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -372,10 +372,20 @@ definePermission({ description: "Assign or reassign instances to a slave.", }); definePermission({ - name: "core.instance.create_save", + name: "core.instance.save.list", + title: "List saves on instance", + description: "List the saves currently on the instance.", +}); +definePermission({ + name: "core.instance.save.create", title: "Create new instance save", description: "Create new savegames on instances.", }); +definePermission({ + name: "core.instance.save.list_subscribe", + title: "Subscribe to save list updatess", + description: "Subscribe to be notifed on updates to the list of saves of instances.", +}); definePermission({ name: "core.instance.export_data", title: "Export locale and icons", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index c9ad29b3..3afbce9f 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -43,6 +43,11 @@ class ControlConnection extends BaseConnection { instance_ids: [], }; + this.saveListSubscriptions = { + all: false, + instance_ids: [], + }; + this.logTransport = null; this.logSubscriptions = { all: false, @@ -326,6 +331,19 @@ class ControlConnection extends BaseConnection { } } + async setSaveListSubscriptionsRequestHandler(message) { + this.saveListSubscriptions = message.data; + } + + saveListUpdate(data) { + if ( + this.saveListSubscriptions.all + || this.saveListSubscriptions.instance_ids.includes(data.instance_id) + ) { + libLink.messages.saveListUpdate.send(this, data); + } + } + async setLogSubscriptionsRequestHandler(message) { this.logSubscriptions = message.data; this.updateLogSubscriptions(); diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 644b404b..22367afb 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -383,6 +383,16 @@ class Master { } } + saveListUpdate(data) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.connector.closing) { + continue; + } + + controlConnection.saveListUpdate(data); + } + } + async loadPlugins() { for (let pluginInfo of this.pluginInfos) { if (!this.config.group(pluginInfo.name).get("load_plugin")) { diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index 3243cd11..f4c71d1b 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -146,6 +146,10 @@ class SlaveConnection extends BaseConnection { libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist }); } + async saveListUpdateEventHandler(message) { + this._master.saveListUpdate(message.data); + } + async logMessageEventHandler(message) { this._master.clusterLogger.log({ ...message.data.info, diff --git a/packages/slave/slave.js b/packages/slave/slave.js index c1c0028e..b6dc2df9 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -169,6 +169,7 @@ class Instance extends libLink.Link { this._status = "stopped"; this._running = false; + this._loadedSave = null; this.server = new libFactorio.FactorioServer( factorioDir, this._dir, serverOptions ); @@ -209,9 +210,50 @@ class Instance extends libLink.Link { } } + static async listSaves(savesDir, loadedSave) { + let defaultSave = null; + if (loadedSave === null) { + defaultSave = await libFileOps.getNewestFile( + savesDir, (name) => !name.endsWith(".tmp.zip") + ); + } + + let list = []; + for (let name of await fs.readdir(savesDir)) { + let type; + let stat = await fs.stat(path.join(savesDir, name)); + if (stat.isFile()) { + type = "file"; + } else if (stat.isDirectory()) { + type = "directory"; + } else { + type = "special"; + } + + list.push({ + name, + type, + size: stat.size, + mtime_ms: stat.mtimeMs, + loaded: name === loadedSave, + default: name === defaultSave, + }); + } + + return list; + } + + async sendSaveListUpdate() { + libLink.messages.saveListUpdate.send(this, { + instance_id: this.id, + list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave), + }); + } + async _autosave(name) { let stat = await fs.stat(this.path("saves", `${name}.zip`)); instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size); + await this.sendSaveListUpdate(); } notifyStatus(status) { @@ -234,6 +276,7 @@ class Instance extends libLink.Link { notifyExit() { this._running = false; + this._loadedSave = null; this.notifyStatus("stopped"); this.config.off("fieldChanged", this._configFieldChanged); @@ -252,6 +295,10 @@ class Instance extends libLink.Link { for (let pluginInstance of this.plugins.values()) { pluginInstance.onExit(); } + + this.sendSaveListUpdate().catch(err => { + this.logger.error(`Unexpected error updating save list:\n${err.stack}`); + }); } async _loadPlugin(pluginInfo, slave) { @@ -496,6 +543,7 @@ class Instance extends libLink.Link { }); this.server.on("exit", () => this.notifyExit()); + this._loadedSave = saveName; await this.server.start(saveName); if (this.config.get("factorio.enable_save_patching")) { @@ -503,6 +551,7 @@ class Instance extends libLink.Link { await this.updateInstanceData(); } + await this.sendSaveListUpdate(); await libPlugin.invokeHook(this.plugins, "onStart"); this._running = true; @@ -690,6 +739,12 @@ class Instance extends libLink.Link { } } + async listSavesRequestHandler(message) { + return { + list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave), + }; + } + async createSaveRequestHandler(message) { this.notifyStatus("creating_save"); try { @@ -1301,6 +1356,23 @@ class Slave extends libLink.Link { return await request.send(instanceConnection, message.data); } + async listSavesRequestHandler(message, request) { + let instanceId = message.data.instance_id; + let instanceConnection = this.instanceConnections.get(instanceId); + if (instanceConnection) { + return await request.send(instanceConnection, message.data); + } + + let instanceInfo = this.instanceInfos.get(instanceId); + if (!instanceInfo) { + throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); + } + + return { + list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null), + }; + } + async createSaveRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 582b7d8d..bcf9b974 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -14,6 +14,8 @@ import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; import CreateSaveModal from "./CreateSaveModal"; import LoadScenarioModal from "./LoadScenarioModal"; +import SavesList from "./SavesList"; +import SectionHeader from "./SectionHeader"; import { notifyErrorHandler } from "../util/notify"; import { useInstance } from "../model/instance"; import { useSlave } from "../model/slave"; @@ -47,7 +49,6 @@ export default function InstanceViewPage(props) { let instanceButtons = -