Compare commits

..
Author SHA1 Message Date
Hornwitser 36865cfcf9 v2.0.0-alpha.2 2020-11-09 12:50:24 +01:00
Hornwitser d493c2d671 Merge pull request #337 from Hornwitser/alpha-2
Alpha 2 preparations
2020-11-09 12:44:59 +01:00
Hornwitser 2a0ae12829 Improve the responsiveness of the web interface
Set breakpoint for the sidebar so that it hides by default when the page
width is too small to show it with the content and keep the width of the
content constant on small screens while sliding the sidebar out to
prevent it getting crushed.
2020-11-08 20:28:48 +01:00
Hornwitser 6a4875c5ec Add bootstrap generate-user-token command
Add command to generate a user token for connecting to the master server
web interface.  This servers as an alternative to generating a control
config for use with clusterioctl.
2020-11-08 18:27:24 +01:00
Hornwitser 325c08389e Fix typo in README.md
Replace does with doesn't as the latter was intended.
2020-11-07 20:24:25 +01:00
Hornwitser bcd1290301 Move config command to lib/shared_commands
Move the definition and implementation of the config command to
lib/shared_comhmands.js, this removes the need for lib config to depend
on the fs module, which is not accessible in web build.
2020-11-07 20:23:06 +01:00
Hornwitser bd1414dd1c Remove CI failure debug code
Remove the echoing out of the test log from test/integration/server.js
used for debugging weird hang on shutdown immediately after startup that
would only happen on Travis.
2020-11-07 18:42:11 +01:00
Hornwitser d6623f89aa Run build scripts on Travis
Run the build scripts of all the packages when testing integrations on
Travis.  This should help catch errors caused by bad web interface code.
2020-11-07 18:39:56 +01:00
Hornwitser 0494858886 Move loadPluginInfos out of lib/plugin.js
Move the code for loading plugins infos from Node.js packages into a
separate module in order to break it out from lib/plugin.js which is
also used on the web interface side.  This resolves a warning of using
an expression with require on the webpack build.
2020-11-07 18:37:51 +01:00
Hornwitser df94ff1d8d Remove leftover file from web refactor
Remove wslink.jsx which became obsolete after f67f56e reimplemented the
logic.
2020-11-07 18:24:17 +01:00
Hornwitser 85111e233e Move linebreak to be after + operator 2020-11-07 18:19:50 +01:00
Hornwitser 5337280eec Remove usage of ++ outside of for loops update
The usage of postfix ++ is somewhat obscure in its meaning.  It's better
to be explicit here.
2020-11-07 18:16:24 +01:00
Hornwitser 3ce32ba870 Fix ctl slave generate-token command
Fix missing link -> libLink rename in clusterioctl slave generate-token
command as well as missing this reference.
2020-11-07 17:53:57 +01:00
Hornwitser 6cb33a8b63 Fix typo in subspace_storage config 2020-11-07 17:48:12 +01:00
Hornwitser 10cebd9e09 Fix typo in request description
Unlike HTTP the lib/link Requests can be initiated by both sides of the
connection.
2020-11-07 17:46:48 +01:00
Hornwitser 0c554cc754 Use double quotes in documentation code
Be consistent with the project code style which requires the use of
double quotes in code.
2020-11-07 17:45:24 +01:00
Hornwitser f3b918e2c2 Merge pull request #335 from Hornwitser/web-refactoring
Web Interface Rework
2020-11-07 17:42:51 +01:00
Hornwitser f67f56e6de Refactor web interface
Reimplement the pages of the web interface in order to make them
prettier and more easily amended by plugins.
2020-11-06 23:08:43 +01:00
Hornwitser 0c0626564a Add login flow
Present user with a login form to input token when connection fails or
the token is not present in local storage.  Replaces the settings page.
2020-10-29 19:11:10 +01:00
Hornwitser 019ffc25f0 Add context to webpack configs
Fix the issue with paths resolving incorrectly for the webpack config
when running the master server with the --dev option from the project
root folder through either npx clusteriomaster or node packages/master
2020-10-28 19:22:20 +01:00
Hornwitser 690e566556 Move web interface to its own package
Create clusterio/web_ui package and move all of the web interface files
to it in order to be able to share the interface components with
plugins.
2020-10-28 19:22:14 +01:00
75 changed files with 2060 additions and 1588 deletions

No files matched your search

