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.
This commit is contained in:
Hornwitser committed 2021-06-01 11:22:22 +02:00
1 parent b53ed7f6ca
commit a96fae5ffd
16 files changed
+390 -3

No files matched your search

+17
View File
@@ -338,6 +338,21 @@ async function loadMapSettings(args) {
};
}
instanceCommands.add(new libCommand.Command({
definition: ["list-saves <instance>", "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 <instance> [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,
+51 -1
View File
@@ -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"],
+11 -1
View File
@@ -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",
+18
View File
@@ -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();
+10
View File
@@ -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")) {
+4
View File
@@ -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,
+72
View File
@@ -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);
@@ -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 = <Space>
<StartStopInstanceButton instance={instance} />
<CreateSaveModal instance={instance} />
<LoadScenarioModal instance={instance} />
<Button
loading={exportingData}
@@ -115,6 +116,8 @@ export default function InstanceViewPage(props) {
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<SectionHeader title="Saves" extra=<CreateSaveModal instance={instance} /> />
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
@@ -0,0 +1,57 @@
import React, { useContext, useState } from "react";
import { Table, Tooltip } from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
import ControlContext from "./ControlContext";
import { useSaves } from "../model/saves";
function formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
}
let units = [" Bytes", " kB", " MB", " GB", " TB"];
let factor = 1000;
let power = Math.min(Math.floor(Math.log(bytes) / Math.log(factor)), units.length);
return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power];
}
export default function SavesList(props) {
let control = useContext(ControlContext);
let saves = useSaves(props.instance.id);
return <Table
size="small"
columns={[
{
title: "Name",
render: save => <>
{save.name}
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined/></Tooltip>}
{save.default && <Tooltip title="Save loaded by default"><LeftOutlined/></Tooltip>}
</>,
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
title: "Size",
key: "size",
responsive: ["sm"],
render: save => formatBytes(save.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Last Modified",
key: "mtime_ms",
render: save => new Date(save.mtime_ms).toLocaleString(),
sorter: (a, b) => a.mtime_ms - b.mtime_ms,
defaultSortOrder: "descend",
},
]}
dataSource={saves}
rowKey={save => save.name}
pagination={false}
/>;
}
@@ -0,0 +1,8 @@
import React, { useContext, useState } from "react";
export default function SectionHeader(props) {
return <div className="section-header">
<div className="section-header-title">{props.title}</div>
{props.extra && <div className="section-header-extra">{props.extra}</div>}
</div>;
}
+21
View File
@@ -67,6 +67,27 @@ body {
padding-right: 64px;
}
.section-header {
display: flex;
align-items: center;
margin-top: 16px;
margin-bottom: 8px;
}
.section-header-title {
flex: auto;
overflow: hidden;
font-weight: bold;
font-size: 16px;
line-height: 1.5715;
white-space: nowrap;
text-overflow: ellipsis;
}
.section-header-extra {
margin-left: auto;
}
.site-name {
position: absolute;
top: -10px;
+2
View File
@@ -18,3 +18,5 @@ export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as PageLayout } from "./components/PageLayout";
export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
+34
View File
@@ -0,0 +1,34 @@
import React, { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import libLink from "@clusterio/lib/link";
import { logger } from "@clusterio/lib/logging";
export function useSaves(instanceId) {
let control = useContext(ControlContext);
let [saves, setSaves] = useState([]);
function updateSaves() {
libLink.messages.listSaves.send(control, { instance_id: instanceId }).then(result => {
setSaves(result.list);
}).catch(err => {
logger.error(`Failed to list instance saves: ${err}`);
});
}
useEffect(() => {
updateSaves();
function updateHandler(data) {
setSaves(data.list);
}
control.onSaveListUpdate(instanceId, updateHandler);
return () => {
control.offSaveListUpdate(instanceId, updateHandler);
};
}, [instanceId]);
return saves;
}
+58
View File
@@ -63,6 +63,7 @@ export class Control extends libLink.Link {
this.accountName = null;
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.instanceLogHandlers = new Map();
this.connector.on("connect", data => {
@@ -70,6 +71,9 @@ export class Control extends libLink.Link {
this.updateInstanceSubscriptions().catch(err => logger.error(
`Unexpected error updating instance subscriptions:\n${err.stack}`
));
this.updateSaveListSubscriptions().catch(err => logger.error(
`Unexpected error updating save list subscriptions:\n${err.stack}`
));
this.updateLogSubscriptions().catch(err => logger.error(
`Unexpected error updating log subscriptions:\n${err.stack}`
));
@@ -142,6 +146,60 @@ export class Control extends libLink.Link {
});
}
async saveListUpdateEventHandler(message) {
let handlers = this.saveListUpdateHandlers.get(message.data.instance_id);
for (let handler of handlers || []) {
handler(message.data);
}
};
async onSaveListUpdate(id, handler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.saveListUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.saveListUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateSaveListSubscriptions();
}
}
async offSaveListUpdate(id, handler) {
let handlers = this.saveListUpdateHandlers.get(id);
if (!handlers) {
throw new Error(`No handlers for instance ${id} exists`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
handlers.splice(index, 1);
if (!handlers.length) {
this.saveListUpdateHandlers.delete(id);
await this.updateSaveListSubscriptions();
}
}
}
async updateSaveListSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setSaveListSubscriptions.send(this, {
all: false,
instance_ids: [...this.saveListUpdateHandlers.keys()],
});
}
async logMessageEventHandler(message) {
let info = message.data.info;
+15
View File
@@ -146,6 +146,21 @@ describe("Integration of Clusterio", function() {
});
});
describe("saveListUpdateEventHandler()", function() {
it("should have triggered for the created save", function() {
slowTest(this);
assert.equal(getControl().saveListUpdates.slice(-1)[0].list[0].name, "world.zip");
});
});
describe("instance list-saves", function() {
it("lists the created save", async function() {
slowTest(this);
let result = await execCtl("instance list-saves test");
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
});
});
describe("instance export-data", function() {
it("exports the data", async function() {
slowTest(this);
+8
View File
@@ -26,11 +26,15 @@ class TestControl extends libLink.Link {
super("control", "master", connector);
libLink.attachAllMessages(this);
this.instanceUpdates = [];
this.saveListUpdates = [];
this.connector.on("connect", () => {
libLink.messages.setInstanceSubscriptions.send(
this, { all: true, instance_ids: [] }
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
libLink.messages.setSaveListSubscriptions.send(
this, { all: true, instance_ids: [] }
).catch(err => logger.error(`Error setting save list subscriptions:\n${err.stack}`));
});
}
@@ -45,6 +49,10 @@ class TestControl extends libLink.Link {
this.instanceUpdates.push(message.data);
}
async saveListUpdateEventHandler(message) {
this.saveListUpdates.push(message.data);
}
async logMessageEventHandler() { }
}