Compare commits

...
Author SHA1 Message Date
Hornwitser ef3d5209eb v2.0.0-alpha.10 2021-10-01 17:59:16 +02:00
Hornwitser e4eac3f9ad Merge pull request #415 from Hornwitser/small-things-6
Small things 6
2021-10-01 17:55:07 +02:00
Hornwitser 107284e796 Fix > and < being present in docs
< and > must apparently not be escaped inside backticks.
2021-10-01 01:52:18 +02:00
Hornwitser bad86a5ff2 Fix inventory_sync uploading editor mode
The serialization and deserialization of editor mode is not supported by
the current code, and while the download correctly checked for the
player being in editor mode, the upload did not check for the player
being in editor mode causing players in editor mode to break. Fix by
adding check in upload_inventory.
2021-10-01 01:52:18 +02:00
Hornwitser 034ca4c250 Fix research_sync sending commands before instance start
Fix commands syncing researces being sent before the instance is started
up, and potential desync of research progress as a result of out of
order execution of RCON commands sent.
2021-10-01 01:52:18 +02:00
Hornwitser 2b101d594a Fix statistics_exporter sending commands before start
Fix statistics_exporter potentially sending commands before an instance
is started when metrics are collected.
2021-10-01 01:52:18 +02:00
Hornwitser c40362fa7f Add sendOrderedRcon for plugins
Add BaseInstancePlugin method for sending RCON commands in order to
simplify logic in plugins that depend on commands being delivered in
order.
2021-10-01 01:52:18 +02:00
Hornwitser e8b515c733 Update missing docs from connector refactor
Fix documentation of max_reconnect_delay, heartbeat_interval and
session_timeout which where changed with the connector refactor.
2021-09-28 23:04:30 +02:00
Hornwitser e5582b5558 Skip gatherng metrics from dropped slaves
Avoid stacking up huge amounts of metrics requests while a slave
connection is in the dropped state.
2021-09-28 21:36:28 +02:00
Hornwitser 3b6ebeb653 Handle errors gathering metrics from slaves
If the session to a slave is lost while gathering metrics the error
would propagate out to the metrics route handler and break collection of
statistics.  Fix by handling errors thrown from sending the getMetrics
request to slaves.
2021-09-28 21:32:29 +02:00
Hornwitser bfec83996a Expect close in WsServer connection disconnect
Don't bother doing the prepareDisconnect dance if the connection is
already broken. This avoids having to wait until the connection times
out when a slave gives up on a current connection and starts a new one.
2021-09-28 21:28:38 +02:00
Hornwitser 4a72c14b74 Terminate connection on timeout
If a connection has timed out the most likely cause is the connection
going stale, in which case calling close could take up to 30 seconds
before the ws lib gives up on waiting for the close message.  Terminate
the connection instead on the Node.js side.  Note that Firefox does not
permit applications from closing connections with a code other than
1000, or in the range 3000-4999.
2021-09-28 21:24:42 +02:00
Hornwitser f529457f96 Replace ini packages with homebrew module
The INI format Factorio use differs from what the ini package parse and
produce.  Most notably no escape mechanism exists in Factorio's parser
which causes instance paths containing among other thing the # character
to completely break.  Fix by replacing the ini package with a Factorio
compatible implementation of INI parsing and serialization.

Resolves the problem in #414.
2021-09-27 03:45:52 +02:00
Hornwitser 642504874b Fix error on slave reconnect of stale connection
Remove call to removed function setTimeout on connector that was left
over from the connector refactor.
2021-09-27 03:45:52 +02:00
Hornwitser d99e160a27 Remove unused node-factorio-api dependency 2021-09-27 03:45:52 +02:00
Hornwitser 714e590496 Add sorting to web interface tables
Add sort options to the slave, instance, user, role and plugins tables.
2021-09-27 03:45:52 +02:00
Hornwitser bfcb57002d Merge pull request #413 from Hornwitser/hide-unauthorized-controls
Hide unauthorized controls
2021-09-22 22:14:15 +02:00
Hornwitser 8b59c89322 Link account menu name to user account
Take the user to the user view page for their own account when clicking
on the name in the top right account menu.
2021-09-20 00:57:38 +02:00
Hornwitser 31e9f59d8d Move save list subscription permission
Move the save list subscription permission to be after the save list
permission as these two permissions are logically related to each other
and both need to be granted to view the save list in the web interface.
2021-09-20 00:56:47 +02:00
Hornwitser 0c74aef68e Hide unauthorized web interface controls
Hide or disable controls in the web interface that the user for the
control connection it's connected as don't have the requisite
permissions to use.
2021-09-20 00:54:43 +02:00
Hornwitser 18e9fc8e7c Provide roles and permission for the control user
Add the roles and their permissions for the user the control connection
is connected as along with updates when these change.  This enables the
web interface to know which permissions it has available to it.
2021-09-20 00:52:59 +02:00
Hornwitser 1035389c9e Hotfix new player sync logic
Fix has_data not being checked by sync_player, dialog being stuck open if
no data is received by the master and master server refusing to server
empty data.
2021-09-12 00:27:46 +02:00
58 changed files with 1061 additions and 595 deletions

No files matched your search