+11 -4
View File
@@ -21,7 +21,7 @@ module.exports = {
},
},
{
"files": ["packages/master/web/**/*.jsx"],
"files": ["packages/web_ui/src/**/*.jsx", "plugins/*/web/**/*.jsx"],
"env": {
"browser": true,
},
@@ -71,7 +71,7 @@ module.exports = {
"default-case-last": "error",
"default-param-last": "error",
"dot-location": ["error", "property"],
"dot-notation": ["warn", { "allowKeywords": true }],
"dot-notation": ["warn", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
@@ -177,7 +177,7 @@ module.exports = {
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": ["warn", { "allowForLoopAfterthoughts": true }],
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "warn",
"no-proto": "error",
@@ -224,7 +224,14 @@ module.exports = {
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": "warn",
"operator-linebreak": ["error", "after", {
"overrides": {
"?": "before",
":": "before",
"||": "before",
"&&": "before",
},
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "warn",
+1 -1
View File
@@ -9,7 +9,7 @@ script:
- npx lerna bootstrap --no-ci
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --bump-patch --factorio-version 0.18 --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.1.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.1/subspace_storage_1.99.1.zip
- npx lerna run build
- npm run-script ci-cover
- cat temp/test/integration/log.txt
- codecov -f coverage/lcov.info
- npm run-script lint
+5 -5
View File
@@ -55,11 +55,11 @@ cloud storage of items via teleporter chests. But this functionality
has been pulled out of Clusterio into its own plugin for Clusterio named
[Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
By itself Clusterio does change the gameplay in any way, you could even
use Clusterio to manage completely vanilla Factorio servers. Plugins do
the work of modding in the visible changes into the game, see the
[Plugins section](#plugins) for ready made plugins you can install into
a Clusterio cluster.
By itself Clusterio doesn't change the gameplay in any way, you could
even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see
the [Plugins section](#plugins) for ready made plugins you can install
into a Clusterio cluster.
## Features
+31 -31
View File
@@ -55,7 +55,7 @@ clusterioctl. Note that it's important that the path starts with ./ or
For a plugin the most important file is the `info.js` file. Without it
the plugin will not recognized by Clusterio. Here's an example of it:
const libLink = require('@clusterio/lib/link'); // For messages
const libLink = require("@clusterio/lib/link"); // For messages
module.exports = {
name: "foo_frobber",
@@ -136,7 +136,7 @@ The plugin class should derive from its respective base class defined in
`lib/plugin`. For example, to define a MasterPlugin class the following
code can be used:
const libPlugin = require('@clusterio/lib/plugin');
const libPlugin = require("@clusterio/lib/plugin");
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
@@ -257,7 +257,7 @@ plugin you listen for an event named `ipc-channel_name` in order to get
data sent by `send_json`. For example in the plugin code:
async init() {
this.instance.server.on('ipc-my_plugin_foo', content =>
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => console.log(
"Error handling foo:", foo
))
@@ -270,10 +270,10 @@ data sent by `send_json`. For example in the plugin code:
And then in the module for the plugin:
local clusterio_api = require('modules/clusterio/api')
local clusterio_api = require("modules/clusterio/api")
-- inside some event handler
clusterio_api.send_json('my_plugin_foo', { data = 123 })
clusterio_api.send_json("my_plugin_foo", { data = 123 })
It's recommended to either use the plugin name as the channel name or to
prefix the channel name with the name of the plugin if you need multiple
@@ -311,7 +311,7 @@ and requests. Events are simple one-way notifications that invoke a
handler on the target it's sent to. Requests are pairs of request and
response messages where the request is sent to the target and the
response is the reply back from the target. The requests are similar to
HTTP requests, in that both parties of a link may initiate one.
HTTP requests, except both parties of a link may initiate one.
### Defining Events
@@ -324,9 +324,9 @@ the event, for example the following could be defined in `info.js`:
messages: {
startFrobnication: new libLink.Event({
type: 'foo_frobber:start_frobnication',
links: ['master-slave', 'slave-instance'],
forwardTo: 'instance',
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
eventProperties: {
"frobnication_type": { type: "string" },
},
@@ -351,22 +351,22 @@ appended to it.
#### links
An array of strings describing which links this event can be sent over.
Direction matters, `'master-slave'` means the event can be sent from the
Direction matters, `"master-slave"` means the event can be sent from the
master to the slave, but can't be sent back the other way, unless
`'slave-master'` is also present in the links array.
`"slave-master"` is also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and
`control`. Master talks with slave and control, and slave talks to
instance. The full chain must be specified as the individual links in
order for a message to travers multiple hops, (i.e., for a message to go
from master to instance it must have both `'master-slave'` and
`'slave-instance'` in the links array). See `forwardTo` and
from master to instance it must have both `"master-slave"` and
`"slave-instance"` in the links array). See `forwardTo` and
`broadcastTo` for ways to forward an event to the next link in a chain.
#### forwardTo
Target to forward an event to. Can either be `'master'`, to indicate a
slave should forward it to the master server, or `'instance'`, to
Target to forward an event to. Can either be `"master"`, to indicate a
slave should forward it to the master server, or `"instance"`, to
indicate it should be forwarded to the instances specified by the
`instance_id` event property. This works by using a default handler for
the event at the links that forward it.
@@ -390,7 +390,7 @@ are not allowed. See [this guide][guide] for an introduction to writing
JSON schemas.
The forwardTo and broadcastTo can be combined such that specifying
`'master'` as the forwardTo value and `'instance'` as the broadcastTo
`"master"` as the forwardTo value and `"instance"` as the broadcastTo
value will cause the event to be broadcast to all instances in the
cluster. For this to work, you will need to specify `instance-slave`,
`slave-master`, `master-slave`, and `slave-instance` as the links.
@@ -409,9 +409,9 @@ the event. For example, the following could be defined in `info.js`:
messages: {
reportFrobnication: new libLink.Request({
type: 'foo_frobber:report_frobnication',
links: ['master-slave', 'slave-instance'],
forwardTo: 'instance',
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestProperties: {
"verbosity": { type: "integer" },
},
@@ -427,7 +427,7 @@ the event. For example, the following could be defined in `info.js`:
This specifies a request that can be sent from the master to a slave,
and from a slave to an instance. The request data must contain the
property `verbosity` with an integer number as the value, as well as the
`instance_id` property (implied by `forwardTo: 'instance'`), and the
`instance_id` property (implied by `forwardTo: "instance"`), and the
response sent must contain a `report` property mapping to an array of
strings. When received by a slave, it will also be forwarded to the
instance specified by `instance_id`.
@@ -445,24 +445,24 @@ response.
#### links
An array of strings describing which links this request can be sent
over. Direction matters; `'master-slave'` means the request can be sent
over. Direction matters; `"master-slave"` means the request can be sent
from the master to the slave and the slave can reply to the master, but
the slave can't send a request to the master unless `'slave-master'` is
the slave can't send a request to the master unless `"slave-master"` is
also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and
`control`. Master talks with slave and control, and slave talks to
instance. The full chain must be specified as the individual links in
order for a message to travers multiple hops, (i.e., for a message to go
from master to instance it must have both `'master-slave'` and
`'slave-instance'` in the links array). See `forwardTo` for ways to
from master to instance it must have both `"master-slave"` and
`"slave-instance"` in the links array). See `forwardTo` for ways to
forward a request to the next link in a chain.
#### forwardTo
Target to forward the request to. Can either be `'master'` to indicate
Target to forward the request to. Can either be `"master"` to indicate
a slave should forward it to the master server when receiving it from an
instance, or `'instance'` to indicate it should be forwarded to the
instance, or `"instance"` to indicate it should be forwarded to the
instances specified by the `instance_id` request property. This works
by using a default handler for the request by the links that forward it.
@@ -557,10 +557,10 @@ from plugins optionally loaded at runtime on different computers.
In its simplest form collecting data from plugins consists of defining
the metric and updating it somewhere in the plugin code. For example:
const { Counter } = require('@clusterio/lib/prometheus');
const { Counter } = require("@clusterio/lib/prometheus");
const fooMetric = new Counter(
'clusterio_foo_frobber_foo_metric', "Measures the level of foo",
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
);
// Somewhere in the master plugin code
@@ -574,11 +574,11 @@ naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and
set it accordingly, for example:
const { Counter } = require('@clusterio/lib/prometheus');
const { Counter } = require("@clusterio/lib/prometheus");
const barMetric = new Gauge(
'clusterio_foo_frobber_bar_metric', "Bar instance level",
{ labels: ['instance_id'] }
"clusterio_foo_frobber_bar_metric", "Bar instance level",
{ labels: ["instance_id"] }
);
// Somewhere in the instance plugin code
+1 -1
View File
@@ -3,5 +3,5 @@
"packages/*",
"plugins/*"
],
"version": "2.0.0-alpha.1"
"version": "2.0.0-alpha.2"
}
+2
View File
@@ -21,6 +21,8 @@
"node": ">=10.16 <11 || >= 12"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-react": "^7.12.1",
"@clusterio/ctl": "file:./packages/ctl",
"@clusterio/lib": "file:./packages/lib",
"@clusterio/master": "file:packages/master",
+5 -4
View File
@@ -17,6 +17,7 @@ const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libConfig = require("@clusterio/lib/config");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libCommand = require("@clusterio/lib/command");
const libSharedCommands = require("@clusterio/lib/shared_commands");
@@ -73,7 +74,7 @@ slaveCommands.add(new libCommand.Command({
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true });
}],
handler: async function(args, control) {
let response = await link.messages.generateSlaveToken.send(control, { slave_id: args.id });
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: args.id });
console.log(response.token);
},
}));
@@ -594,7 +595,7 @@ async function startControl() {
type: "string",
})
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
.command("control-config", "Manage Control config", libConfig.configCommand)
.command("control-config", "Manage Control config", libSharedCommands.configCommand)
.wrap(yargs.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
@@ -628,7 +629,7 @@ async function startControl() {
rootCommands.add(debugCommands);
console.log("Loading Plugin info");
let pluginInfos = await libPlugin.loadPluginInfos(pluginList);
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
@@ -680,7 +681,7 @@ async function startControl() {
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await libConfig.handleConfigCommand(args, controlConfig, args.config);
await libSharedCommands.handleConfigCommand(args, controlConfig, args.config);
return;
}
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.1",
"@clusterio/lib": "^2.0.0-alpha.2",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
+3 -78
View File
@@ -1,8 +1,6 @@
// Core definitions for the configuration system
"use strict";
const fs = require("fs-extra");
const classes = require("./classes");
@@ -69,9 +67,9 @@ MasterGroup.define({
name: "auth_secret",
title: "Master Authentication Secret",
description:
"Secret used to generate and verify authentication tokens."
+" Should be a long string of random letters and numbers."
+" Do not share this.",
"Secret used to generate and verify authentication tokens. " +
"Should be a long string of random letters and numbers. " +
"Do not share this.",
type: "string",
optional: true,
});
@@ -379,77 +377,6 @@ function finalizeConfigs() {
ControlConfig.finalize();
}
/**
* Yargs config command
*
* Can be passed to yargs.command to implement a config command. Use
* handleConfigCommand to do the requested action.
*
* @param {Object} yargs - yargs command builder.
* @memberof module:lib/config
*/
function configCommand(yargs) {
yargs
.command("set <field> [value]", "Set config field")
.command("show <field>", "Show value of the given config field")
.command("list", "List all configuration fields and their values")
.demandCommand(1, "You need to specify a command to run")
.help()
.strict()
;
}
/**
* Handle yargs command
*
* Handle the actions that are made available by configCommand.
*
* @param {Object} args - yargs args object.
* @param {module:lib/config.Config} instance - Config instance.
* @param {string} configPath - Path to configuration file.
* @memberof module:lib/config
*/
async function handleConfigCommand(args, instance, configPath) {
let command = args._[1];
if (command === "list") {
for (let GroupClass of instance.constructor.groups.values()) {
for (let def of GroupClass.definitions.values()) {
let value = instance.get(def.fullName);
console.log(`${def.fullName} ${JSON.stringify(value)}`);
}
}
} else if (command === "show") {
try {
console.log(instance.get(args.field));
} catch (err) {
if (err instanceof classes.InvalidField) {
console.error(err.message);
} else {
throw err;
}
}
} else if (command === "set") {
if (args.value === undefined) {
args.value = null;
}
try {
instance.set(args.field, args.value);
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof classes.InvalidField || err instanceof classes.InvalidValue) {
console.error(err.message);
} else {
throw err;
}
}
}
}
module.exports = {
MasterGroup,
SlaveGroup,
@@ -464,6 +391,4 @@ module.exports = {
registerPluginConfigGroups,
finalizeConfigs,
configCommand,
handleConfigCommand,
};
+1 -1
View File
@@ -152,7 +152,7 @@ function reorderDependencies(modules) {
// No unmet dependencies, record and continue
present.set(module.name, module.version);
index++;
index += 1;
if (hold.has(module.name)) {
modules.splice(index, 0, ...hold.get(module.name));
+9 -5
View File
@@ -34,7 +34,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
let dropCount = 0;
for (let index = 0; index < this._sendBuffer.length; index++) {
if (this._sendBuffer[index].seq <= seq) {
dropCount++;
dropCount += 1;
} else {
break;
}
@@ -102,12 +102,14 @@ class WebSocketBaseConnector extends events.EventEmitter {
if (!["handshake", "connected", "closing"].includes(this._state)) {
throw new libErrors.SessionLost("No session");
}
let message = { seq: this._seq, type, data };
let seq = this._seq;
this._seq += 1;
let message = { seq, type, data };
this._sendBuffer.push(message);
if (["connected", "closing"].includes(this._state)) {
this._socket.send(JSON.stringify(message));
}
return this._seq++;
return seq;
}
/**
@@ -450,8 +452,10 @@ class VirtualConnector extends events.EventEmitter {
* @returns {number} the sequence number of the message sent
*/
send(type, data) {
this.other.emit("message", { seq: this._seq, type, data });
return this._seq++;
let seq = this._seq;
this._seq += 1;
this.other.emit("message", { seq, type, data });
return seq;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"repository": "https://github.com/clusterio/factorioClusterio",
"license": "MIT",
"keywords": [
-43
View File
@@ -3,9 +3,6 @@
* @module lib/plugin
*/
"use strict";
const path = require("path");
const libErrors = require("@clusterio/lib/errors");
/**
@@ -386,45 +383,6 @@ class BaseControlPlugin {
async addCommands(rootCommand) { }
}
/**
* Load plugin information
*
* Loads plugin info modules for the paths to the given plugins. Once
* loaded the info modules will not be reloaded should this function be
* called again.
*
* @param {Map<string, string>} pluginList -
* Mapping of plugin name to require path for the plugins to load.
* @returns {Array<Object>} Array of plugin info modules.
* @static
*/
async function loadPluginInfos(pluginList) {
let plugins = [];
for (let [pluginName, pluginPath] of pluginList) {
let pluginInfo;
let pluginPackage;
try {
pluginInfo = require(path.posix.join(pluginPath, "info"));
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
} catch (err) {
throw new libErrors.PluginError(pluginName, err);
}
if (pluginInfo.name !== pluginName) {
throw new libErrors.EnvironmentError(
`Expected plugin at ${pluginPath} to be named ${pluginName} but got ${pluginInfo.name}`
);
}
pluginInfo.requirePath = pluginPath;
pluginInfo.version = pluginPackage.version;
plugins.push(pluginInfo);
}
return plugins;
}
/**
* Attach plugin messages
*
@@ -485,7 +443,6 @@ module.exports = {
BaseMasterPlugin,
BaseControlPlugin,
loadPluginInfos,
attachPluginMessages,
invokeHook,
};
+52
View File
@@ -0,0 +1,52 @@
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
*/
"use strict";
const libErrors = require("@clusterio/lib/errors");
const path = require("path");
/**
* Load plugin information
*
* Loads plugin info modules for the paths to the given plugins. Once
* loaded the info modules will not be reloaded should this function be
* called again.
*
* @param {Map<string, string>} pluginList -
* Mapping of plugin name to require path for the plugins to load.
* @returns {Array<Object>} Array of plugin info modules.
* @static
*/
async function loadPluginInfos(pluginList) {
let plugins = [];
for (let [pluginName, pluginPath] of pluginList) {
let pluginInfo;
let pluginPackage;
try {
pluginInfo = require(path.posix.join(pluginPath, "info"));
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
} catch (err) {
throw new libErrors.PluginError(pluginName, err);
}
if (pluginInfo.name !== pluginName) {
throw new libErrors.EnvironmentError(
`Expected plugin at ${pluginPath} to be named ${pluginName} but got ${pluginInfo.name}`
);
}
pluginInfo.requirePath = pluginPath;
pluginInfo.version = pluginPackage.version;
plugins.push(pluginInfo);
}
return plugins;
}
module.exports = {
loadPluginInfos,
};
+76
View File
@@ -6,6 +6,9 @@
const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
/**
* Yargs plugin command
*
@@ -80,7 +83,80 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
}
}
/**
* Yargs config command
*
* Can be passed to yargs.command to implement a config command. Use
* handleConfigCommand to do the requested action.
*
* @param {Object} yargs - yargs command builder.
*/
function configCommand(yargs) {
yargs
.command("set <field> [value]", "Set config field")
.command("show <field>", "Show value of the given config field")
.command("list", "List all configuration fields and their values")
.demandCommand(1, "You need to specify a command to run")
.help()
.strict()
;
}
/**
* Handle yargs command
*
* Handle the actions that are made available by configCommand.
*
* @param {Object} args - yargs args object.
* @param {module:lib/config.Config} instance - Config instance.
* @param {string} configPath - Path to configuration file.
*/
async function handleConfigCommand(args, instance, configPath) {
let command = args._[1];
if (command === "list") {
for (let GroupClass of instance.constructor.groups.values()) {
for (let def of GroupClass.definitions.values()) {
let value = instance.get(def.fullName);
console.log(`${def.fullName} ${JSON.stringify(value)}`);
}
}
} else if (command === "show") {
try {
console.log(instance.get(args.field));
} catch (err) {
if (err instanceof libConfig.InvalidField) {
console.error(err.message);
} else {
throw err;
}
}
} else if (command === "set") {
if (args.value === undefined) {
args.value = null;
}
try {
instance.set(args.field, args.value);
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
console.error(err.message);
} else {
throw err;
}
}
}
}
module.exports = {
pluginCommand,
handlePluginCommand,
configCommand,
handleConfigCommand,
};
+7
View File
@@ -110,6 +110,13 @@ exist in the cluster and grant it full cluster admin permission. You
should be using your own Factorio multiplayer username here.
#### `bootstrap generate-user-token <name>`
Generate an access token for logging in and possibly managing the
cluster from the given user account. This can be used both in the web
interface and as the `master_token` to connect with clusterioctl.
#### `bootstrap create-ctl-config <name>`
Creates a clusterioctl config for the given user with url and token set
+17 -5
View File
@@ -49,6 +49,7 @@ const libSchema = require("@clusterio/lib/schema");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const libConfig = require("@clusterio/lib/config");
const libUsers = require("@clusterio/lib/users");
@@ -598,7 +599,7 @@ class ControlConnection extends BaseConnection {
}
async generateSlaveTokenRequestHandler(message) {
return { token: generateSlaveToken(message.data.slave_id) };
return { token: this.generateSlaveToken(message.data.slave_id) };
}
async createSlaveConfigRequestHandler(message) {
@@ -1429,7 +1430,8 @@ async function handleHandshake(message, socket, req, attachHandler) {
return;
}
let sessionId = nextSessionId++;
let sessionId = nextSessionId;
nextSessionId += 1;
let sessionToken = jwt.sign({ aud: masterSession, sid: sessionId }, masterConfig.get("master.auth_secret"));
let connector = new WebSocketServerConnector(socket, sessionId);
activeConnectors.set(sessionId, connector);
@@ -1572,10 +1574,11 @@ async function startServer() {
type: "string",
})
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
.command("config", "Manage Master config", libConfig.configCommand)
.command("config", "Manage Master config", libSharedCommands.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
yargs
.command("create-admin <name>", "Create a cluster admin")
.command("generate-user-token <name>", "Generate authentication token for the given user")
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
@@ -1610,7 +1613,7 @@ async function startServer() {
}
console.log("Loading Plugin info");
pluginInfos = await libPlugin.loadPluginInfos(pluginList);
pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
@@ -1639,7 +1642,7 @@ async function startServer() {
}
if (command === "config") {
await libConfig.handleConfigCommand(args, masterConfig, masterConfigPath);
await libSharedCommands.handleConfigCommand(args, masterConfig, masterConfigPath);
return;
} else if (command === "bootstrap") {
@@ -1662,6 +1665,15 @@ async function startServer() {
admin.isAdmin = true;
await saveUsers(masterConfig.get("master.database_directory"), "users.json");
} else if (subCommand === "generate-user-token") {
let user = db.users.get(args.name);
if (!user) {
console.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
console.log(user.createToken(masterConfig.get("master.auth_secret")));
} else if (subCommand === "create-ctl-config") {
let admin = db.users.get(args.name);
if (!admin) {
+4 -9
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
@@ -19,7 +19,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.1",
"@clusterio/lib": "^2.0.0-alpha.2",
"body-parser": "^1.18.2",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
@@ -35,17 +35,12 @@
"yargs": "^14.2.0"
},
"devDependencies": {
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"antd": "^4.6.5",
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^4.3.0",
"events": "^3.2.0",
"fast-deep-equal": "^3.1.3",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
-13
View File
@@ -1,13 +0,0 @@
import React from "react";
import { BrowserRouter } from "react-router-dom";
import basename from "../basename";
import Layout from "../layout";
export default function App() {
return (
<BrowserRouter basename={basename}>
<Layout />
</BrowserRouter>
);
}
-25
View File
@@ -1,25 +0,0 @@
@import '~antd/dist/antd.dark.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
[data-theme="compact"] p, p {
margin: 0;
}
.row-links{
cursor: pointer;
}
+1 -1
View File
@@ -1 +1 @@
import("./bootstrap");
import("@clusterio/web_ui/src/bootstrap");
-166
View File
@@ -1,166 +0,0 @@
import { Layout, Menu } from "antd";
import { Route, Link, withRouter } from "react-router-dom";
import React, { Component } from "react";
import { Dashboard } from "../pages/dashboard";
import { InstancesTable } from "../pages/instances/instancesTable";
import { SlavesTable } from "../pages/slaves/slavesTable";
import { UsersTable } from "../pages/users/usersTable";
import { RolesTable } from "../pages/roles/rolesTable";
import { PermissionsTable } from "../pages/permissions/permissionsTable";
import { InstanceView } from "../pages/instances/instance";
import { UserView } from "../pages/users/user";
import { RoleView } from "../pages/roles/role";
import { Settings } from "../pages/settings/settings";
const { Content, Footer, Sider } = Layout;
const { SubMenu } = Menu;
class SiteLayout extends Component {
constructor(props) {
super(props);
const { location } = props;
this.state = {
collapsed: false,
setcollapsed: false,
tags: [],
setTags: [],
location,
siteSettings: {},
factoryModel: [],
};
}
onCollapse() {
this.setState({ collapsed: !this.state.collapsed });
}
titleCase(text) {
return text.substring(0, 1).toUpperCase() + text.substring(1);
}
replaceParametersInUrl({ endpoint, params }) {
let path = endpoint;
console.log("endpoint", params);
Object.keys(params).forEach(key => {
path = path.replace(":" + key, params[key]);
});
return path;
}
render() {
return (
<Layout style={{ minHeight: "100vh" }}>
{document.location.search !== "?hideExtras" ? (
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse.bind(this)}
width={250}
breakpoint="lg"
>
<p></p>
<div style={{ width: "100%", textAlign: "center" }}>
{" "}
{this.state.collapsed ? (
<img
alt=""
src="/smal_logo.png"
style={{ height: "48px", margin: 8 }}
/>
) : (
<img
alt=""
src="/logo.png"
style={{ width: "200px", margin: 8 }}
/>
)}
</div>
<p></p>
<p></p>
<Menu
theme="dark"
defaultSelectedKeys={[
this.state.location.pathname.split("/")[
this.state.location.pathname.split("/").length - 1
],
]}
defaultOpenKeys={(() => {
let x = this.state.location.pathname.split("/");
x.shift();
return x;
})()}
mode="inline"
>
<Menu.Item key="slaves">
<span>Slaves</span>
<Link to="/slaves" />
</Menu.Item>
<Menu.Item key="instances">
<span>Instances</span>
<Link to="/instances" />
</Menu.Item>
<Menu.Item key="users">
<span>Users</span>
<Link to="/users" />
</Menu.Item>
<Menu.Item key="roles">
<span>Roles</span>
<Link to="/roles" />
</Menu.Item>
<Menu.Item key="permissions">
<span>Permissions</span>
<Link to="/permissions" />
</Menu.Item>
<Menu.Item key="settings">
<span>Settings</span>
<Link to="/settings" />
</Menu.Item>
</Menu>
</Sider>
) : (
""
)}
<Layout style={{
// background: "#F4F4F4"
}}>
<Content
style={{
margin: "24px 16px",
padding: 24,
// background: "#fff",
minHeight: 280,
}}
>
<Route exact path="/" component={Dashboard} />
<Route exact path="/instances" component={InstancesTable} />
<Route exact path="/instances/:id/view" component={InstanceView} />
<Route exact path="/slaves" component={SlavesTable} />
<Route exact path="/slaves/:id/view" component={SlavesTable} />
<Route exact path="/users" component={UsersTable} />
<Route exact path="/users/:id/view" component={UserView} />
<Route exact path="/roles" component={RolesTable} />
<Route exact path="/roles/:id/view" component={RoleView} />
<Route exact path="/permissions" component={PermissionsTable} />
<Route exact path="/permissions/:id/view" component={PermissionsTable} />
<Route exact path="/settings" component={Settings} />
</Content>
<Footer style={{
// background: "#F4F4F4",
textAlign: "center",
}}>
Clusterio 2.0 web interface https://github.com/clusterio
</Footer>
</Layout>
</Layout>
);
}
}
export default withRouter(SiteLayout);
-7
View File
@@ -1,7 +0,0 @@
import React, {Component} from "react";
export class Dashboard extends Component {
render(){
return <h2>Dashboard</h2>;
}
}
@@ -1,454 +0,0 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd";
import {
listSlaves,
listInstances,
startInstance,
stopInstance,
setInstanceOutputSubscriptions,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
sendRcon,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import EditOutlined from "@ant-design/icons/EditOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
const { Option } = Select;
export class InstanceView extends Component {
constructor(props) {
super(props);
this.state = {
instance: {},
slaves: [],
logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }],
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
let id = this.props.match.params.id;
await this.getData();
await setInstanceOutputSubscriptions({
instance_id: Number(id),
});
window.instanceOutputEventHandler = ({ instance_id, output }) => {
if (instance_id === Number(id)) {
this.setState({
logLines: [...this.state.logLines, output],
});
}
// Automatically scroll to bottom on new line.
// this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
};
}
async getData() {
let id = this.props.match.params.id;
let instances = await listInstances();
let slaves = await listSlaves();
this.setState({
instance: instances.find(x => x.id === Number(id)),
slaves,
});
let config = await getInstanceConfig({ instance_id: Number(id) });
console.log(config);
this.setState({
config,
});
}
async start() {
this.setState({
starting: true,
});
try {
await startInstance({
instance_id: this.state.instance.id,
save: null,
});
} catch (e) {
notify("Error starting instance", "error", e.message);
this.setState({
starting: false,
});
return;
}
notify("Instance started");
this.setState({
starting: false,
});
this.getData();
}
async stop() {
this.setState({
stopping: true,
});
try {
await stopInstance({
instance_id: this.state.instance.id,
});
} catch (e) {
notify("Error stopping instance", "error", e.message);
this.setState({
stopping: false,
});
return;
}
notify("Instance stopped");
this.setState({
stopping: false,
});
this.getData();
}
async createSave() {
this.setState({
creatingSave: true,
});
try {
await createSave({
instance_id: this.state.instance.id,
});
} catch (e) {
notify("Error creating save", "error", e.message);
this.setState({
creatingSave: false,
});
return;
}
notify("Save created", "success");
this.setState({
creatingSave: false,
});
}
async onConfigEditFinish(values) {
console.log("Completed config edit with", values);
for (let k in values) {
if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) {
let value = values[k];
if (typeof value === "boolean") value = value.toString();
console.log("Setting", k, value);
await setInstanceConfigField({
instance_id: this.state.instance.id,
field: k,
value: value,
});
}
}
notify("Set configuration fields", "success");
this.setState({
editingConfig: false,
});
this.getData();
}
render() {
console.log(this.state);
let { instance, slaves } = this.state;
let slave = slaves && slaves.find(x => x.id === instance.assigned_slave);
return <Card>
<h2>{instance.name}</h2>
<p>ID: {instance.id}</p>
<p>Slave: {slave && slave.name}</p>
{instance.status !== "running" && <Button
loading={this.state.starting}
type="primary"
onClick={this.start.bind(this)}
>
Start server
</Button>}
{instance.status !== "stopped" && <Button
loading={this.state.stopping}
type="primary"
onClick={this.stop.bind(this)}
>
Stop server
</Button>}
{instance.status !== "running" && <Button
loading={this.state.creatingSave}
type="primary"
onClick={this.createSave.bind(this)}
>
Create save
</Button>}
<Popover
content={
<Select style={{
width: "200px",
}}
onChange={async newSlaveId => {
let result = await assignInstance({ instance_id: instance.id, slave_id: newSlaveId });
notify("Assigned to slave", "success");
this.getData();
}}
>
{this.state.slaves.map(newSlave => <Option
defaultValue={instance.assigned_slave} value={newSlave.id} key={newSlave.id}
>
{newSlave.name}
</Option>)}
</Select>
}
trigger="click"
type={instance.status === "unassigned" ? "primary" : "default"}
>
<Button>
Assign to slave
</Button>
</Popover>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteInstance({ instance_id: instance.id });
notify("Deleted instance " + instance.id, "success");
this.navigate("/instances");
}}
>
Delete this instance permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<style>
{/* Scroll sticks to bottom of terminal window after manually scrolling to the bottom */}
{`#scroller * {
/* don't allow the children of the scrollable element to be selected as an anchor node */
overflow-anchor: none;
}
#anchor {
/* allow the final child to be selected as an anchor node */
overflow-anchor: auto;
/* anchor nodes are required to have non-zero area */
height: 1px;
}`}
</style>
<div id="scroller" style={{
height: "300px",
overflowY: "scroll",
}}>
{this.state.logLines.map(item => <p>{item.time} {item.level} {item.file}: {item.message}</p>)}
<div id="anchor" ref={this.messagesEndRef}></div>
</div>
{/* rcon input field */}
<Form
onFinish={async values => {
await sendRcon({ instance_id: instance.id, command: values.command });
}}
>
<Form.Item
label="RCON: "
name="command"
>
<Input />
</Form.Item>
</Form>
{this.state.config && this.renderConfig(this.state.config)}
</Card>;
}
getInstanceConfigAsKvObject(config) {
let object = {};
for (let group of config.serialized_config.groups) {
for (let fieldName of Object.keys(group.fields)) {
object[group.name + "." + fieldName] = group.fields[fieldName];
}
}
return object;
}
renderConfig(config) {
const layout = {
labelCol: {
span: 6,
},
wrapperCol: {
span: 14,
},
};
const tailLayout = {
wrapperCol: {
offset: 6,
span: 14,
},
};
return <Card>
<Form
{...layout}
initialValues={this.getInstanceConfigAsKvObject(config)}
onFinish={this.onConfigEditFinish.bind(this)}
>
{this.state.editingConfig ?
<Form.Item>
<Button type="primary" htmlType="submit">
Save config changes
</Button>
</Form.Item>
:
<Button onClick={() => this.setState({ editingConfig: true })}>
<EditOutlined /> Edit config
</Button>
}
<Row>
{config && config.serialized_config.groups.map(group => <>
<Col span={24} lg={12}>
<Divider
// orientation="left"
>{group.name}</Divider>
{/* {Object.keys(group.fields).map(field => <>
<span>{field}: </span>
<span>{JSON.stringify(group.fields[field])}</span>
<br />
</>)} */}
{console.log(group)}
{Object.keys(group.fields).map(field => <ConfigFormItem field={field} group={group} editingConfig={this.state.editingConfig} />)}
</Col>
</>)}
</Row>
</Form>
</Card>;
}
}
function renderDisplayField(value) {
if (typeof value === "object") {
return JSON.stringify(value);
}
if (typeof value === "boolean") {
return <Checkbox />;
}
return value.toString();
}
function renderFormField(value) {
if (typeof value === "object") {
return <ObjectInput />;
}
if (typeof value === "boolean") {
return <Checkbox />;
}
return <Input />;
}
class ObjectInput extends Component {
constructor(props) {
super(props);
console.log(this.props);
/*
value: {
auto_pause: false,
tags: ["clusterio"]
},
id: "factorio.settings",
onChange: f()
*/
this.state = {};
}
onChange(e, key) {
if (e.target.type === "checkbox") {
// if (e.target.checked !== undefined) {
// Its a checkbox, set value to true/false
let value = { ...this.props.value };
value[key] = e.target.checked || false;
this.props.onChange(value);
} else if (e.target.type === "text") {
// } else if (e.target.value !== undefined) {
// its probably a text input
let value = { ...this.props.value };
value[key] = e.target.value || "";
this.props.onChange(value);
}
}
render() {
let { value } = this.props;
let ret = [];
for (let key in value) {
if (typeof value[key] === "boolean") {
ret.push(<Checkbox
key={key}
checked={value[key]}
onChange={e => this.onChange(e, key)}
>
{key}
</Checkbox>
);
} else if (typeof value[key] === "string") {
ret.push(<div
key={key}
><span>{key}</span><Input
value={value[key]}
onChange={e => this.onChange(e, key)}
/></div>
);
}
}
ret.push(<Popover
key="addMore"
content={<>
<Input onChange={e => this.setState({ newInput: e.target.value })} />
<Button
onClick={e => {
this.onChange({ target: { value: "", type: "text" } }, this.state.newInput);
this.setState({
newInput: undefined,
addPopoverVisible: false,
});
}}
><PlusOutlined />Add property</Button>
</>}
trigger="click"
visible={this.state.addPopoverVisible}
handleVisibleChange={visible => this.setState({ addPopoverVisible: visible })}
>
<Button
onClick={e => this.setState({ addPopoverVisible: true })}
>
<PlusOutlined />Add property
</Button>
</Popover>);
return <Space direction="vertical">{ret}</Space>;
}
}
class ConfigFormItem extends Component {
render() {
let { field, group, editingConfig } = this.props;
return <Form.Item
label={field}
name={group.name + "." + field}
valuePropName={typeof group.fields[field] === "boolean" ? "checked" : "value" /* Boolean fields should be rendered as checkboxes */}
>
{editingConfig ?
renderFormField(group.fields[field])
:
renderDisplayField(group.fields[field])
}
</Form.Item>;
}
}
@@ -1,42 +0,0 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, createInstance } from "../../util/wslink";
import DataTable from "../../components/data-table";
const config = require("@clusterio/lib/config");
export class InstancesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Instances</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let instanceConfig = new config.InstanceConfig();
await instanceConfig.init();
console.log("Base config", instanceConfig);
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let response = await createInstance(serialized_config);
console.log("Created instance", response);
},
}}
DataFunction={listInstances}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/instances/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
@@ -1,26 +0,0 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listPermissions } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class PermissionsTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Permissions</h2>
<DataTable
DataFunction={listPermissions}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/permissions/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
-118
View File
@@ -1,118 +0,0 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Transfer } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import {
listSlaves,
listInstances,
startInstance,
stopInstance,
setInstanceOutputSubscriptions,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
sendRcon,
deleteUser,
listUsers,
listRoles,
setRoles,
listPermissions,
updateRole,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
const { Option } = Select;
export class RoleView extends Component {
constructor(props) {
super(props);
this.state = {
role: {},
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData();
}
async getData() {
let id = this.props.match.params.id;
let roles = await listRoles();
let permissions = await listPermissions();
this.setState({
role: roles.find(x => x.id === Number(id)),
permissions,
});
}
async handleChange(targetKeys) {
this.setState({
role: {
...this.state.role,
permissions: targetKeys,
},
});
let response = await updateRole({
...this.state.role,
permissions: targetKeys,
});
console.log("Updated user roles", response);
}
render() {
console.log(this.state);
let { role, permissions } = this.state;
return <Card>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ role: role.name });
notify("Deleted role " + role.name, "success");
this.navigate("/roles");
}}
>
Delete this role permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<h2>{role.name}</h2>
<h3>Assign roles</h3>
<style>
{`
.ant-transfer-list{
width: calc( 50% - 34px );
height: 500px;
}
`}
</style>
<Transfer
titles={["Available", "Assigned"]}
dataSource={permissions}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={role.permissions}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
</Card>;
}
}
@@ -1,39 +0,0 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class RolesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Roles</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
},{
dataIndex: "description",
title: "Description",
}],
insert: async args => {
let response = await createRole({name: args.name, description: args.description});
console.log("Created role",response);
},
}}
DataFunction={listRoles}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/roles/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
@@ -1,70 +0,0 @@
import React, { Component } from "react";
import { Card, Button, Input, Form } from "antd";
const layout = {
labelCol: {
span: 4,
},
wrapperCol: {
span: 16,
},
};
const tailLayout = {
wrapperCol: {
offset: 4,
span: 16,
},
};
export class Settings extends Component {
constructor(props) {
super(props);
this.state = {
role: {},
};
}
navigate(url) {
this.props.history.push(url);
}
render() {
console.log(this.state);
return <Card>
<h2>Web interface settings</h2>
<Form
{...layout}
name="basic"
initialValues={{
master_token: localStorage.getItem("master_token"),
}}
onFinish={values => {
console.log("Saving settings",values);
for (let key of Object.keys(values)){
localStorage.setItem(key, values[key].replace("https://", "wss://").replace("http://", "ws://"));
}
// Force refresh to apply changes
document.location = document.location;
}}
>
<Form.Item
label="User auth token"
name="master_token"
rules={[
{
required: true,
message: "Please input your user auth token from config-control.json",
},
]}
>
<Input />
</Form.Item>
<Form.Item {...tailLayout}>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Form>
</Card>;
}
}
@@ -1,26 +0,0 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class SlavesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Slaves</h2>
<DataTable
DataFunction={listSlaves}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/slaves/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
-95
View File
@@ -1,95 +0,0 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Transfer } from "antd";
import {
deleteUser,
listUsers,
listRoles,
setRoles,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
const { Option } = Select;
export class UserView extends Component {
constructor(props) {
super(props);
this.state = {
user: {},
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData();
}
async getData() {
let name = this.props.match.params.id;
let users = await listUsers();
let roles = await listRoles();
this.setState({
user: users.find(x => x.name === name),
roles,
});
}
async handleChange(targetKeys) {
this.setState({
user: {
...this.state.user,
roles: targetKeys,
},
});
let response = await setRoles({
name: this.state.user.name,
roles: targetKeys,
});
console.log("Updated user roles",response);
};
render() {
console.log(this.state);
let { user, roles } = this.state;
return <Card>
<h2>{user.name}</h2>
<h3>Assign roles</h3>
<Transfer
titles={["Available","Assigned"]}
dataSource={roles}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={user.roles}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ name: user.name });
notify("Deleted user " + user.name, "success");
this.navigate("/users");
}}
>
Delete this user from the cluster
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
</Card>;
}
}
@@ -1,36 +0,0 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class UsersTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Slaves</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let response = await createUser({name: args.name});
console.log("Created user",response);
},
}}
DataFunction={listUsers}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/users/${record.name}/view`);
},
}),
}}
/>
</Card>;
}
}
-10
View File
@@ -1,10 +0,0 @@
import { notification } from "antd";
export default function notify(message, type = "info", description = undefined) {
notification[type]({
message: typeof message === "string" ? message : "ERROR: See console",
description,
placement: "bottomRight",
});
if (typeof message !== "string") console.error(message);
}
-116
View File
@@ -1,116 +0,0 @@
import * as errors from "@clusterio/lib/errors";
import * as link from "@clusterio/lib/link";
// Note: control is created as in bootstrap.jsx as a global.
let listSlaves = createListFunction("listSlaves");
let listInstances = createListFunction("listInstances");
let listUsers = createListFunction("listUsers", "name");
let listRoles = createListFunction("listRoles");
let listPermissions = createListFunction("listPermissions", "name");
async function startInstance({ instance_id, save = null }) {
return await link.messages.startInstance.send(control, { instance_id, save });
};
async function stopInstance({ instance_id }) {
return await link.messages.stopInstance.send(control, { instance_id });
};
async function createInstance(serialized_config) {
return await link.messages.createInstance.send(control, { serialized_config });
};
async function deleteInstance({ instance_id }) {
return await link.messages.deleteInstance.send(control, { instance_id });
};
async function assignInstance({ instance_id, slave_id }) {
return await link.messages.assignInstanceCommand.send(control, {
instance_id,
slave_id,
});
};
async function createSave({ instance_id }) {
return await link.messages.createSave.send(control, { instance_id });
};
async function getInstanceConfig({ instance_id }) {
return await link.messages.getInstanceConfig.send(control, { instance_id });
};
async function setInstanceConfigField({ instance_id, field, value }) {
return await link.messages.setInstanceConfigField.send(control, {
instance_id,
field,
value: typeof value === "object" ? JSON.stringify(value) : value,
});
};
async function setInstanceConfigProp({ instance_id, field, prop, value }) {
return await link.messages.setInstanceConfigProp.send(control, {
instance_id,
field,
prop,
value, // JSON Object
});
};
async function sendRcon({ instance_id, command }) {
return await link.messages.sendRcon.send(control, { instance_id, command });
};
async function setInstanceOutputSubscriptions({ instance_id }) {
return await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instance_id] });
};
async function createUser({ name }) {
return await link.messages.createUser.send(control, { name });
};
async function deleteUser({ name }) {
return await link.messages.deleteUser.send(control, { name });
};
async function setRoles({ name, roles }) {
return await link.messages.updateUserRoles.send(control, {
name,
roles,
});
};
async function createRole({ name, description, permissions = [] }) {
return await link.messages.createRole.send(control, { name, description, permissions });
};
async function updateRole({ id, name, description, permissions }) {
return await link.messages.updateRole.send(control, {
id, name, description, permissions,
});
};
export {
listSlaves,
listInstances,
listUsers,
createUser,
deleteUser,
listRoles,
setRoles,
createRole,
updateRole,
listPermissions,
startInstance,
stopInstance,
createInstance,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
setInstanceConfigProp,
sendRcon,
setInstanceOutputSubscriptions,
};
function createListFunction(name, key) {
return async () => (
(await link.messages[name].send(control)).list.map(addKey(key || "id"))
);
}
function addKey(key) {
return function (el) {
return {
...el,
key: el[key],
};
};
}
+3 -1
View File
@@ -3,9 +3,10 @@ const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
devServer: {
contentBase: "./static",
@@ -19,6 +20,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "master",
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.1",
"@clusterio/lib": "^2.0.0-alpha.2",
"fs-extra": "^8.1.0",
"phin": "^3.4.1",
"pidusage": "^2.0.18",
+4 -3
View File
@@ -28,6 +28,7 @@ const libFileOps = require("@clusterio/lib/file_ops");
const libFactorio = require("@clusterio/lib/factorio");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libErrors = require("@clusterio/lib/errors");
const libPrometheus = require("@clusterio/lib/prometheus");
const libLuaTools = require("@clusterio/lib/lua_tools");
@@ -1394,7 +1395,7 @@ async function startSlave() {
type: "string",
})
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
.command("config", "Manage Slave config", libConfig.configCommand)
.command("config", "Manage Slave config", libSharedCommands.configCommand)
.command("run", "Run slave")
.demandCommand(1, "You need to specify a command to run")
.strict()
@@ -1419,7 +1420,7 @@ async function startSlave() {
}
console.log("Loading Plugin info");
let pluginInfos = await libPlugin.loadPluginInfos(pluginList);
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
@@ -1439,7 +1440,7 @@ async function startSlave() {
}
if (command === "config") {
await libConfig.handleConfigCommand(args, slaveConfig, args.config);
await libSharedCommands.handleConfigCommand(args, slaveConfig, args.config);
return;
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@clusterio/web_ui",
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"clusterio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@ant-design/icons": "^4.2.2",
"@clusterio/lib": "^2.0.0-alpha.2",
"antd": "^4.6.5",
"clean-webpack-plugin": "^3.0.0",
"events": "^3.2.0",
"path-browserify": "^1.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
}
File renamed without changes.
@@ -8,6 +8,7 @@ import libConfig from "@clusterio/lib/config";
import App from "./components/App";
import { Control, ControlConnector } from "./util/websocket";
async function loadScript(url) {
let script = document.createElement("script");
script.src = url;
@@ -61,17 +62,10 @@ async function load() {
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10, localStorage.getItem("master_token"));
// XXX come up with a better way of sharing this
window.control = new Control(controlConnector, []);
try {
await controlConnector.connect();
} catch (err) {
// XXX: What now?
console.log(err);
}
let controlConnector = new ControlConnector(wsUrl, 10);
let control = new Control(controlConnector, []);
ReactDOM.render(<App />, document.getElementById("root"));
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
}
load().catch(console.log);
+83
View File
@@ -0,0 +1,83 @@
import events from "events";
import React, { useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import basename from "../basename";
import SiteLayout from "./SiteLayout";
import ControlContext from "./ControlContext";
import LoginForm from "./LoginForm";
import { Card, Spin } from "antd";
export default function App(props) {
let [connected, setConnected] = useState(false);
let [token, setToken] = useState(localStorage.getItem("master_token") || null);
let connector = props.control.connector;
function clearToken() {
setToken(null);
localStorage.removeItem("master_token");
}
useEffect(() => {
function onConnect() {
localStorage.setItem("master_token", token);
setConnected(true);
}
function onClose() {
setConnected(false);
}
function onError(err) {
console.error("Unexpected error in connector", err);
clearToken();
setConnected(false);
}
connector.on("connect", onConnect);
connector.on("close", onClose);
connector.on("error", onError);
if (token && !connected) {
connector.token = token;
connector.connect();
}
return () => {
connector.off("error", onError);
connector.off("close", onClose);
connector.off("connect", onConnect);
};
});
let page;
if (connected) {
page = <SiteLayout/>;
} else if (token) {
page = <div className="login-container">
<Card>
<h1>Connecting</h1>
<Spin size="large"/>
</Card>
</div>;
} else {
page = <div className="login-container">
<Card>
<h1>Clusterio</h1>
<LoginForm setToken={setToken} />
</Card>
</div>;
}
return (
<ControlContext.Provider value={props.control}>
<BrowserRouter basename={basename}>
{page}
</BrowserRouter>
</ControlContext.Provider>
);
}
@@ -0,0 +1,3 @@
import React from "react";
export default React.createContext();
@@ -0,0 +1,604 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal,
Popconfirm, Row, Select, Space, Spin, Tree, Typography,
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
const { Title, Paragraph } = Typography;
function useInstance(id) {
let control = useContext(ControlContext);
let [instance, setInstance] = useState({ loading: true });
function updateInstance() {
if (!Number.isInteger(id)) {
setInstance({ missing: true });
return;
}
// XXX optimize by requesting only the instance in question
libLink.messages.listInstances.send(control).then(result => {
let match = result.list.find(i => i.id === id);
if (!match) {
setInstance({ missing: true });
} else {
setInstance({ ...match, present: true });
}
});
}
useEffect(() => {
updateInstance();
}, [id]);
return [instance, updateInstance];
}
function useSlave(id) {
let control = useContext(ControlContext);
let [slave, setSlave] = useState({ loading: true });
function updateSlave() {
if (!Number.isInteger(id)) {
setSlave({ missing: true });
return;
}
// XXX optimize by requesting only the slave in question
libLink.messages.listSlaves.send(control).then(result => {
let match = result.list.find(i => i.id === id);
if (!match) {
setSlave({ missing: true });
} else {
setSlave({ ...match, present: true });
}
});
}
useEffect(() => {
updateSlave();
}, [id]);
return [slave, updateSlave];
}
function formatOutputColored(output, key) {
let time = "";
if (output.format === "seconds") {
time = <span className="factorio-time">{output.time.padStart(8)} </span>;
} else if (output.format === "date") {
time = <span className="factorio-time">{output.time} </span>;
}
let info = "";
if (output.type === "log") {
let level = output.level;
if (level === "Info") {
level = <span className="factorio-info">{level}</span>;
} else if (output.level === "Warning") {
level = <span className="factorio-warning">{level}</span>;
} else if (output.level === "Error") {
level = <span className="factorio-error">{level}</span>;
}
info = <>{level} <span className="factorio-filename">{output.file}: </span></>;
} else if (output.type === "action") {
info = <>[<span className="factorio-action">{output.action}</span>] </>;
}
return <span key={key}>{time}{info}{output.message}<br/></span>;
}
function InstanceConsole(props) {
let control = useContext(ControlContext);
let [lines, setLines] = useState([{ message: "<previous messages are not shown>" }]);
useEffect(() => {
function outputHandler(output) {
setLines(currentLines => currentLines.concat([output]));
}
control.onInstanceOutput(props.id, outputHandler);
return () => {
control.offInstanceOutput(props.id, outputHandler);
};
}, [props.id]);
return <>
<Paragraph code className="instance-console">
{lines.map(formatOutputColored)}
<div className="scroll-anchor" />
</Paragraph>
</>;
}
function InstanceRcon(props) {
let control = useContext(ControlContext);
let [output, setOutput] = useState(null);
let [running, setRunning] = useState(false);
async function sendCommand(command) {
if (!command) {
setOutput(null);
return;
}
setRunning(true);
try {
let result = await libLink.messages.sendRcon.send(control, {
instance_id: props.id,
command: command,
});
setOutput(result.result);
} finally {
setRunning(false);
}
}
return <>
{output && <>
<Title level={5}>Rcon result</Title>
<Paragraph code className="rcon-result">{output}</Paragraph>
</>}
<Input.Search
disabled={props.disabled}
placeholder="Send RCON Command"
enterButton="Send"
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
loading={running}
/>
</>;
}
function AssignInstanceModal(props) {
let [visible, setVisible] = useState(false);
let [slaves, setSlaves] = useState([]);
let [loading, setLoading] = useState(true);
let [applying, setApplying] = useState(false);
let [form] = Form.useForm();
let control = useContext(ControlContext);
function open() {
setVisible(true);
libLink.messages.listSlaves.send(control).then((result) => {
setSlaves(result.list);
setLoading(false);
});
}
function handleAssign() {
let slaveId = form.getFieldValue("slave");
if (slaveId === undefined) {
setVisible(false);
return;
}
setApplying(true);
libLink.messages.assignInstanceCommand.send(control, {
instance_id: props.id,
slave_id: slaveId,
}).then(() => {
setVisible(false);
if (props.onFinish) {
props.onFinish();
}
}).catch(
notifyErrorHandler("Error assigning instance")
).finally(
() => setApplying(false)
);
}
function handleCancel() {
setVisible(false);
}
return <>
<Button {...props.buttonProps} onClick={open}>
{props.buttonContent || "Assign"}
</Button>
<Modal
title="Assign Instance"
okText="Assign"
visible={visible}
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnClose
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Slave to assign this instance to. Assignment
creates the necessary files on the slave to start up
the instance. Note that reassigning an instance from
one slave to another will not move the server save over.
</Paragraph>
<Form form={form} initialValues={{ slave: props.slaveId }}>
<Form.Item name="slave" label="Slave">
<Select loading={loading}>
{slaves.map((slave) => <Select.Option
key={slave["id"]}
value={slave["id"]}
disabled={!slave["connected"]}
>
{slave["name"]}
{!slave["connected"] && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
</Form>
</Modal>
</>;
}
function StartStopInstanceButton(props) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
function onClick() {
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
action = libLink.messages.startInstance.send(
control, { instance_id: props.instance["id"], save: null }
).catch(
notifyErrorHandler("Error starting instance")
);
} else if (["starting", "running"].includes(props.instance["status"])) {
action = libLink.messages.stopInstance.send(
control, { instance_id: props.instance["id"] }
).catch(
notifyErrorHandler("Error stopping instance")
);
} else {
setSwitching(false);
return;
}
action.finally(() => {
setSwitching(false);
if (props.onFinish) {
props.onFinish();
}
});
}
return <Button
{...(props.buttonProps || {})}
loading={switching}
type="primary"
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
onClick={onClick}
>
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
</Button>;
}
function InstanceConfigTree(props) {
let control = useContext(ControlContext);
let [config, setConfig] = useState(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState(new Set());
let [errorFields, setErrorFields] = useState(new Map());
let [applying, setApplying] = useState(false);
async function updateConfig() {
let result = await libLink.messages.getInstanceConfig.send(control, {
instance_id: props.id,
});
let newConfig = new libConfig.InstanceConfig();
await newConfig.load(result.serialized_config);
setConfig(newConfig);
}
useEffect(() => { updateConfig(); }, [props.id]);
function renderInput(group, def) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
if (config === null) {
return <Spin/>;
}
let initialValues = {};
let treeData = [];
let propsMap = new Map();
for (let [name, GroupClass] of libConfig.InstanceConfig.groups) {
let group = config.group(name);
let treeNode = {
key: group.name,
title: group.name,
children: [],
};
for (let def of GroupClass.definitions.values()) {
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
let childNode = {
key: fieldName,
children: [],
};
if (def.type === "object") {
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
childNode.children.push({
key: propPath,
title: <Form.Item
name={propPath}
label={`"${prop}"`}
rules={[{ validator: (_, fieldValue) => {
try {
JSON.parse(fieldValue);
} catch (err) {
return Promise.reject(new Error("Must be valid json"));
}
return Promise.resolve();
}}]}
>
<Input className="json-input" />
</Form.Item>,
});
initialValues[propPath] = JSON.stringify(value[prop]);
propsMap.set(propPath, [fieldName, prop]);
}
} else {
childNode.title = <Form.Item
name={fieldName}
label={def.title || def.name}
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
valuePropName={def.type === "boolean" ? "checked" : "value"}
>
{renderInput(group, def)}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
function onValuesChange(changedValues) {
let newChangedFields = new Set(changedFields);
let changed = false;
for (let [key, value] of Object.entries(changedValues)) {
if (initialValues[key] === value) {
changed = newChangedFields.delete(key) || changed;
} else {
changed = !newChangedFields.has(key) || changed;
newChangedFields.add(key);
}
}
if (changed) {
setChangedFields(newChangedFields);
}
}
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
try {
value = JSON.parse(value);
} catch (err) {
continue;
}
request = libLink.messages.setInstanceConfigProp.send(control, {
instance_id: props.id,
field: fieldName,
prop,
value,
});
} else {
request = libLink.messages.setInstanceConfigField.send(control, {
instance_id: props.id,
field,
value: String(value),
});
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<Form
size="small"
form={form}
initialValues={initialValues}
onValuesChange={onValuesChange}
>
<Tree
treeData={treeData}
selectable={false}
defaultExpandAll={true}
filterTreeNode={(node) => changedFields.has(node.key)}
/>
</Form>
</Card>
</>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
let history = useHistory();
let control = useContext(ControlContext);
let [instance, updateInstance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
let [creatingSave, setCreatingSave] = useState(false);
let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }];
if (instance.loading) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing) {
return <PageLayout nav={nav}>
<h2>Instance not found</h2>
<p>Instance with id {instanceId} was not found on the master server.</p>
</PageLayout>;
}
let instanceButtons = <Space>
<StartStopInstanceButton
instance={instance}
onFinish={updateInstance}
/>
{instance.status === "stopped" && <Button
loading={creatingSave}
onClick={() => {
setCreatingSave(true);
libLink.messages.createSave.send(
control, { instance_id: instanceId }
).catch(notifyErrorHandler("Error creating save")).finally(() => {
updateInstance();
setCreatingSave(false);
});
}}
>
Create save
</Button>}
<Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
}}
>
<Button
danger
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
>
<DeleteOutlined />
</Button>
</Popconfirm>
</Space>;
let assigned = instance["assigned_slave"] !== null;
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={instance["name"]}
extra={instanceButtons}
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
<AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
onFinish={updateInstance}
/>
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
<InstanceConfigTree id={instanceId} onApply={updateInstance} />
</PageLayout>;
}
@@ -0,0 +1,55 @@
import React, { useContext } from "react";
import { useHistory } from "react-router-dom";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function InstancesPage() {
let control = useContext(ControlContext);
let history = useHistory();
async function listInstances() {
let result = await libLink.messages.listInstances.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Assigned Slave": item["assigned_slave"],
"Status": item["status"],
}));
}
return <PageLayout nav={[{ name: "Instances" }]}>
<h2>Instances</h2>
<DataTable
DataFunction={listInstances}
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let instanceConfig = new libConfig.InstanceConfig();
await instanceConfig.init();
console.log("Base config", instanceConfig);
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let response = await libLink.messages.createInstance.send(control, { serialized_config });
console.log("Created instance", response);
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/instances/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}
@@ -0,0 +1,27 @@
import React from "react";
import { Form, Input, Button } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
export default function LoginForm(props) {
return (
<Form
name="login"
onFinish={(values) => { props.setToken(values.token); }}
>
<Form.Item
name="token"
rules={[
{ required: true, message: "Please input authentication token" },
]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Authentication Token"
/>
</Form.Item>
<Button type="primary" htmlType="submit">Log in</Button>
</Form>
);
}
@@ -0,0 +1,18 @@
import React from "react";
import { Link } from "react-router-dom";
import { Layout, Menu, Breadcrumb, Typography } from "antd";
const { Content } = Layout;
export default function PageLayout(props) {
return <>
<Breadcrumb className="site-breadcrumb">
{props.nav.map((part, index) => <Breadcrumb.Item key={index}>
{part.path ? <Link to={part.path}>{part.name}</Link> : part.name}
</Breadcrumb.Item>)}
</Breadcrumb>
<Content className="site-layout-content" >
{props.children}
</Content>
</>;
}
@@ -0,0 +1,151 @@
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 DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import libUsers from "@clusterio/lib/users";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
function useRole(id) {
let control = useContext(ControlContext);
let [role, setRole] = useState({ loading: true });
function updateRole() {
// XXX optimize by requesting only the role in question
libLink.messages.listRoles.send(control).then(result => {
let match = result.list.find(u => u.id === id);
if (!match) {
setRole({ missing: true });
} else {
setRole({
...match,
present: true,
});
}
});
}
useEffect(() => {
updateRole();
}, [id]);
return [role, updateRole];
}
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
let history = useHistory();
let control = useContext(ControlContext);
let [role, updateRole] = useRole(roleId);
let [edited, setEdited] = useState(false);
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
if (role.loading) {
return <PageLayout nav={nav}>
<h2>{roleId}</h2>
<Spin size="large" />
</PageLayout>;
}
if (role.missing) {
return <PageLayout nav={nav}>
<h2>Role not found</h2>
<p>Role with id {roleId} was not found on the master server.</p>
</PageLayout>;
}
let initialValues = {
name: role["name"],
description: role["description"],
permissions: {
...Object.fromEntries([...libUsers.permissions.values()].map(perm => [
perm.name, role["permissions"].includes(perm.name),
])),
},
};
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
console.log(values);
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
}
libLink.messages.updateRole.send(control, {
id: roleId,
name: values.name || "",
description: values.description || "",
permissions: newPermissions,
}).then(() => {
setEdited(false);
updateRole();
}).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
</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>
<Form.Item name="name" label="Name">
<Input/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
</Form.Item>
<h3>Permissions</h3>
{[...libUsers.permissions.values()].map(({name, title, description}) => (
<Form.Item
name={["permissions", name]}
key={name}
label={title}
tooltip={description}
valuePropName="checked"
labelCol={{ sm: { span: 22, push: 2 }}}
wrapperCol={{ sm: { span: 2, pull: 22 }}}
labelAlign="left"
colon={false}
>
<Checkbox/>
</Form.Item>
))}
</Form>
</PageLayout>;
}
@@ -0,0 +1,51 @@
import React, { useContext } from "react";
import { useHistory } from "react-router-dom";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function UsersPage() {
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"],
}));
}
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: [],
});
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/roles/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}
@@ -0,0 +1,86 @@
import React, { useEffect, useState } from "react";
import { Switch, Route, useHistory } from "react-router-dom";
import { Layout, Menu } from "antd";
import webUiPackage from "../../package.json";
const { Header, Sider } = Layout;
import SlavesPage from "./SlavesPage";
import InstancesPage from "./InstancesPage";
import InstanceViewPage from "./InstanceViewPage";
import UsersPage from "./UsersPage";
import UserViewPage from "./UserViewPage";
import RolesPage from "./RolesPage";
import RoleViewPage from "./RoleViewPage";
const pages = [
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
];
const sidebar = [
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
{ name: "Instances", path: "/instances", content: <InstancesPage />},
{ name: "Users", path: "/users", content: <UsersPage /> },
{ name: "Roles", path: "/roles", content: <RolesPage /> },
];
export default function SiteLayout(props) {
let history = useHistory();
let [sidebarPath, setSidebarPath] = useState(null);
function SetSidebar(setSidebarProps) {
useEffect(() => {
setSidebarPath(setSidebarProps.path);
});
return null;
}
return <Layout style={{ minHeight: "100vh" }}>
<Header className="header">
<div className="site-logo" />
<span className="site-name" style={{ top: -10 }}>Clusterio</span>
<span style={{ position: "absolute", top: 10, left: 120, color: "#f00" }}>{ webUiPackage.version }</span>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
<Menu.Item key="1">Dashboard</Menu.Item>
</Menu>
</Header>
<Layout className="site-layout">
<Sider
collapsible
collapsedWidth={0}
breakpoint="md"
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
<Menu
mode="inline"
selectedKeys={[sidebarPath]}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{sidebar.map(({name, path}) => <Menu.Item key={path}>{name}</Menu.Item>)}
</Menu>
</Sider>
<Layout className="site-layout-content-container">
<Switch>
{sidebar.map(({path, content}) => <Route exact path={path} key={path}>
<SetSidebar path={path} />
{content}
</Route>)}
{pages.map(page => <Route exact path={page.path} key={page.path}>
<SetSidebar path={page.sidebarPath} />
{page.content}
</Route>)}
<Route>
<SetSidebar path={null} />
</Route>
</Switch>
</Layout>
</Layout>
</Layout>;
}
@@ -0,0 +1,28 @@
import React, { useContext } from "react";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function SlavesPage() {
let control = useContext(ControlContext);
async function listSlaves() {
let result = await libLink.messages.listSlaves.send(control);
return result["list"].map((item) => ({
key: item["id"],
"Name": item["name"],
"Agent": item["agent"],
"Version": item["version"],
"Connected": item["connected"] && "Yes",
}));
}
return <PageLayout nav={[{ name: "Slaves" }]}>
<h2>Slaves</h2>
<DataTable DataFunction={listSlaves} />
</PageLayout>;
};
@@ -0,0 +1,246 @@
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 DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
function useUser(name) {
let control = useContext(ControlContext);
let [user, setUser] = useState({ loading: true });
function updateUser() {
// XXX optimize by requesting only the user in question
libLink.messages.listUsers.send(control).then(result => {
let match = result.list.find(u => u.name === name);
if (!match) {
setUser({ missing: true });
} else {
setUser({ ...match, present: true });
}
});
}
useEffect(() => {
updateUser();
}, [name]);
return [user, updateUser];
}
export default function UserViewPage() {
let params = useParams();
let userName = params.name;
let history = useHistory();
let control = useContext(ControlContext);
let [user, updateUser] = useUser(userName);
let [roles, setRoles] = useState(null);
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState(false);
let [applyingRoles, setApplyingRoles] = useState(false);
let [rolesError, setRolesError] = useState();
let [banUserPopover, setBanUserPopover] = useState(false);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
});
}, []);
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (user.loading) {
return <PageLayout nav={nav}>
<h2>{userName}</h2>
<Spin size="large" />
</PageLayout>;
}
if (user.missing) {
return <PageLayout nav={nav}>
<h2>User not found</h2>
<p>User with name {userName} was not found on the master server.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<Row>
<Col flex="auto">
<h2>{userName}</h2>
</Col>
<Col flex="0 0 auto">
<Popconfirm
title={<>
Delete user account and all data associated with it?
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
</>}
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteUser.send(
control, { name: userName }
).then(() => {
history.push("/users");
}).catch(notifyErrorHandler("Error deleting user"));
}}
>
<Button danger >
<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>
</Col>
<Col flex="0 0 auto">
<Button
type={"primary"}
disabled={!rolesDirty}
loading={applyingRoles}
onClick={() => {
let newRoles = form.getFieldValue("roles");
setApplyingRoles(true);
libLink.messages.updateUserRoles.send(control, {
name: userName,
roles: newRoles,
}).then(() => {
setRolesDirty(false);
setRolesError();
}).catch(err => {
setRolesError(err.message);
return form.validateFields();
}).finally(() => {
setApplyingRoles(false);
});
}}
>Apply</Button>
</Col>
</Row>
</Form.Item>
<Form.Item label="In-game Admin">
<Switch
checked={user["is_admin"]}
onClick={() => {
libLink.messages.setUserAdmin.send(control, {
name: userName,
admin: !user["is_admin"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user admin status"));
}}
/>
</Form.Item>
<Form.Item label="Whitelisted">
<Switch
checked={user["is_whitelisted"]}
onClick={() => {
libLink.messages.setUserWhitelisted.send(control, {
name: userName,
whitelisted: !user["is_whitelisted"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user whitelisted status"));
}}
/>
</Form.Item>
<Form.Item label="Banned">
<Space>
{user["is_banned"] ? "Yes" : "No"}
<Popover
title="Ban user"
visible={banUserPopover}
trigger="click"
onVisibleChange={() => {
if (!user["is_banned"]) {
setBanUserPopover(!banUserPopover);
}
}}
content=<Form.Item label="Reason">
<Row gutter={8}>
<Col flex="auto">
<Form.Item noStyle name="reason">
<Input/>
</Form.Item>
</Col>
<Col flex="0 0 auto">
<Button
type="primary"
onClick={() => {
let reason = form.getFieldValue("reason");
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: true,
reason,
}).then(() => {
setBanUserPopover(false);
updateUser();
}).catch(notifyErrorHandler("Error banning user"));
}}
>Ban</Button>
</Col>
</Row>
</Form.Item>
>
<Button
type="primary"
size="small"
onClick={() => {
if (user["is_banned"]) {
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: false,
reason: "",
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error pardoning user"));
}
}}
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
</Popover>
</Space>
</Form.Item>
</Form>
</PageLayout>;
}
@@ -0,0 +1,69 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Spin, Tag } from "antd";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function UsersPage() {
let control = useContext(ControlContext);
let history = useHistory();
let [roles, setRoles] = useState(null);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
});
}, []);
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",
title: "Name",
}],
insert: async args => {
await libLink.messages.createUser.send(control, { name: args.name });
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/users/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}
File renamed without changes.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+154
View File
@@ -0,0 +1,154 @@
@import '~antd/dist/antd.dark.css';
body {
margin: 0;
}
[data-theme="compact"] p, p {
margin: 0;
}
.row-links{
cursor: pointer;
}
.login-container {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.site-layout-content {
background: #141414;
padding: 24px;
margin: 0;
flex-grow: 0;
}
.site-layout {
overflow-x: hidden;
}
.site-layout-content-container {
min-width: 100%;
}
.site-breadcrumb {
margin: 16px 0 16px 52px;
}
@media (min-width: 576px) {
.site-layout-content-container {
padding: 0 24px 24px;
}
.site-breadcrumb {
margin-left: 28px;
}
}
@media (min-width: 768px) {
.site-layout {
overflow-x: initial;
}
.site-layout-content-container {
min-width: initial;
}
}
.site-layout-sider {
z-index: 0;
}
.header {
padding-left: 250px;
}
.site-name {
position: absolute;
left: 112px;
font-size: 18pt;
font-weight: 400;
}
.site-logo {
position: absolute;
left: 36px;
width: 64px;
height: 64px;
background-image: url("./images/logo.png");
background-size: 64px;
}
.rcon-result code {
display: block;
background: #000;
border-color: #252525;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}
/* see https://blog.eqrion.net/pin-to-bottom/ for how scroll anchors works */
.instance-console code * {
overflow-anchor: none;
}
.instance-console code .scroll-anchor {
overflow-anchor: auto;
height: 1px;
}
.instance-console code {
display: block;
margin: 0;
background: #000;
border-color: #252525;
height: 300px;
overflow-y: scroll;
}
.instance-console .factorio-time {
color: #afaf1f;
}
.instance-console .factorio-filename {
color: #808080;
}
.instance-console .factorio-info {
font-weight: bold;
color: #4646ea;
}
.instance-console .factorio-warning {
font-weight: bold;
color: #afaf1f;
}
.instance-console .factorio-error {
font-weight: bold;
color: #ad3939;
}
.instance-console .factorio-action {
color: #afaf1f;
}
.config-container {
background-color: #111111;
}
.config-container .ant-form-item {
margin: 0;
}
.config-container .filter-node .ant-tree-node-content-wrapper {
background: #2a1912;
}
.json-input {
font-family: monospace;
}
+33
View File
@@ -0,0 +1,33 @@
import { notification } from "antd";
export default function notify(message, type = "info", description = undefined) {
notification[type]({
message: typeof message === "string" ? message : "ERROR: See console",
description,
placement: "bottomRight",
});
if (typeof message !== "string") console.error(message);
}
/**
* Promise rejection handler notifying the user
*
* Shows the error occuring in a notification card.
*
* @param {string} message -
* User facing message to give context to the error. Should say which
* operation failed.
* @returns {function(err)} function showing the error passed to it.
*/
export function notifyErrorHandler(message) {
return function handler(err) {
console.error(err);
notification.error({
message,
description: err.message,
duration: 0,
placement: "bottomRight",
});
};
}
@@ -1,52 +1,29 @@
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
const version = require("../../package.json").version;
/**
* Format a parsed Factorio output message
*
* Formats a parsed Factorio output from lib/factorio back into the
* text string it was parsed from.
*
* @param {Object} output - Factorio server output.
* @returns {string} origial output text.
* @private
*/
function formatOutput(output) {
let time = "";
if (output.format === "seconds") {
time = `${output.time.padStart(8)} `;
} else if (output.format === "date") {
time = `${output.time} `;
}
let info = "";
if (output.type === "log") {
info = `${output.level} ${output.file}: `;
} else if (output.type === "action") {
info = `[${output.action}] `;
}
return `${time}${info}${output.message}`;
}
/**
* Connector for control connection to master server
* @private
*/
export class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
constructor(url, reconnectDelay) {
super(url, reconnectDelay);
this._token = token;
this.token = null;
}
register() {
if (!this.token) {
throw new Error("Token not set");
}
console.log("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "web",
version: "2.0.0-alpha",
token: this.token,
agent: "Clusterio Web UI",
version,
});
}
}
@@ -70,16 +47,59 @@ export class Control extends libLink.Link {
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
}
this.instanceOutputHandlers = new Map();
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutput(output));
if (window.instanceOutputEventHandler) {
window.instanceOutputEventHandler({instance_id, output});
let handlers = this.instanceOutputHandlers.get(instance_id);
for (let handler of handlers || []) {
handler(output);
}
}
async onInstanceOutput(id, handler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.instanceOutputHandlers.get(id);
if (!handlers) {
handlers = [];
this.instanceOutputHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateInstanceOutputSubscriptions();
}
}
async offInstanceOutput(id, handler) {
let handlers = this.instanceOutputHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for instance ${id} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
await this.updateInstanceOutputSubscriptions();
}
}
async updateInstanceOutputSubscriptions() {
await libLink.messages.setInstanceOutputSubscriptions.send(this, {
instance_ids: [...this.instanceOutputHandlers.keys()],
});
}
async debugWsMessageEventHandler(message) {
console.log("WS", message.data.direction, message.data.content);
}
@@ -95,6 +115,6 @@ export class Control extends libLink.Link {
}
}
await this.connector.close(1001, "Control Quit");
await this.connector.close(1000, "Control Quit");
}
}
@@ -29,6 +29,10 @@ module.exports = (env = {}) => ({
test: /node_modules.fs-extra/,
use: "null-loader",
},
{
test: /node_modules.jsonwebtoken/,
use: "null-loader",
},
{
test: /\.css$/,
use: [
@@ -59,6 +63,10 @@ module.exports = (env = {}) => ({
},
},
},
{
test: /\.png$/,
use: "file-loader",
},
],
},
resolve: {
+3 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -17,14 +17,10 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"@clusterio/master": "^2.0.0-alpha.1",
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"events": "^3.2.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
+2 -2
View File
@@ -46,7 +46,7 @@ describe("global_chat plugin", function() {
instancePlugin.instance.connector.sentMessages = [];
await instancePlugin.onOutput(output);
assert(instancePlugin.instance.connector.sentMessages.length, "message was not sent");
count++;
count += 1;
}
}
assert(count > 0, "no lines were tested");
@@ -58,7 +58,7 @@ describe("global_chat plugin", function() {
instancePlugin.instance.connector.sentMessages = [];
await instancePlugin.onOutput(output);
assert(!instancePlugin.instance.connector.sentMessages.length, "message was sent");
count++;
count += 1;
}
}
assert(count > 0, "no lines were tested");
+3 -1
View File
@@ -3,9 +3,10 @@ const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/master/webpack.common");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
+3 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -20,14 +20,10 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"@clusterio/master": "^2.0.0-alpha.1",
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"events": "^3.2.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
+3 -1
View File
@@ -3,9 +3,10 @@ const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/master/webpack.common");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
+3 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -17,14 +17,10 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"@clusterio/master": "^2.0.0-alpha.1",
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"events": "^3.2.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
@@ -3,9 +3,10 @@ const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/master/webpack.common");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
+2 -2
View File
@@ -21,7 +21,7 @@ MasterConfigGroup.define({
});
MasterConfigGroup.define({
name: "log_item_transfers",
title: "Log Item Trassfers",
title: "Log Item Transfers",
description: "Spam master console with item transfers done.",
type: "boolean",
initial_value: false,
@@ -32,7 +32,7 @@ class InstanceConfigGroup extends libConfig.PluginConfigGroup {}
InstanceConfigGroup.groupName = "subspace_storage";
InstanceConfigGroup.define({
name: "log_item_transfers",
title: "Log Item Trassfers",
title: "Log Item Transfers",
description: "Spam slave console with item transfers done.",
type: "boolean",
initial_value: false,
+3 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.1",
"version": "2.0.0-alpha.2",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,15 +20,11 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"@clusterio/master": "^2.0.0-alpha.1",
"@clusterio/web_ui": "^2.0.0-alpha.2",
"antd": "^4.6.5",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"events": "^3.2.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"webpack-cli": "^3.3.12",
+3 -1
View File
@@ -3,9 +3,10 @@ const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/master/webpack.common");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
"antd": { import: false },
"react": { import: false },
+20 -1
View File
@@ -5,7 +5,7 @@ const path = require("path");
const libLink = require("@clusterio/lib/link");
const { slowTest, get, execCtl, sendRcon, getControl, instancesDir } = require("./index");
const { slowTest, get, exec, execCtl, sendRcon, getControl, instancesDir } = require("./index");
async function getInstances() {
@@ -19,12 +19,31 @@ async function checkInstanceStatus(id, status) {
}
describe("Integration of Clusterio", function() {
describe("clusteriomaster", function() {
describe("bootstrap generate-user-token", function() {
it("work for existing user", async function() {
await exec("node ../../packages/master bootstrap generate-user-token test");
});
it("fails if user does not exist", async function() {
await assert.rejects(
exec("node ../../packages/master bootstrap generate-user-token invalid")
);
});
});
});
describe("clusterioctl", function() {
describe("slave list", function() {
it("runs", async function() {
await execCtl("slave list");
});
});
describe("slave generate-token", function() {
it("runs", async function() {
await execCtl("slave generate-token --id 42");
});
});
describe("instance list", function() {
it("runs", async function() {
await execCtl("instance list");
+1 -1
View File
@@ -24,7 +24,7 @@ describe("Integration of lib/factorio/export", function() {
// Value is always a string, nearly always truthy
if (!value) {
empty_keys++;
empty_keys += 1;
}
assert.equal(typeof value, "string");
}
+1 -1
View File
@@ -199,7 +199,7 @@ describe("lib/factorio", function() {
} catch (err) { }
if (success) {
validate(modules);
successCount++;
successCount += 1;
}
}
-52
View File
@@ -1,7 +1,5 @@
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const mock = require("../mock");
const libLink = require("@clusterio/lib/link");
@@ -41,56 +39,6 @@ describe("lib/plugin", function() {
});
});
describe("loadPluginInfos()", function() {
let baseDir = path.join("temp", "test", "plugin");
let missingPlugin = path.join(baseDir, "missing_plugin");
let testPlugin = path.join(baseDir, "test_plugin");
let brokenPlugin = path.join(baseDir, "broken_plugin");
let invalidPlugin = path.join(baseDir, "invalid_plugin");
before(async function() {
async function writePlugin(pluginPath, infoName) {
await fs.outputFile(
path.join(pluginPath, "info.js"),
`module.exports = { name: "${infoName}" };`
);
await fs.outputFile(
path.join(pluginPath, "package.json"),
'{ "version": "0.0.1" }'
);
}
await writePlugin(testPlugin, "test");
await writePlugin(brokenPlugin, "broken");
await fs.outputFile(path.join(brokenPlugin, "info.js"), "Syntax Error");
await writePlugin(invalidPlugin, "wrong");
});
it("should throw on missing plugin", async function() {
await assert.rejects(
libPlugin.loadPluginInfos(new Map([["missing", missingPlugin]]), []),
new RegExp(`^Error: PluginError: Cannot find module '${missingPlugin}/info'`)
);
});
it("should load test plugin", async function() {
assert.deepEqual(
await libPlugin.loadPluginInfos(new Map([["test", path.resolve(testPlugin)]])),
[{ name: "test", version: "0.0.1", requirePath: path.resolve(testPlugin) }]
);
});
it("should reject on broken plugin", async function() {
await assert.rejects(
libPlugin.loadPluginInfos(new Map([["broken", path.resolve(brokenPlugin)]])),
{ message: "PluginError: Unexpected identifier" }
);
});
it("should reject on invalid plugin", async function() {
await assert.rejects(
libPlugin.loadPluginInfos(new Map([["invalid", path.resolve(invalidPlugin)]])),
{ message: `Expected plugin at ${path.resolve(invalidPlugin)} to be named invalid but got wrong` }
);
});
});
describe("attachPluginMessages()", function() {
let mockLink = new libLink.Link("source", "target", new mock.MockConnector());
let mockEvent = new libLink.Event({ type: "test:test", links: ["target-source"] });
+59
View File
@@ -0,0 +1,59 @@
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
describe("lib/plugin_loader", function() {
describe("loadPluginInfos()", function() {
let baseDir = path.join("temp", "test", "plugin");
let missingPlugin = path.join(baseDir, "missing_plugin");
let testPlugin = path.join(baseDir, "test_plugin");
let brokenPlugin = path.join(baseDir, "broken_plugin");
let invalidPlugin = path.join(baseDir, "invalid_plugin");
before(async function() {
async function writePlugin(pluginPath, infoName) {
await fs.outputFile(
path.join(pluginPath, "info.js"),
`module.exports = { name: "${infoName}" };`
);
await fs.outputFile(
path.join(pluginPath, "package.json"),
'{ "version": "0.0.1" }'
);
}
await writePlugin(testPlugin, "test");
await writePlugin(brokenPlugin, "broken");
await fs.outputFile(path.join(brokenPlugin, "info.js"), "Syntax Error");
await writePlugin(invalidPlugin, "wrong");
});
it("should throw on missing plugin", async function() {
await assert.rejects(
libPluginLoader.loadPluginInfos(new Map([["missing", missingPlugin]]), []),
new RegExp(`^Error: PluginError: Cannot find module '${missingPlugin}/info'`)
);
});
it("should load test plugin", async function() {
assert.deepEqual(
await libPluginLoader.loadPluginInfos(new Map([["test", path.resolve(testPlugin)]])),
[{ name: "test", version: "0.0.1", requirePath: path.resolve(testPlugin) }]
);
});
it("should reject on broken plugin", async function() {
await assert.rejects(
libPluginLoader.loadPluginInfos(new Map([["broken", path.resolve(brokenPlugin)]])),
{ message: "PluginError: Unexpected identifier" }
);
});
it("should reject on invalid plugin", async function() {
await assert.rejects(
libPluginLoader.loadPluginInfos(new Map([["invalid", path.resolve(invalidPlugin)]])),
{ message: `Expected plugin at ${path.resolve(invalidPlugin)} to be named invalid but got wrong` }
);
});
});
});
+4 -2
View File
@@ -58,10 +58,12 @@ class MockConnector extends events.EventEmitter {
}
send(type, data) {
let message = { seq: this._seq, type, data };
let seq = this._seq;
this._seq += 1;
let message = { seq, type, data };
this.sentMessages.push(message);
setImmediate(() => this.emit("send", message));
return this._seq++;
return seq;
}
}
+2 -8
View File
@@ -67,19 +67,13 @@ describe("Slave testing", function() {
it("should throw on CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
check(
bad, "cannot be named any of . .."
+" CON PRN AUX NUL COM1-9 and LPT1-9"
);
check(bad, "cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9");
}
});
it("should throw on . and ..", function() {
for (let bad of [".", ".."]) {
check(
bad, "cannot be named any of . .."
+" CON PRN AUX NUL COM1-9 and LPT1-9"
);
check(bad, "cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9");
}
});