mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3d5209eb | ||
|
|
e4eac3f9ad | ||
|
|
107284e796 | ||
|
|
bad86a5ff2 | ||
|
|
034ca4c250 | ||
|
|
2b101d594a | ||
|
|
c40362fa7f | ||
|
|
e8b515c733 | ||
|
|
e5582b5558 | ||
|
|
3b6ebeb653 | ||
|
|
bfec83996a | ||
|
|
4a72c14b74 | ||
|
|
f529457f96 | ||
|
|
642504874b | ||
|
|
d99e160a27 | ||
|
|
714e590496 | ||
|
|
bfcb57002d | ||
|
|
8b59c89322 | ||
|
|
31e9f59d8d | ||
|
|
0c74aef68e | ||
|
|
18e9fc8e7c | ||
|
|
1035389c9e |
No files matched your search
+16
-14
@@ -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.
|
||||
@@ -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<string, string>` -
|
||||
- `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<string, string>` -
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are available on the slave.
|
||||
|
||||
#### `register_control`
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"plugins/*",
|
||||
"test"
|
||||
],
|
||||
"version": "2.0.0-alpha.9"
|
||||
"version": "2.0.0-alpha.10"
|
||||
}
|
||||
@@ -927,6 +927,8 @@ class Control extends libLink.Link {
|
||||
this.keepOpen = false;
|
||||
}
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler() { }
|
||||
|
||||
async instanceUpdateEventHandler() { }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) || [],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -49,6 +49,8 @@ class TestControl extends libLink.Link {
|
||||
|
||||
async debugWsMessageEventHandler() { }
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler(message) {
|
||||
this.slaveUpdates.push(message.data);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user