+16 -14
View File
@@ -86,19 +86,20 @@ Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Defaults to 45.
Defaults to 15.
### master.connector_shutdown_timeout
### master.session_timeout
Timeout in seconds to use while shutting down the master server for link sessions that currently don't have an active connection and haven't been closed down properly.
This happens when the network between a slave and the master server goes down unexpectedly, or the slave goes down without closing the connection properly either do to crashing or the machine it's hosted on going down.
If the timeout expires the session is terminated and data loss may occur, if the slave went down this is unavoidable.
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
In the case of the master or client crashing the data loss is unavoidable.
Defaults to 30.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `master.heartbeat_interval`.
Defaults to 60.
### master.metrics_timeout
@@ -197,12 +198,12 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
### slave.reconnect_delay
### slave.max_reconnect_delay
Delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all slaves trying to reconnect at the same time.
Maximum delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 5.
Defaults to 60.
## Instance Configuration
@@ -399,8 +400,9 @@ If you have a self signed certificate on the master server you will need to copy
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.reconnect_delay
### control.max_reconnect_delay
Duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 2.
Defaults to 60.
+2 -2
View File
@@ -99,7 +99,7 @@ The following messages are the only valid messages that can be sent during the h
Sent by the master server when the WebSocket connection has been opened.
- `version: string` - The version of the master, e.g. "2.0.0".
- `plugins: Object&lt;string, string&gt;` -
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
#### `register_slave`
@@ -111,7 +111,7 @@ Slave handshake for establishing a new connection session.
- `version: string` - The protocol version of the slave, e.g. "2.0.0".
- `name: string` - Name of the slave.
- `id: integer` - ID of the slave.
- `plugins: Object&lt;string, string&gt;` -
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the slave.
#### `register_control`
+1 -1
View File
@@ -4,5 +4,5 @@
"plugins/*",
"test"
],
"version": "2.0.0-alpha.9"
"version": "2.0.0-alpha.10"
}
+2
View File
@@ -927,6 +927,8 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"as-table": "^1.0.31",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
+3 -3
View File
@@ -2,10 +2,10 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const ini = require("ini");
const Jimp = require("jimp");
const JSZip = require("jszip");
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
/**
@@ -312,7 +312,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
for (let mod of modOrder) {
if (["core", "base", "export"].includes(mod)) {
@@ -330,7 +330,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(ini.parse(content.toString("utf8")));
mergeLocale(libIni.parse(content.toString("utf8")));
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ const events = require("events");
const util = require("util");
const crypto = require("crypto");
const ini = require("ini");
const libIni = require("../ini");
const rconClient = require("rcon-client");
const libErrors = require("../errors");
const { logger } = require("../logging");
@@ -1167,7 +1167,7 @@ class FactorioServer extends events.EventEmitter {
}
async _writeConfigIni() {
let content = ini.encode({
let content = libIni.stringify({
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
+94
View File
@@ -0,0 +1,94 @@
/**
* Factorio Compatible INI parser
*
* @module lib/ini
* @author Hornwitser
*/
"use strict";
/**
* Parse INI file into object of sections
* @param {string} input - content of INI file to parse.
* @returns {object<string, object<string, string>>}
* sections parsed from the ini file.
*/
function parse(input) {
const sections = {};
let currentSection = sections;
const lines = input.split(/\r\n|\n/);
for (let number = 1; number <= lines.length; number++) {
let line = lines[number - 1].replace(/^[\t ]+/, "");
if (!line || line[0] === "#" || line[0] === ";") {
continue;
}
// Read section header
if (line[0] === "[") {
line = line.replace(/[\t ]+$/, "");
if (line.slice(-1) !== "]") {
throw new Error(`Unterminated section header on line ${number}`);
}
const name = line.slice(1, -1);
if (Object.prototype.hasOwnProperty.call(sections, name)) {
throw new Error(`Duplicated section [${name}] on line ${number}`);
}
currentSection = {};
sections[name] = currentSection;
continue;
}
// Read key value
const equalIndex = line.indexOf("=");
if (equalIndex === -1) {
throw new Error(`Missing value for key ${line} on line ${number}`);
}
const key = line.slice(0, equalIndex);
if (Object.prototype.hasOwnProperty.call(currentSection, key)) {
throw new Error(`Duplicated key ${key} on line ${number}`);
}
const value = line.slice(equalIndex + 1);
currentSection[key] = value;
}
return sections;
}
/**
* Serialize an object of section definitions into an INI file
* @param {object<string, object<string, string>>} sections -
* Sections to encode into ini format
* @returns {string} sections serialized into INI format
*/
function stringify(sections) {
let serialized = [];
let first = true;
for (let [key, value] of Object.entries(sections)) {
if (typeof value === "string") {
serialized.push(`${key}=${value}`);
first = false;
}
}
for (let [section, content] of Object.entries(sections)) {
if (typeof content === "object") {
if (!first) {
serialized.push("");
}
first = false;
serialized.push(`[${section}]`);
for (let [key, value] of Object.entries(content)) {
serialized.push(`${key}=${value}`);
}
}
}
if (serialized.length) { serialized.push(""); }
return serialized.join("\n");
}
module.exports = {
parse,
stringify,
};
+6 -1
View File
@@ -73,7 +73,12 @@ class WebSocketBaseConnector extends events.EventEmitter {
this._check("connected");
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
logger.verbose("Connector | closing after heartbeat timed out");
this._socket.close(1008, "Heartbeat timeout");
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket.close(4008, "Heartbeat timeout");
} else {
this._socket.terminate();
}
} else {
this._socket.send(JSON.stringify({
+24
View File
@@ -1085,6 +1085,30 @@ messages.debugWsMessage = new Event({
},
});
messages.accountUpdate = new Event({
type: "account_update",
links: ["master-control"],
eventRequired: [],
eventProperties: {
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
});
messages.logMessage = new Event({
type: "log_message",
links: ["slave-master", "master-control"],
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"browser": "browser.js",
@@ -15,7 +15,6 @@
"ajv": "^6.12.6",
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"isomorphic-ws": "^4.0.1",
"jimp": "^0.12.1",
"jsonwebtoken": "^8.2.1",
+67 -1
View File
@@ -60,6 +60,9 @@ class BaseInstancePlugin {
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = instance.logger.child({ plugin: this.info.name });
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
@@ -207,9 +210,72 @@ class BaseInstancePlugin {
*/
async onPlayerEvent(event) { }
async sendRcon(message, expectEmpty) {
/**
* Send RCON message to instance
*
* Send a message or command to the server which can potentially be
* executed out of order in relation to other RCON commands.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendRcon(message, expectEmpty=false) {
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
}
/**
* Send serially ordered RCON message to instance
*
* Send a message or command to the server which will not be executed
* out of order in relation to other RCON commands sent with this
* method. The ordering applies per plugin, and two plugins sending
* commands with this method may execute out of order in relation to
* each other.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendOrderedRcon(message, expectEmpty=false) {
let promise = new Promise((resolve, reject) => {
this._pendingRconMessages.push({resolve, reject, message, expectEmpty});
});
if (!this._sendingRconMessages) {
this._sendPendingRconMessages();
}
return await promise;
}
async _sendPendingRconMessages() {
this._sendingRconMessages = true;
while (this._pendingRconMessages.length) {
let task = this._pendingRconMessages.shift();
try {
let result = await this.sendRcon(task.message, task.expectEmpty);
task.resolve(result);
} catch (err) {
task.reject(err);
}
}
this._sendingRconMessages = false;
}
}
/**
+18 -1
View File
@@ -104,10 +104,27 @@ const serverHandshake = ajv.compile({
"session_timeout": { type: "number" },
"heartbeat_interval": { type: "number" },
"account": {
type: "object",
additionalProperties: false,
required: ["name"],
required: ["name", "roles"],
properties: {
"name": { type: "string" },
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
},
},
+5 -5
View File
@@ -382,6 +382,11 @@ definePermission({
title: "List saves on instance",
description: "List the saves currently on the instance.",
});
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.save.create",
title: "Create new instance save",
@@ -402,11 +407,6 @@ definePermission({
title: "Download save from instance",
description: "Download savegames from 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",
+3 -4
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env production",
@@ -19,21 +19,20 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"express": "^4.16.2",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"fast-deep-equal": "^3.1.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+1
View File
@@ -64,6 +64,7 @@ class BaseConnection extends libLink.Link {
}
async disconnect(code, reason) {
this.connector.setClosing();
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
+4
View File
@@ -513,6 +513,7 @@ class ControlConnection extends BaseConnection {
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._master.rolePermissionsUpdated(role);
}
async grantDefaultRolePermissionsRequestHandler(message) {
@@ -522,6 +523,7 @@ class ControlConnection extends BaseConnection {
}
role.grantDefaultPermissions();
this._master.rolePermissionsUpdated(role);
}
async deleteRoleRequestHandler(message) {
@@ -534,6 +536,7 @@ class ControlConnection extends BaseConnection {
this._master.userManager.roles.delete(id);
for (let user of this._master.userManager.users.values()) {
user.roles.delete(role);
this._master.userPermissionsUpdated(user);
}
}
@@ -573,6 +576,7 @@ class ControlConnection extends BaseConnection {
}
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(user);
}
async setUserAdminRequestHandler(message) {
+39
View File
@@ -10,6 +10,7 @@ const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
@@ -410,6 +411,44 @@ class Master {
}
}
/**
* Notify connected control clients under the given user that the
* permissions for this user may have changed.
* @param {module:lib/users.User} user - User permisions updated for.
*/
userPermissionsUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user === user) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
/**
* Notify connected control clients with the given role that the
* permissions may have changed.
* @param {module:lib/users.Role} role - Role permisions updated for.
*/
rolePermissionsUpdated(role) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user.roles.has(role)) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
+6 -2
View File
@@ -136,7 +136,7 @@ class WsServer {
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
socket.terminate();
this.pendingSockets.delete(socket);
}, 30*1000);
@@ -266,7 +266,6 @@ ${err.stack}`
let connection = this.slaveConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
connection.connector.setTimeout(15); // Slave connection is likely stalled
await connection.disconnect(1008, "Registered from another connection");
}
@@ -298,6 +297,11 @@ ${err.stack}`
this.controlConnections.push(connection);
additionalReadyData["account"] = {
name: user.name,
roles: [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
};
}
+3 -12
View File
@@ -73,19 +73,10 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
continue(socket, lastSeq) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. If this is the case the connector will have to
// wait for the previous socket to close down before it can continue
// from a new socket, or there will be overlapping events from both.
// stale connection. Terminate it if so.
if (this._state === "connected") {
this._socket.close(4003, "Session Hijacked");
// Correctly waiting for the connector to close and then
// handling all the edge cases like multiple continues happening
// in parallel or connections going stale is far too difficult.
// Kill the connection here and let the client reconnect later
// when the connector is not held up by an active connection.
socket.close(1013, "Session Busy");
socket.terminate();
this._socket.terminate();
this._socket.once("close", () => this.continue(socket, lastSeq));
return;
}
+13 -1
View File
@@ -8,6 +8,7 @@ const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libErrors = require("@clusterio/lib/errors");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
@@ -47,7 +48,18 @@ async function getMetrics(req, res, next) {
let requests = [];
let timeout = req.app.locals.master.config.get("master.metrics_timeout") * 1000;
for (let slaveConnection of req.app.locals.master.wsServer.slaveConnections.values()) {
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
if (!slaveConnection.connected) {
continue;
}
requests.push(libHelpers.timeout(
libLink.messages.getMetrics.send(slaveConnection).catch(err => {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`Unexpected error gathering metrics from slave:\n${err.stack}`);
}
return null;
}),
timeout, null
));
}
for await (let result of await libPrometheus.defaultRegistry.collect()) {
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"pidusage": "^2.0.18",
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.13.8",
"@babel/preset-react": "^7.12.13",
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"assert": "^2.0.0",
"babel-loader": "^8.2.2",
@@ -3,6 +3,7 @@ import { Typography } from "antd";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
@@ -49,6 +50,7 @@ function formatLog(info, key) {
}
export default function InstanceConsole(props) {
let account = useAccount();
let control = useContext(ControlContext);
let anchor = useRef();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
@@ -59,19 +61,23 @@ export default function InstanceConsole(props) {
let parent = anchor.current.parentElement;
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
max_level: null,
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
if (account.hasPermission("core.log.query")) {
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
max_level: null,
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
} else {
setPastLines([]);
}
function logHandler(info) {
setLines(currentLines => currentLines.concat(
@@ -5,6 +5,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import InstanceConfigTree from "./InstanceConfigTree";
@@ -27,6 +28,7 @@ export default function InstanceViewPage(props) {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [instance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
@@ -46,9 +48,12 @@ export default function InstanceViewPage(props) {
}
let instanceButtons = <Space>
<StartStopInstanceButton instance={instance} />
<LoadScenarioModal instance={instance} />
<Button
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasPermission("core.instance.export_data") && <Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
@@ -63,8 +68,8 @@ export default function InstanceViewPage(props) {
}}
>
Export data
</Button>
<Popconfirm
</Button>}
{account.hasPermission("core.instance.delete") && <Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
@@ -83,7 +88,7 @@ export default function InstanceViewPage(props) {
>
<DeleteOutlined />
</Button>
</Popconfirm>
</Popconfirm>}
</Space>;
let assigned = instance["assigned_slave"] !== null;
@@ -99,7 +104,7 @@ export default function InstanceViewPage(props) {
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
<AssignInstanceModal
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
@@ -109,16 +114,25 @@ export default function InstanceViewPage(props) {
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>
/>}
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
&& <SavesList instance={instance} />
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
}
{account.hasPermission("core.log.follow") && <InstanceConsole id={instanceId} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
}
<InstanceConfigTree id={instanceId} />
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
</PageLayout>;
}
@@ -4,12 +4,15 @@ import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { libConfig, libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useInstanceList } from "../model/instance";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function CreateInstanceButton(props) {
let control = useContext(ControlContext);
@@ -59,14 +62,23 @@ function CreateInstanceButton(props) {
export default function InstancesPage() {
let history = useHistory();
let account = useAccount();
let [slaveList] = useSlaveList();
let [instanceList] = useInstanceList();
function slaveName(slaveId) {
let slave = slaveList.find(s => s.id === slaveId);
if (slave) {
return slave.name;
}
return String(slaveId);
}
return <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
className="site-page-header"
title="Instances"
extra=<CreateInstanceButton />
extra={account.hasPermission("core.instance.create") && <CreateInstanceButton />}
/>
<Table
@@ -74,21 +86,19 @@ export default function InstancesPage() {
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave) {
return slave.name;
}
return instance.assigned_slave;
},
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
},
{
title: "Status",
dataIndex: "status",
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
]}
dataSource={instanceList}
@@ -1,12 +1,15 @@
import React, { useContext } from "react";
import { useAccount } from "../model/account";
import MasterConfigTree from "./MasterConfigTree";
import PageLayout from "./PageLayout";
export default function SlavesPage() {
let account = useAccount();
return <PageLayout nav={[{ name: "Master" }]}>
<h2>Master</h2>
<MasterConfigTree />
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
</PageLayout>;
};
@@ -6,6 +6,8 @@ import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function PluginsPage() {
let plugins = useContext(ControlContext).plugins;
@@ -47,6 +49,8 @@ export default function PluginsPage() {
title: "Name",
key: "name",
render: plugin => (plugin.info ? plugin.info.title : plugin.meta.name),
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
},
{
title: "Version",
@@ -60,11 +64,13 @@ export default function PluginsPage() {
}
return plugin.package.version;
},
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
},
{
title: "Loaded",
dataIndex: ["meta", "loaded"],
render: loaded => (loaded ? "Yes" : null),
sorter: (a, b) => a.loaded - b.loaded,
responsive: ["sm"],
},
]}
+38 -34
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
import { Button, Checkbox, Form, Input, PageHeader, Popconfirm, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink, libUsers } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
@@ -42,6 +43,7 @@ export default function RoleViewPage() {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [role, updateRole] = useRole(roleId);
let [edited, setEdited] = useState(false);
@@ -50,14 +52,20 @@ export default function RoleViewPage() {
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
if (role.loading) {
return <PageLayout nav={nav}>
<h2>{roleId}</h2>
<PageHeader
className="site-page-header"
title={roleId}
/>
<Spin size="large" />
</PageLayout>;
}
if (role.missing) {
return <PageLayout nav={nav}>
<h2>Role not found</h2>
<PageHeader
className="site-page-header"
title="Role not found"
/>
<p>Role with id {roleId} was not found on the master server.</p>
</PageLayout>;
}
@@ -72,6 +80,7 @@ export default function RoleViewPage() {
},
};
let canUpdate = account.hasPermission("core.role.update");
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
@@ -93,40 +102,35 @@ export default function RoleViewPage() {
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
<Row>
<Col flex="auto">
<h2>{role["name"]}</h2>
</Col>
<Col flex="0 0 auto">
<Space>
<Button type={edited ? "primary" : "default"} htmlType="submit">
Apply
<PageHeader
className="site-page-header"
title={role["name"]}
extra={<>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteRole.send(
control, { id: roleId }
).then(() => {
history.push("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
<Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteRole.send(
control, { id: roleId }
).then(() => {
history.push("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
</Popconfirm>}
</>}
/>
<Form.Item name="name" label="Name">
<Input/>
<Input disabled={!canUpdate}/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
<Input disabled={!canUpdate}/>
</Form.Item>
<h3>Permissions</h3>
{[...libUsers.permissions.values()].map(({name, title, description}) => (
@@ -141,7 +145,7 @@ export default function RoleViewPage() {
labelAlign="left"
colon={false}
>
<Checkbox/>
<Checkbox disabled={!canUpdate} />
</Form.Item>
))}
</Form>
+86 -33
View File
@@ -1,50 +1,103 @@
import React, { useContext } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { libLink } from "@clusterio/lib";
import DataTable from "./data-table";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function UsersPage() {
function CreateRoleButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
async function createRole() {
let values = form.getFieldsValue();
if (!values.roleName) {
form.setFields([{ name: "roleName", errors: ["Name is required"] }]);
return;
}
let result = await libLink.messages.createRole.send(control, {
name: values.roleName,
description: values.description || "",
permissions: [],
});
setVisible(false);
history.push(`/roles/${result.id}/view`);
}
return <>
<Button
type="primary"
onClick={() => { setVisible(true); }}
>Create</Button>
<Modal
title="Create Role"
okText="Create"
visible={visible}
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
onCancel={() => { setVisible(false); }}
destroyOnClose
>
<Form form={form}>
<Form.Item name="roleName" label="Name">
<Input/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function RolesPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
async function listRoles() {
let result = await libLink.messages.listRoles.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Description": item["description"],
}));
}
let [roles, setRoles] = useState([]);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(result["list"]);
}).catch(notifyErrorHandler("Error fetching role list"));
}, []);
return <PageLayout nav={[{ name: "Roles" }]}>
<h2>Roles</h2>
<DataTable
DataFunction={listRoles}
AddRecord={{
fields: [
{ dataIndex: "name", title: "Name" },
{ dataIndex: "description", title: "Description" },
],
insert: async args => {
await libLink.messages.createRole.send(control, {
name: args.name,
description: args.description || "",
permissions: [],
});
<PageHeader
className="site-page-header"
title="Roles"
extra={account.hasPermission("core.role.create") && <CreateRoleButton />}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
history.push(`/roles/${record.key}/view`);
},
}),
}}
{
title: "Description",
dataIndex: "description",
},
]}
dataSource={roles}
pagination={false}
rowKey={role => role["id"]}
onRow={(role, rowIndex) => ({
onClick: event => {
history.push(`/roles/${role["id"]}/view`);
},
})}
/>
</PageLayout>;
}
+20 -14
View File
@@ -5,6 +5,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
@@ -24,6 +25,7 @@ function formatBytes(bytes) {
}
export default function SavesList(props) {
let account = useAccount();
let control = useContext(ControlContext);
let saves = useSaves(props.instance.id);
let [starting, setStarting] = useState(false);
@@ -65,7 +67,7 @@ export default function SavesList(props) {
columnWidth: 33,
expandRowByClick: true,
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
<Button
{account.hasPermission("core.instance.start") && <Button
loading={starting}
disabled={props.instance.status !== "stopped"}
onClick={() => {
@@ -78,8 +80,8 @@ export default function SavesList(props) {
() => { setStarting(false); }
);
}}
>Load save</Button>
<Button
>Load save</Button>}
{account.hasPermission("core.instance.save.download") && <Button
disabled={slaveOffline}
onClick={() => {
libLink.messages.downloadSave.send(
@@ -92,8 +94,8 @@ export default function SavesList(props) {
notifyErrorHandler("Error downloading save")
);
}}
>Download</Button>
<Popconfirm
>Download</Button>}
{account.hasPermission("core.instance.save.delete") && <Popconfirm
title="Permanently delete save?"
okText="Delete"
placement="top"
@@ -105,7 +107,7 @@ export default function SavesList(props) {
}}
>
<Button danger disabled={slaveOffline}>Delete</Button>
</Popconfirm>
</Popconfirm>}
</Space>,
}}
/>;
@@ -158,15 +160,19 @@ export default function SavesList(props) {
};
return <div>
<SectionHeader title="Saves" extra=<Space>
<Upload {...uploadProps} >
<SectionHeader title="Saves" extra={<Space>
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
<Button disabled={slaveOffline}>Upload save</Button>
</Upload>
<CreateSaveModal instance={props.instance} />
</Space> />
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
</Upload>}
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
</Space>} />
{
account.hasPermission("core.instance.save.upload")
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
: saveTable
}
{uploadingFiles.length ? <List>
{uploadingFiles.map(file => <List.Item key={file.uid}>
{file.name}
@@ -27,9 +27,9 @@ export default function SiteLayout(props) {
}
let accountMenu = <Menu>
<Menu.ItemGroup title={account.name}/>
<Menu.Item key="1" onClick={() => { history.push(`/users/${account.name}/view`); }}>{account.name}</Menu.Item>
<Menu.Divider/>
<Menu.Item danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
<Menu.Item key="2" danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
</Menu>;
let combinedPages = [...pages];
@@ -69,9 +69,12 @@ export default function SiteLayout(props) {
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{combinedPages.map(({sidebarName, path}) => (
sidebarName ? <Menu.Item key={path}>{sidebarName}</Menu.Item> : null)
)}
{combinedPages.map(({sidebarName, permission, path}) => {
if (!sidebarName || permission && !account.hasPermission(permission)) {
return null;
}
return <Menu.Item key={path}>{sidebarName}</Menu.Item>;
})}
</Menu>
</Sider>
<Layout className="site-layout-content-container">
+11 -2
View File
@@ -3,11 +3,14 @@ import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useSlaveList } from "../model/slave";
import { notifyErrorHandler } from "../util/notify";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function GenerateSlaveTokenButton(props) {
let control = useContext(ControlContext);
@@ -71,33 +74,39 @@ function GenerateSlaveTokenButton(props) {
export default function SlavesPage() {
let control = useContext(ControlContext);
let account = useAccount();
let [slaveList] = useSlaveList();
return <PageLayout nav={[{ name: "Slaves" }]}>
<PageHeader
className="site-page-header"
title="Slaves"
extra=<GenerateSlaveTokenButton />
extra={account.hasPermission("core.slave.generate_token") && <GenerateSlaveTokenButton />}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Agent",
dataIndex: "agent",
sorter: (a, b) => strcmp(a["agent"], b["agent"]),
responsive: ["lg"],
},
{
title: "Version",
dataIndex: "version",
sorter: (a, b) => strcmp(a["version"], b["version"]),
},
{
title: "Connected",
key: "connected",
render: slave => slave["connected"] && "Yes",
sorter: (a, b) => a["connected"] - b["connected"],
},
]}
dataSource={slaveList}
+57 -41
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
import { Button, Col, Form, Input, PageHeader, Popconfirm, Popover, Row, Tag, Select, Space, Spin, Switch } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
@@ -39,6 +40,7 @@ export default function UserViewPage() {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [user, updateUser] = useUser(userName);
let [roles, setRoles] = useState(null);
@@ -59,25 +61,55 @@ export default function UserViewPage() {
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (user.loading) {
return <PageLayout nav={nav}>
<h2>{userName}</h2>
<PageHeader
className="site-page-header"
title={userName}
/>
<Spin size="large" />
</PageLayout>;
}
if (user.missing) {
return <PageLayout nav={nav}>
<h2>User not found</h2>
<PageHeader
className="site-page-header"
title="User not found"
/>
<p>User with name {userName} was not found on the master server.</p>
</PageLayout>;
}
let roleSelector = <Form.Item
noStyle
name="roles"
initialValue={user["roles"]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
let role = roles.get(option.value);
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
}}
>
{roles && [...roles.values()].map(r => <Select.Option
key={r["id"]}
value={r["id"]}
>{r["name"]}</Select.Option>)}
</Select>
</Form.Item>;
return <PageLayout nav={nav}>
<Row>
<Col flex="auto">
<h2>{userName}</h2>
</Col>
<Col flex="0 0 auto">
<Popconfirm
<PageHeader
className="site-page-header"
title={userName}
extra={
account.hasPermission("core.user.delete") && <Popconfirm
title={<>
Delete user account and all data associated with it?
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
@@ -97,40 +129,22 @@ export default function UserViewPage() {
<DeleteOutlined />
</Button>
</Popconfirm>
</Col>
</Row>
}
/>
<Form form={form}>
<Form.Item
label="Roles"
>
<Row gutter={8}>
<Col flex="auto">
<Form.Item
noStyle
name="roles"
initialValue={user["roles"]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
let role = roles.get(option.value);
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
}}
>
{roles && [...roles.values()].map(r => <Select.Option
key={r["id"]}
value={r["id"]}
>{r["name"]}</Select.Option>)}
</Select>
</Form.Item>
{account.hasPermission("core.user.update_roles")
? roleSelector
: [...user["roles"]].map(id => <Tag key={id}>{
roles ? (roles.get(id) || { name: id })["name"] : id
}</Tag>)
}
</Col>
<Col flex="0 0 auto">
{account.hasPermission("core.user.update_roles") && <Col flex="0 0 auto">
<Button
type={"primary"}
disabled={!rolesDirty}
@@ -152,12 +166,13 @@ export default function UserViewPage() {
});
}}
>Apply</Button>
</Col>
</Col>}
</Row>
</Form.Item>
<Form.Item label="In-game Admin">
<Switch
checked={user["is_admin"]}
disabled={!account.hasPermission("core.user.set_admin")}
onClick={() => {
libLink.messages.setUserAdmin.send(control, {
name: userName,
@@ -172,6 +187,7 @@ export default function UserViewPage() {
<Form.Item label="Whitelisted">
<Switch
checked={user["is_whitelisted"]}
disabled={!account.hasPermission("core.user.set_whitelisted")}
onClick={() => {
libLink.messages.setUserWhitelisted.send(control, {
name: userName,
@@ -186,7 +202,7 @@ export default function UserViewPage() {
<Form.Item label="Banned">
<Space>
{user["is_banned"] ? "Yes" : "No"}
<Popover
{account.hasPermission("core.user.set_banned") && <Popover
title="Ban user"
visible={banUserPopover}
trigger="click"
@@ -195,7 +211,7 @@ export default function UserViewPage() {
setBanUserPopover(!banUserPopover);
}
}}
content=<Form.Item label="Reason">
content={<Form.Item label="Reason">
<Row gutter={8}>
<Col flex="auto">
<Form.Item noStyle name="reason">
@@ -220,7 +236,7 @@ export default function UserViewPage() {
>Ban</Button>
</Col>
</Row>
</Form.Item>
</Form.Item>}
>
<Button
type="primary"
@@ -238,7 +254,7 @@ export default function UserViewPage() {
}
}}
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
</Popover>
</Popover>}
</Space>
</Form.Item>
</Form>
+98 -41
View File
@@ -1,68 +1,125 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Spin, Tag } from "antd";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { libLink } from "@clusterio/lib";
import DataTable from "./data-table";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function CreateUserButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
async function createUser() {
let values = form.getFieldsValue();
if (!values.userName) {
form.setFields([{ name: "userName", errors: ["Name is required"] }]);
return;
}
await libLink.messages.createUser.send(control, { name: values.userName });
setVisible(false);
history.push(`/users/${values.userName}/view`);
}
return <>
<Button
type="primary"
onClick={() => { setVisible(true); }}
>Create</Button>
<Modal
title="Create User"
okText="Create"
visible={visible}
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
onCancel={() => { setVisible(false); }}
destroyOnClose
>
<Form form={form}>
<Form.Item name="userName" label="Name">
<Input/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
let [roles, setRoles] = useState(null);
let [users, setUsers] = useState([]);
let [roles, setRoles] = useState(new Map());
useEffect(() => {
libLink.messages.listUsers.send(control).then(result => {
setUsers(result["list"]);
}).catch(notifyErrorHandler("Error fetching user list"));
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
// ignore
});
}, []);
async function listUsers() {
let result = await libLink.messages.listUsers.send(control);
return result["list"].map(item => ({
key: item["name"],
"Name": item["name"],
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
"Admin": item["is_admin"] && "Yes",
"Whitelisted": item["is_whitelisted"] && "Yes",
"Banned": item["is_banned"] && "Yes",
}));
}
// DataTable does not make it possible to update the rendering function
if (roles === null) {
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<Spin/>
</PageLayout>;
}
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<DataTable
DataFunction={listUsers}
AddRecord={{
fields: [{
dataIndex: "name",
<PageHeader
className="site-page-header"
title="Users"
extra={account.hasPermission("core.user.create") && <CreateUserButton />}
/>
<Table
columns={[
{
title: "Name",
}],
insert: async args => {
await libLink.messages.createUser.send(control, { name: args.name });
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
history.push(`/users/${record.key}/view`);
},
}),
}}
{
title: "Roles",
key: "roles",
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
},
{
title: "Admin",
key: "admin",
render: user => user["is_admin"] && "yes",
sorter: (a, b) => a["is_admin"] - b["is_admin"],
responsive: ["lg"],
},
{
title: "Whitelisted",
key: "whitelisted",
render: user => user["is_whitelisted"] && "yes",
sorter: (a, b) => a["is_whitelisted"] - b["is_whitelisted"],
responsive: ["lg"],
},
{
title: "Banned",
key: "banned",
render: user => user["is_banned"] && "yes",
sorter: (a, b) => a["is_banned"] - b["is_banned"],
responsive: ["lg"],
},
]}
dataSource={users}
pagination={false}
rowKey={user => user["name"]}
onRow={(user, rowIndex) => ({
onClick: event => {
history.push(`/users/${user["name"]}/view`);
},
})}
/>
</PageLayout>;
}
@@ -1,281 +0,0 @@
import React, { Component } from "react";
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import notify from "../util/notify";
const { Panel } = Collapse;
/**
* Display data in table. Allows for editing data by clicking rows in the table.
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
*
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
* Editable - Boolean - Allow editing
* AddRecord - Boolean - Allow adding new records
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
* RecordIdField - String -
* By default we assume the column "Id" is a unique identifier for a record. If
* that isn't the case, supply the column name here
*/
class DataTable extends Component {
constructor(props) {
super(props);
if (!this.props.DataFunction) {
throw new Error("DataFunction not defined");
}
this.state = {
pagination: {
pageSize: 100,
current: 1,
},
filters: this.props.Filters,
sorter: {},
tableData: [],
showEditModal: false,
record: {},
};
}
componentDidMount() {
this.getData();
}
async getData(pagination, filters, sorter) {
// pagination = {
// pageSize: 10, // items per page
// current: 1, // page number
// total: 123, // Total amount of items
// }
// filters = {
// ReportId: 123
// }
// sorter = {}
pagination = pagination || this.state.pagination;
filters = filters || this.state.filters;
sorter = sorter || this.state.sorter;
// eslint-disable-next-line new-cap
let data = await this.props.DataFunction();
data = data || {};
this.setState({
// eslint-disable-next-line new-cap
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
pagination: { ...pagination, ...data.pagination },
sorter,
filters,
});
}
showEditModal(record, index) {
if (!this.state.showEditModal) {
this.setState({
record: record,
showEditModal: true,
});
}
}
hideEditModal() {
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
}
async onEditFinish(values) {
if (this.state.inserting) { // Create new record
await this.props.AddRecord.insert(values);
} else { // Edit existing record, capable of doing sparse updates
await this.props.Editable.insert(values);
}
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
// Force refresh of data
this.getData();
}
async deleteRecord(record) {
delete record.key;
// Delete "null" values as they seem to cause deletes to fail silently when
// the value is null and field type is float
for (let key in record) {
if (record[key] === null) { delete record[key]; }
}
let status = {};
// TODO: Delete data here
if (status.ok) { notify("success", status.msg, "success"); }
if (!status.ok) { notify("error", status.msg, "error"); }
// Force refresh of data
this.getData();
}
render() {
let tableProps = {
...this.props.TableProps || {},
onRow: (record, rowIndex) => {
// To allow for extending functionality, we take a TableProps argument
// from props and pass it through to the Table component. Since we
// are also using onRow internaly here, we handle the externally
// provided events ourselves by passing along the event object. This
// way the end user isn't affected by our event handlers.
let externalOnRow = {
...(
this.props.TableProps
&& this.props.TableProps.onRow
&& this.props.TableProps.onRow(record, rowIndex)
),
};
return {
onClick: event => {
if (event.target.type !== "button") {
if (this.props.Editable) { this.showEditModal(record, rowIndex); }
if (externalOnRow.onClick) { externalOnRow.onClick(event); }
}
},
onDoubleClick: event => {
if (externalOnRow.onDoubleClick) { externalOnRow.onDoubleClick(event); }
},
onContextMenu: event => {
if (externalOnRow.onContextMenu) { externalOnRow.onContextMenu(event); }
},
onMouseEnter: event => {
if (externalOnRow.onMouseEnter) { externalOnRow.onMouseEnter(event); }
},
onMouseLeave: event => {
if (externalOnRow.onMouseLeave) { externalOnRow.onMouseLeave(event); }
},
};
},
};
let columns = this.props.columns || this.inferColumns(this.state.tableData);
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) {
columns.push({
// Add delete button on the right
dataIndex: "notInUse",
title: "",
render: (text, record) => <Popover
content={
<Button
type="primary"
danger
onClick={() => {
this.deleteRecord(record);
}}
>
Slett permanent
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>,
});
}
return <>
<Row>
<Col span={24}>
<style>
{`
.editableTable .ant-table-tbody > tr {
cursor:pointer
}
`}
</style>
<Table
className={this.props.Editable && "editableTable"}
{...tableProps}
columns={columns.map(x => ({
...x, // Add a 4th parameter to the stock render function with all of our table data
render: x.render
? (...originalParams) => x.render(...originalParams, this.state.tableData)
: undefined,
}))}
dataSource={this.state.tableData}
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
onChange={this.getData.bind(this)}
/>
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
showEditModal: true,
inserting: true,
})}>
<PlusOutlined />Add
</Button>}
</Col>
<Modal
visible={this.state.showEditModal}
onCancel={this.hideEditModal.bind(this)}
footer={null}
title={
<h2>{this.state.inserting ? "Add" : "Edit"}</h2>
}
>
{this.state.showEditModal && <Form
labelCol={{
span: 6,
}}
wrapperCol={{
span: 12,
}}
layout="horizontal"
onFinish={this.onEditFinish.bind(this)}
initialValues={this.state.record}
>
{(
// Use externally supplied form input fields
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
// Generate input fields from external columns
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
// Generate input fields from autogenerated columns (only works with existing data)
|| this.inferColumns(this.state.tableData)
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit && col.renderEdit(
this.state.record[col.dataIndex],
this.state.record,
undefined /* Non spec compliant, is supposed to be index of record */,
this.state.tableData
) || <Input placeholder={this.state.record[col.dataIndex]} />}
</Form.Item>)}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Save" : "Save"}
</Button>
</Form.Item>
</Form>}
</Modal>
</Row>
</>;
}
inferColumns(data) {
// Look at the data and find columns and how to display them. Can be
// overridden by props.Columns if more customization is needed
let columns = [];
data.forEach(row => {
Object.keys(row).filter(x => x !== "key").forEach(key => {
let existing = columns.find(col => col.key === key);
if (!existing) {
columns.push({
key: key,
title: key,
dataIndex: key,
});
}
});
});
return columns;
}
}
export default DataTable;
+47 -9
View File
@@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext";
/**
* @typedef {Object} Account
* @property {?string} name - Name of the currently logged in account.
* @property {?Array<object>} roles - Roles of the corrently logged in account.
* @property {function(string)} hasPermission -
* Check if the currently logged in account has the given permission.
* @property {function()} logout - Logs out of the web interface.
*/
export function useAccount() {
let control = useContext(ControlContext);
let [name, setName] = useState(control.accountName);
let [roles, setRoles] = useState(control.accountRoles);
useEffect(() => {
function onConnect(data) {
setName(data.account.name);
function onAccountUpdate(account) {
setName(account.name);
setRoles(account.roles);
}
function onClose() {
setName(null);
}
control.connector.on("connect", onConnect);
control.connector.on("close", onClose);
control.onAccountUpdate(onAccountUpdate);
return () => {
control.connector.off("connect", onConnect);
control.connector.off("close", onClose);
control.offAccountUpdate(onAccountUpdate);
};
}, [control]);
function hasPermission(permission) {
for (let role of roles) {
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
return true;
}
}
return false;
}
return {
name,
roles,
hasPermission(permission) {
if (!roles) {
return null;
}
return hasPermission(permission);
},
hasAnyPermission(...permissions) {
if (!roles) {
return null;
}
for (let permission of permissions) {
if (hasPermission(permission)) {
return true;
}
}
return false;
},
hasAllPermission(...permissions) {
if (!roles) {
return null;
}
for (let permission of permissions) {
if (!hasPermission(permission)) {
return false;
}
}
return true;
},
logOut() {
control.loggingOut = true;
control.shutdown(notifyErrorHandler("Error logging out"));
+54 -10
View File
@@ -13,16 +13,60 @@ import PluginViewPage from "./components/PluginViewPage";
export const pages = [
{ path: "/master", sidebarName: "Master", content: <MasterPage /> },
{ path: "/slaves", sidebarName: "Slaves", content: <SlavesPage /> },
{ path: "/instances", sidebarName: "Instances", content: <InstancesPage />},
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
{ path: "/users", sidebarName: "Users", content: <UsersPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles", sidebarName: "Roles", content: <RolesPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
{ path: "/plugins", sidebarName: "Plugins", content: <PluginsPage /> },
{ path: "/plugins/:name/view", sidebarPath: "/plugins", content: <PluginViewPage /> },
{
path: "/master",
sidebarName: "Master",
content: <MasterPage />,
},
{
path: "/slaves",
sidebarName: "Slaves",
permission: "core.slave.list",
content: <SlavesPage />,
},
{
path: "/instances",
sidebarName: "Instances",
permission: "core.instance.list",
content: <InstancesPage />,
},
{
path: "/instances/:id/view",
sidebarPath: "/instances",
content: <InstanceViewPage />,
},
{
path: "/users",
sidebarName: "Users",
permission: "core.user.list",
content: <UsersPage />,
},
{
path: "/users/:name/view",
sidebarPath: "/users",
content: <UserViewPage />,
},
{
path: "/roles",
sidebarName: "Roles",
permission: "core.user.list",
content: <RolesPage />,
},
{
path: "/roles/:id/view",
sidebarPath: "/roles",
content: <RoleViewPage />,
},
{
path: "/plugins",
sidebarName: "Plugins",
content: <PluginsPage />,
},
{
path: "/plugins/:name/view",
sidebarPath: "/plugins",
content: <PluginViewPage />,
},
];
export const sidebarPages = [
+37
View File
@@ -60,6 +60,13 @@ export class Control extends libLink.Link {
*/
this.accountName = null;
/**
* Roles of the account this control link is connected as.
* @type {?Array<object>}
*/
this.accountRoles = null;
this.accountUpdateHandlers = [];
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
@@ -67,6 +74,8 @@ export class Control extends libLink.Link {
this.connector.on("connect", data => {
this.accountName = data.account.name;
this.accountRoles = data.account.roles;
this.emitAccountUpdate();
this.updateSlaveSubscriptions().catch(err => logger.error(
`Unexpected error updating slave subscriptions:\n${err.stack}`
));
@@ -83,6 +92,8 @@ export class Control extends libLink.Link {
this.connector.on("close", () => {
this.accountName = null;
this.accountRoles = null;
this.emitAccountUpdate();
});
for (let event of ["connect", "drop", "resume", "close"]) {
@@ -94,6 +105,32 @@ export class Control extends libLink.Link {
}
}
async accountUpdateEventHandler(message) {
this.accountRoles = message.data.roles;
this.emitAccountUpdate();
}
emitAccountUpdate() {
for (let handler of this.accountUpdateHandlers) {
handler({
name: this.accountName,
roles: this.accountRoles,
});
}
}
onAccountUpdate(handler) {
this.accountUpdateHandlers.push(handler);
}
offAccountUpdate(handler) {
let index = this.accountUpdateHandlers.lastIndexOf(handler);
if (index === -1) {
throw new Error("Given handler is not registered for account update");
}
this.accountUpdateHandlers.splice(index, 1);
}
async slaveUpdateEventHandler(message) {
let handlers = [].concat(
this.slaveUpdateHandlers.get(null) || [],
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,8 +20,8 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
+1 -6
View File
@@ -160,14 +160,9 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
this.logger.warn(`${instanceName} downloaded ${player_name} while another instance has acquired it`);
}
let playerData = this.playerDatastore.get(player_name);
if (!playerData) {
throw new libErrors.RequestError(`No player data exists for ${player_name}`);
}
this.logger.verbose(`Sending player data for ${player_name} to ${instanceName}`);
return {
player_data: playerData || null,
player_data: this.playerDatastore.get(player_name) || null,
};
}
@@ -28,6 +28,8 @@ local function download_inventory(player_name, data, number, total)
log("ERROR: Inventory sync failed, got empty player data for " .. player.name .. " from the master")
player.print("ERROR: Inventory sync failed, got empty player data from the master")
progress_dialog.remove(player)
-- Give the player a character and pretend that's the synced player data
ensure_character(player)
@@ -194,7 +194,10 @@ function inventory_sync.sync_player(acquire_response)
return
end
if not player_record.sync or acquire_response.generation > player_record.generation then
if
acquire_response.has_data
and (not player_record.sync or acquire_response.generation > player_record.generation)
then
inventory_sync.initiate_inventory_download(player, player_record, acquire_response.generation)
else
@@ -3,6 +3,18 @@ local serialize = require("modules/inventory_sync/serialize")
local save_crafts = require("modules/inventory_sync/save_crafts")
local function upload_inventory(player, player_record)
-- Editor mode is not supported.
if player.controller_type == defines.controllers.editor then
player.toggle_map_editor()
end
if player.controller_type == defines.controllers.editor then
log("ERROR: Inventory sync failed to upload, unable to switch " .. player.name .. " out of editor mode")
player.print("ERROR: Inventory sync failed to upload, unable to switch out of editor mode")
global.inventory_sync.active_uploads[player.name] = nil
return
end
local serialized_player = serialize.serialize_player(player)
serialized_player.generation = player_record.generation
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+6 -1
View File
@@ -31,7 +31,12 @@ function InventoryPage() {
export class WebPlugin extends libPlugin.BaseWebPlugin {
async init() {
this.pages = [
{ path: "/inventory", sidebarName: "Inventory sync", content: <InventoryPage /> },
{
path: "/inventory",
sidebarName: "Inventory sync",
permission: "inventory_sync.inventory.view",
content: <InventoryPage />,
},
];
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -24,8 +24,8 @@
"jsonwebtoken": "^8.2.1"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"phin": "^3.5.1",
"react": "^16.13.1",
+13 -4
View File
@@ -19,6 +19,8 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
this.instance.server.on("ipc-research_sync:finished", (tech) => {
this.researchFinished(tech).catch(err => this.unexpectedError(err));
});
this.syncStarted = false;
}
async researchContribution(tech) {
@@ -26,8 +28,11 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async progressEventHandler(message) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
let techsJson = libLuaTools.escapeString(JSON.stringify(message.data.technologies));
await this.sendRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
await this.sendOrderedRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
}
async researchFinished(tech) {
@@ -35,14 +40,17 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async finishedEventHandler(message) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
let { name, level, researched } = message.data;
await this.sendRcon(
await this.sendOrderedRcon(
`/sc research_sync.research_technology("${libLuaTools.escapeString(name)}", ${level})`, true
);
}
async onStart() {
let dumpJson = await this.sendRcon("/sc research_sync.dump_technologies()");
let dumpJson = await this.sendOrderedRcon("/sc research_sync.dump_technologies()");
let techsToSend = [];
let instanceTechs = new Map();
for (let tech of JSON.parse(dumpJson)) {
@@ -56,6 +64,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
let response = await this.info.messages.syncTechnologies.send(this.instance, { technologies: techsToSend });
this.syncStarted = true;
let techsToSync = [];
for (let masterTech of response.technologies) {
let [name, level, progress, researched] = masterTech;
@@ -72,7 +81,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
if (techsToSync.length) {
let syncJson = libLuaTools.escapeString(JSON.stringify(techsToSync));
await this.sendRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
await this.sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
+4
View File
@@ -77,6 +77,10 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async onMetrics() {
if (this.instance.status !== "running") {
return;
}
// Wait configured timeout for the metrics to be collected. It may
// take a long time for the command to go through if the command
// stream is overloaded. Should the timeout be exceeded the
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,8 +20,8 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
@@ -17,7 +17,14 @@ describe("statistics_exporter plugin", function() {
});
describe(".onMetrics()", function() {
it("should not send commands before running", async function() {
instancePlugin.instance.server.rconCommands.length = 0;
instancePlugin.instance.status = "starting";
await instancePlugin.onMetrics();
assert(instancePlugin.instance.server.rconCommands.length === 0, "commands were run");
});
it("should record statistics", async function() {
instancePlugin.instance.status = "running";
instancePlugin.instance.server.rconCommandResults.set(
"/sc statistics_exporter.export()",
JSON.stringify({
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+6 -1
View File
@@ -117,7 +117,12 @@ function StoragePage() {
export class WebPlugin extends libPlugin.BaseWebPlugin {
async init() {
this.pages = [
{ path: "/storage", sidebarName: "Storage", content: <StoragePage/> },
{
path: "/storage",
sidebarName: "Storage",
permission: "subspace_storage.storage.view",
content: <StoragePage/>,
},
];
this.storage = new Map();
+2
View File
@@ -49,6 +49,8 @@ class TestControl extends libLink.Link {
async debugWsMessageEventHandler() { }
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler(message) {
this.slaveUpdates.push(message.data);
}
+93
View File
@@ -0,0 +1,93 @@
"use strict";
const assert = require("assert").strict;
const libIni = require("@clusterio/lib/ini");
describe("lib/ini", function() {
describe("parse()", function() {
it("should ignore comments", function() {
assert.deepEqual(
libIni.parse("# A comment\n; Another comment\nfoo=bar\n"),
{ foo: "bar" },
);
});
it("should ignore certain whitespace", function() {
assert.deepEqual(
libIni.parse(" # A comment \n\n [spam] \t \n \tfoo=bar\n"),
{ spam: { foo: "bar" }}
);
});
it("should preserve certain whitespace", function() {
assert.deepEqual(
libIni.parse("[\t spam\t ]\nfoo =\t bar \n"),
{ "\t spam\t ": { "foo ": "\t bar " }}
);
});
it("should not treat characters specially", function() {
assert.deepEqual(
libIni.parse("[#;spam]\nfoo#;=#;bar=#;'\""),
{ "#;spam": { "foo#;": "#;bar=#;'\"" }}
);
});
it("should handle windows line endings", function() {
assert.deepEqual(
libIni.parse("[spam]\r\nfoo=bar\r\n"),
{ "spam": { "foo": "bar" }}
);
});
it("should throw on incomplete section", function() {
assert.throws(
() => libIni.parse("[spam"),
new Error("Unterminated section header on line 1")
);
});
it("should throw on missing value", function() {
assert.throws(
() => libIni.parse("key"),
new Error("Missing value for key key on line 1")
);
});
it("should throw on duplicated section", function() {
assert.throws(
() => libIni.parse("[spam]\n[spam]\n"),
new Error("Duplicated section [spam] on line 2")
);
});
it("should throw on duplicated keys", function() {
assert.throws(
() => libIni.parse("key=value\nkey=value\n"),
new Error("Duplicated key key on line 2")
);
});
});
describe("stringify()", function() {
it("should serialize to reference output", function() {
let cases = [
[{}, ""],
[{ foo: "bar" }, "foo=bar\n"],
[{ foo: { spam: "one", bar: "two" }}, "[foo]\nspam=one\nbar=two\n"],
[
{ start: "alpha", foo: { spam: "one", bar: "two" }, end: "beta" },
"start=alpha\nend=beta\n\n[foo]\nspam=one\nbar=two\n",
],
[{ "trail ": "\t tabs \t" }, "trail =\t tabs \t\n"],
[{ "#;spam": { "foo#;": "#;bar=#;'\"" }}, "[#;spam]\nfoo#;=#;bar=#;'\"\n"],
];
for (let [input, expected] of cases) {
assert.equal(libIni.stringify(input), expected);
}
});
});
it("should round trip object", function() {
for (let expected of [
{},
{ simple: "value" },
{ section: { foo: "false", bar: "text" }},
{ mixed: "root", section: { foo: "1", bar: "2" }, end: "value" },
{ first: { foo: "1", bar: "2" }, second: { spam: "true" }},
{ '"#;[] ': ' "#;[] ', ' #;"[ ': { '"#;[] ': ' "#;[ ' }},
]) {
let actual = libIni.parse(libIni.stringify(expected));
assert.deepEqual(actual, expected);
}
});
});
+31 -1
View File
@@ -10,7 +10,7 @@ describe("lib/plugin", function() {
describe("class BaseInstancePlugin", function() {
let instancePlugin;
it("should be constructible", async function() {
instancePlugin = new libPlugin.BaseInstancePlugin({}, { logger: new mock.MockLogger() }, {});
instancePlugin = new libPlugin.BaseInstancePlugin({}, new mock.MockInstance(), {});
await instancePlugin.init();
});
it("should define defaults for hooks", async function() {
@@ -22,6 +22,36 @@ describe("lib/plugin", function() {
instancePlugin.onMasterConnectionEvent("connect");
await instancePlugin.onPrepareMasterDisconnect();
});
describe("sendRcon", function() {
it("should send commands out of order", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" });
let a = instancePlugin.sendRcon("a");
let b = instancePlugin.sendRcon("b");
let result = await Promise.race([a, b]);
assert.equal(result, "b");
});
it("should propagate errors", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref"));
await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref"));
});
});
describe("sendOrderedRcon", function() {
it("should send commands in order", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" });
let a = instancePlugin.sendOrderedRcon("a");
let b = instancePlugin.sendOrderedRcon("b");
let result = await Promise.race([a, b]);
assert.equal(result, "a");
});
it("should propagate errors", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref"));
await assert.rejects(instancePlugin.sendOrderedRcon("a"), new Error("ref"));
});
});
});
describe("class BaseMasterPlugin", function() {
+16 -1
View File
@@ -3,6 +3,7 @@ const events = require("events");
const http = require("http");
const express = require("express");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libUsers = require("@clusterio/lib/users");
@@ -105,7 +106,20 @@ class MockServer extends events.EventEmitter {
async sendRcon(command) {
this.rconCommands.push(command);
return this.rconCommandResults.get(command) || "";
let result = this.rconCommandResults.get(command);
if (!result) {
result = { response: "" };
}
if (result instanceof Error || typeof result === "string") {
result = { response: result };
}
if (result.time) {
await libHelpers.wait(result.time);
}
if (result.response instanceof Error) {
throw result.response;
}
return result.response || "";
}
}
@@ -116,6 +130,7 @@ class MockInstance extends libLink.Link {
this.server = new MockServer();
this.name = "test";
this.id = 7357;
this.status = "running";
this.mockConfigEntries = new Map([
["instance.id", 7357],
["factorio.enable_save_patching", true],