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
Hornwitser eee4c69575 v2.0.0-alpha.1 2020-10-28 15:02:13 +01:00
Hornwitser adbbdef1a8 Merge pull request #327 from Hornwitser/add-package-readmes
Add package readmes and update project
readme file.
2020-10-28 13:32:04 +01:00
Hornwitser 36e5606e25 Add section on configuring the master server
Add detailed instructions for setting the https port the master server
runs on, setting the external_address option and how to set up a reverse
proxy to the master server.
2020-10-28 01:48:59 +01:00
Hornwitser 5fb7a33199 Link slave and ctl docs in README
Link to the slave package README and the managing-a-cluster document in
the sections for slave and clusterctl.
2020-10-28 01:39:43 +01:00
Hornwitser db0a3fb85e Drop support for Node.js v11
Drop claims of supporting the EOL v11 branch of Node.js.
2020-10-28 00:58:01 +01:00
Hornwitser 1215bbf3ab Rewrite introduction to Clusterio
Rewrite the woefully out of date description of Clusterio in the
readme to give a better sense of what Clusterio is and the available
plugins.
2020-10-28 00:58:01 +01:00
Hornwitser 64ac5a181a Fix typo in managing-a-cluster.md filename. 2020-10-28 00:58:01 +01:00
Hornwitser 4834c289ff Add README.md files to packages
Add README with basic descriptions of each package to all of the
packages in this repository.
2020-10-28 00:58:01 +01:00
Hornwitser 5fffc282fe Merge pull request #332 from Hornwitser/web-plugins
Implement plugin loading in web interface
2020-10-27 21:57:42 +01:00
Hornwitser 6dbf29153e Implement plugin loading in web interface
Add loading of plugin info to the web interface via webpack Module
Federation.
2020-10-27 18:24:45 +01:00
Hornwitser 542d8a798f Merge pull request #326 from Hornwitser/publish-fixes
Publish fixes
2020-10-26 15:50:53 +01:00
Hornwitser 7d8b55adb6 Use less strict matching for lib dependency
Stop requiring exact version match for the clusterio/lib dependency in
master, slave and ctl to make doing alpha releases simpler.
2020-10-26 15:24:29 +01:00
Hornwitser 1eca2ec6e6 Add missing ajv dependency to clusterio/lib
The lib/schema module uses ajv for compiling JSON Schema validators but
for some reason ajv was not part of the dependency list in its
package.json.
2020-10-26 15:17:01 +01:00
Hornwitser 9ee3fe6c37 Move gitignore for web build out of package
Mave the ignore entry for packages/master/static out of
packages/master/.gitignore to avoid npm using it and causing the web
build to be excluded from the package.
2020-10-24 16:46:02 +02:00
93 changed files with 3243 additions and 1699 deletions

No files matched your search

+1
View File
@@ -1 +1,2 @@
packages/master/static
plugins/*/static
+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",
+4
View File
@@ -45,6 +45,10 @@ static/export
saves
/temp/
# Web build
/packages/master/static
/plugins/*/static
# IDE
.idea/
.vs/
+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
+115 -48
View File
@@ -30,76 +30,81 @@ the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
### Table of contents
* [Introduction & methodology](#introduction)
* [Introduction](#introduction)
* [Features](#features)
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Hosting Behind Proxy](#hosting-behind-proxy)
* [Setting up an admin account](#setting-up-an-admin-account)
* [Running Clusterio](#running-clusterio)
* [Master Server](#master-server)
* [Slaves](#slaves)
* [Instances](#instances)
* [Common problems](#Common-problems)
## Introduction
Features:
Clusterio is a clustered Factorio server manager that provides the
tooling for implementing cross server interactions in Factorio. It
was previously best known for implementing cross server transfer and
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).
- Entities to send/recieve items
- Cross dimensional storage
- Sending of liquids
- Sending of circuit network signals
- Inventory combinator to display item levels in the cluster (and epoch time)
- Reporting of graphs and UPS on master interface (Also has extensive Prometheus reporting)
Optional extras (see [Plugins](#Plugins))
- Have your inventory synchronize across servers
- Teleport trans from the border of one world to te next
- Show in-game chat in discord
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.
Connection diagram:
## Features
![http://i.imgur.com/7FdVfgB.png](http://i.imgur.com/7FdVfgB.png)
- Clustered Factorio server management allowing you manage the running
of Factorio servers across a fleet of physical servers from both a web
interface and a command line interface.
There can be any number of clients connected to each slave, and any number of slaves connected to a master but there can only be one master server in each cluster.
- User list management for synchronizing in-game admins, whitelisted
users, and bans to all the servers in the cluster.
**How does it work?**
- Integrated support for exporting statistics for the whole cluster to
Prometheus via a single metrics endpoint.
Traditional factorio mods have always been limited by the games deterministic design. This gives us a very bug free and predictable game, but doesn't allow us cool stuff such as internet communication.
Clusterio exploits one of the games logging features, game.write_file and RCON to communicate between servers. Sending an item from one server to another takes this path:
- Extensive plugin support for adding your own cross server features to
Factorio using Clusterio's communication backbone.
1. server1: Chest has stuff in it, write the contents to a file and delete them from the game world
2. client.js on server1: File has stuff in it, parse and send to the master for storage
## Plugins
3. master: server1 gave us stuff, store it in the storage and write some statistics
The heart of Clusterio is its plugin syste. Plugins add functionality
to Factorio servers, Clusterio itself or both. These are the plugins
supported and maintained by the Clusterio developers:
4. server2: get-chest is empty, write a request to file
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat
between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize
research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect
in-game statistics from all the servers and makes it available to the
Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/factorioClusterioMod):
Provide shared storage that can transport items between servers via
teleport chests.
5. client.js on server2: Request file has stuff in it, parse and send a request to master for more items of that type
Want to make your own plugin? Check out the documentation on [Writing
Plugins](/docs/writing-plugins.md) for where to start.
6. master: server2 asked for stuff, check if we have enough and how much demand there is, then send however much is appropriate back
7. client.js on server2: We were allowed to import x of item y, run command /c remote.call("clusterio", "importMany", "{'copper-plate':120}")
This process works the same for both items and liquids, independent on what mods are used. Yes, modded items are fully supported.
Clusterio can also do a few other neat things, such as giving you access to epoch time, syncing player inventories between servers, keeping track of playtime (playerManager plugin), teleporting trains between servers (trainTeleports) and exporting tons of factorio related statistics to Prometheus for graphing in grafana.
## Ubuntu setup
**Warning**: These instructions are for the unstable master version and is not
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
Clusterio runs on Node.js v12 and up, v11.13.0+ and v10.16.0+. Node.js itself is not
Clusterio runs on Node.js v12 and up, and v10.16.0+. Node.js itself is not
supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
Master and all slaves:
@@ -140,7 +145,7 @@ recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable
**Requirements**
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
up, v11.13.0+ and v10.16.0+.
up, and v10.16.0+.
**Master**
@@ -199,10 +204,65 @@ plugins by the absolute path to them, or a relative path that must start
with either . or .. (which will then be resolved to an absolute path).
## Running Clusterio
## Configure Master Server
After following the installation instructions you can use the following
commands to run Clusterio.
By default the master server will create a self signed TLS certificate
and listen on HTTPS on port 8443. You can change the port used with the
command
npx clusteriomaster config set master.https_port 1234
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriomaster config set master.external_address https://192.0.2.4:8443/
Change the url to reflect the IP, protocol, and port the master server
is accessible under, dns names are also supported. If proxyed under a
sub-path include the sub path as well and make sure the URL ends with a
slash.
You can list the config of the master server with the `npx
clusteriomaster config list` command. See the [readme for
@clusterio/master](/packages/master/README.md) for more
information.
### Hosting Behind Proxy
If you want to host the master server behind a reverse proxy the
recommended setup is to disable HTTPS and host it on a high port
over HTTP to which you point your proxy to. For example to configure
port 8080 as the HTTP port for the master server.
npx clusteriomaster config set master.https_port # unsets https_port
npx clusteriomaster config set master.http_port 8080
The proxy can then forward requests to a sub-path like `/cluster/` to
`http://localhost:8080/`.
Additianlly WebSocket connections to api/socket relative to the sub-path
the interface is hosted under also needs to be forwarded. See below for
the specifics of setting this up on Apache.
#### Apache Config
To proxy with Apache make sure that proxy_module, proxy_http_module and
proxy_wstunnel_module is loaded in the config, then add the following
directives to wherever is the approriate place for your flavour of
Apache config organization.
```apache
<Location /cluster/>
ProxyPass "http://localhost:8080/"
</location>
<Location /cluster/api/socket>
ProxyPass "ws://localhost:8080/api/socket"
</Location>
```
Pay attention to the use of trailing slashes, they are important.
### Setting up an admin account
@@ -220,7 +280,13 @@ promotes it to a cluster admin. The second one sets up a
server under the given user account.
### Running the master Server
## Running Clusterio
After following the installation and master configuration instructions
you can use the following commands to run Clusterio.
### Master Server
It's necessary to run the master server in order for anything to work.
Once you've completed the setup run the following command to start it
@@ -246,8 +312,8 @@ the config for a remote slave you will need to have set the
reached on.
You can list the config of a slave on the slave itself with the `npx
clusterioslave config list` command. Use `npx clusterioslave config
--help` for more information.
clusterioslave config list` command. See the [readme for
@clusterio/slave](/packages/slave/README.md) for more information.
Once the config is set up run the slave with
@@ -276,7 +342,8 @@ directory and files needed to run the instance on the given slave. The
third line starts the instance, which creates a new save if there are no
save games present.
There are many more commands available with clusterioctl. See
There are many more commands available with clusterioctl. See the
[Managing a Cluster](/docs/managing-a-cluster.md) document or
`npx clusterioctl --help` for a full list of them.
+323
View File
@@ -0,0 +1,323 @@
Configuration
=============
Master Configuration
--------------------
### master.database_directory
Directory used to store cluster data to. Needs to be writeable by the
master server. This is also used by plugins to store its data.
Defaults to "database".
### master.http_port
Port to host HTTP server on. If set to null no HTTP server will be
exposed. At least one of this and master.https_port needs to be set to
a port.
Defaults to null.
### master.https_port
Port to host HTTPS server on. Uses master.tls_certificate and
master.tls_private_key as the certificate and private key for the HTTPS
server. If set to null no HTTPS server will be exposed. At least one
of this and master.http_port needs to be set to a port.
Defaults to 8443.
### master.external_address
External address the master server is accessible on. Currently only
used for `clusteriomaster bootstrap create-ctl-config` in order to give
the right url to connect to. This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### master.tls_certificate
TLS certificate to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_private_key
exists a self signed certificate is automatically created.
Defaults to "database/certificates/cert.crt".
### master.tls_private_key
TLS private key to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_certificate
exists a self signed certificate is automatically created.
Defaults to "database/certificates/cert.key".
### master.tls_bits
Number of bits to use when creating a self signed certificate.
Defaults to 2048.
### master.auth_secret
Base64 encoded authentication secret used to verify tokens issued to
slaves, users, and sessions. This should contain 256 bytes of random
data, and if changed will cause all tokens become invalid. Keep this
secret if leaked an attacker could easily compromise the cluster.
Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket
connections. If a connection hasn't received a heartbeat in 2 times the
heartbeat interval it will be considered stale and closed. A lower
value means less time between connections going stale and them being
closed.
Defaults to 60
### master.connector_shutdown_timeout
Timeout in seconds to use while shutting down the master server for link
sessions that currently don't have an active connection and haven't been
closed down properly. This happens when the network between a slave and
the master server goes down unexpectedly, or the slave goes down without
closing the connection properly either do to crashing or the machine
it's hosted on going down. If the timeout expires the session is
terminated and data loss may occur, if the slave went down this is
unavoidable.
Defaults to 30.
### master.default_role_id
Role to automatically grant to new users. If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### <plugin_name>.enabled
Whether to enable the given plugin on the master server. Plugins
disabled on the master server will not be loaded on instances.
Defaults to true.
Slave Configuration
-------------------
### slave.name
Name of the slave. Shows up in slave lists and is used to reference
this slave in the clusterioctl command line interface.
Defaults to "New Slave".
### slave.id
Imutable numeric id of the slave which uniquely identifies this slave in
the cluster.
Defaults to a random 31 bit number.
### slave.factorio_directory
Directory to look for the Factorio server(s) in. This can either point
to a Factorio server directory, or to a directory containing multiple
versions of the Factorio server.
Defaults to "factorio".
### slave.instances_directory
Directory to store instances in. One sub-directory will be created in
it for each instance assigned to this slave and these sub-directories
will contain logs, saves, and various auto generated configuration files
for the instances.
Defaults to "instances".
### slave.master_url
URL to connect to the master server to.
Defaults to "https://localhost:8443/".
### slave.master_token
Access token used for authenticating with the master server. You can
generate an access token with `clusterioctl slave generate-token
--id <slave-id>`, or use the `clusterioctl slave create-config` to
create a new slave config with the correct url and token in it.
Defaults to "enter token here".
### slave.public_address
External address instances hosted on this slave can be accessed on.
This is used by plugins like Server Select to give the correct address
to switch between instances in-game.
Defaults to "localhost".
### slave.reconnect_delay
Delay in seconds to wait after connection to the master server is
dropped before attempting to reconnect to it. The actual delay on each
reconnect will be a random number between 0 and this configured value to
avoid all slaves trying to reconnect at the same time.
Defaults to 5.
Instance Configuration
======================
### instance.name
Name of the instance, shown in instance lists and used to reference to this
instance in the clusterioctl command line interface.
Defaults to "New Instance"
### instance.id
Imutable numeric id of the instance which uniquely identifies this
instance in the cluster.
Defaluts to a random 31 bit integer.
### instance.assigned_slave
Slave this instance is assigned to. To change this you need to use the
separate assign instance interface. After being assigned to a slave the
directory for the instance is created on the slave.
Defaults to null meaning not assigned.
### factorio.version
Version of Factorio to use for this instance. Can be a version like
"1.0.0" or special string "latest" meaning the latest version of
Factorio found on the slave.
Defaults to "latest"
### factorio.game_port
UDP port to run the Factorio server on. If null a random port in the
dynamyc range assigned each time the instance starts.
Defaults to null
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on. If null
a random port in the dynamic range is assigned each time the instance
starts.
Defaults to null
### factorio.rcon_password
Password to use for the RCON interafec. If null a random secure
password is generated and used each time the instance starts.
Defaults to null
### factorio.enable_save_patching
Whether to use save patching or not. When enabled lua code is patched
into the game save before starting it. Most plugins require the use of
save patching to function. Turning it off makes it possible to use
Clusterio to run and manage regular vanilla games and scenarios not
compatible with Clusterio.
Defaults to true
### factorio.enable_whitelist
Turn on the whitelist on the server.
Defaults to false
### factorio.settings
Object with the settings to put into `server-settings.json` for the
Factorio server. The settings in `data/server-settings.example.json` of
the Factorio installation is used as the base and properties specified
here overrides the properties there.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in
cluster. If enabled Clusterio will generate a new
`server-adminlist.json` on instance startup based on the users in the
cluster with admin status set to true, and keep the admins in sync with
the users that have admin status while the instance is running.
Defaults to true
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users
in the cluster. If enabled Clusterio will generate a new
`server-whitelist.json` on instance startup based on the users in the
cluster with whitelisted status set to true, and keep the whitelisted
users in sync with the users that have whitelisted status while the
instance in running.
Defaults to true
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the
cluster. If enabled Clusterio will generate a new `server-banlist.json`
on instance startup based on users in the cluster with banned status set
to true, and keep the banned users in sync with the users that have the
banned status while the instance is running.
Defaults to true
### <plugin_name>.enabled
Whether to enable the given plugin on the instance. Note that plugins
need to be enabled on the master server in order for them to be loaded
on instances.
Defaults to true
+1 -1
View File
@@ -187,7 +187,7 @@ of this log should be self explanatory, please follow it carefully.
Supported Node.js and Factorio Versions
---------------------------------------
Clusterio run on Node.js v12 and up, v11.13.0+ and v10.16.0+, make sure
Clusterio run on Node.js v12 and up, and v10 from v10.16.0, make sure
to check that the Node builtin API's and npm packages you use are
supported on these versions. The Travis CI tests are runned on whatever
version of Node.js v10 is the default, which at the time of writing is
@@ -1,7 +1,7 @@
Managing a Cluster
==================
Clusterio clusers are managed through the master server by using the
Clusterio clusters are managed through the master server by using the
`clusterioctl` command line interface which is invoked by running
`npx clusterioctl <command>` in the clusterio directory. This document
uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should
+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.0"
"version": "2.0.0-alpha.2"
}
+5 -2
View File
@@ -18,9 +18,11 @@
]
},
"engines": {
"node": ">=10.16 <11 || >= 11.13"
"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",
@@ -33,6 +35,7 @@
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"nyc": "^14.1.1",
"phin": "^3.4.1"
"phin": "^3.4.1",
"webpack": "^5.0.0"
}
}
+108
View File
@@ -0,0 +1,108 @@
Clusterio Command Line Interface
================================
Provides a command line interface for managing a Clusterio cluster.
Note that this document only describes the options and commands for
managing clusterioctl itself, see [Managing a
cluster](/docs/managing-a-cluster.md) for the available commands used
for managing a cluster.
Usage
-----
npx clusterioctl <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to ctl. Defaults to `plugin-list.json`
and will be created if it does not exist. See the `plugin` command
for managing this list.
* `--config <file>` JSON file to use for storing configuration for
ctl. Defaults to `config-control.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by ctl. The available plugins
will be automatically loaded and the commands they provide will be added
to the ctl utility.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioctl plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin list`
Lists the plugins set up to be available by name followed by path.
### `config-control`
Manage the ctl configuration. This allows setting the url and token to
the master server to manage.
#### `config-control set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
#### `config-control show <config-entry>`
Shows the value for a single config entry.
#### `config-control list`
Lists up all configuration entries with their currently configured values.
## See Also
[Managing a cluster](/docs/managing-a-cluster.md) for the available
commands used for managing a cluster.
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
+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;
}
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.0",
"version": "2.0.0-alpha.2",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,10 +15,10 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 11.13"
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "=2.0.0-alpha.0",
"@clusterio/lib": "^2.0.0-alpha.2",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
+8
View File
@@ -0,0 +1,8 @@
Common Library for Clusterio
============================
This package contains functionality shared between master, slave, ctl
and/or plugins of Clusterio.
See [the Clusterio repository](https://github.com/clusterio/factorioClusterio)
for more inforation on Clusterio.
+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;
}
}
+3 -2
View File
@@ -1,16 +1,17 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.0",
"version": "2.0.0-alpha.2",
"repository": "https://github.com/clusterio/factorioClusterio",
"license": "MIT",
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 11.13"
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"ajv": "^6.12.6",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"isomorphic-ws": "^4.0.1",
-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,
};
-2
View File
@@ -1,2 +0,0 @@
# Webpack output
static
+135
View File
@@ -0,0 +1,135 @@
Clusterio Master Server
=======================
Communication hub for Clusterio clusters. The master server forwards
data between Clusterio slaves connected to it and allows the cluster to
be remotely managed through WebSocket connections to it either by using
the included web interface or the Clusterio ctl command line utility.
Usage
-----
npx clusteriomaster <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the master server. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
master server. Defaults to `config-master.json` and will be created
if it does not exist. See the `config` command for inspecting and
modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the master server. The
available plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusteriomaster plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin list`
Lists the plugins set up to be available by name followed by path.
### `config`
Manage the master server configuration offline. This should only be
used when the master server is stopped, otherwise the config read might
be out of date and config changes will be overwritten when the master
server shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration entries.
#### `config show <config-entry>`
Shows the value for a single config entry.
#### `config list`
Lists up all configuration entries with their currently configured values.
### `bootstrap`
Commands for setting up initial admin acount and access to the cluster.
#### `bootstrap create-admin <name>`
Creates a new user account with the given name if it does not already
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
up for connecting to the cluster.
### `run`
Runs the master server.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
+32 -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");
@@ -201,6 +202,16 @@ function validateSlaveToken(req, res, next) {
next();
}
app.get("/api/plugins", (req, res) => {
let plugins = [];
for (let pluginInfo of pluginInfos) {
let name = pluginInfo.name;
let enabled = masterPlugins.has(name) && masterConfig.group(name).get("enabled");
plugins.push({ name, enabled });
}
res.send(plugins);
});
// Handle an uploaded export package.
async function uploadExport(req, res, next) {
endpointHitCounter.labels(req.route.path).inc();
@@ -588,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) {
@@ -1419,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);
@@ -1562,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",
@@ -1600,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();
@@ -1629,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") {
@@ -1652,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) {
@@ -1744,6 +1766,11 @@ async function startServer() {
for (let route of pluginInfo.routes || []) {
app.get(route, serveWeb(route));
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let staticPath = path.join(path.dirname(pluginPackagePath), "static");
console.log(`/plugin/${pluginInfo.name}`, staticPath);
app.use(`/plugin/${pluginInfo.name}`, express.static(staticPath));
}
// Load plugins
+8 -11
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.0",
"version": "2.0.0-alpha.2",
"main": "master.js",
"scripts": {
"build": "webpack --env.production",
"build": "webpack-cli --env.production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -16,10 +16,10 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 11.13"
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "=2.0.0-alpha.0",
"@clusterio/lib": "^2.0.0-alpha.2",
"body-parser": "^1.18.2",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
@@ -35,22 +35,19 @@
"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",
"fast-deep-equal": "^3.1.3",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"style-loader": "^1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2"
"webpack-dev-middleware": "^3.7.2",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
-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
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clusterio</title>
<script>
window.web_root = "__WEB_ROOT__";
window.webRoot = "__WEB_ROOT__";
</script>
</head>
<body>
+1 -8
View File
@@ -1,8 +1 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";
ReactDOM.render(<App />, document.getElementById("root"));
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,83 +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_url: localStorage.getItem("master_url"),
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="Master URL"
name="master_url"
rules={[
{
required: true,
message: "Please input master url, ex ws://localhost:8080 or wss://localhost:8443/ . For SSL connections, ensure you have a valid certificate.",
},
]}
>
<Input />
</Form.Item>
<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);
}
-134
View File
@@ -1,134 +0,0 @@
import { Control, ControlConnector } from "./../util/websocket";
import * as errors from "@clusterio/lib/errors";
import * as link from "@clusterio/lib/link";
import config from "@clusterio/lib/config";
let control, controlConnector;
// Create WS connection to master. Used by all components through the interface exported here.
(async () => {
config.finalizeConfigs();
controlConnector = new ControlConnector(
localStorage.getItem("master_url"),
10, // Reconnect delay
localStorage.getItem("master_token"),
);
control = new Control(controlConnector, []);
try {
await controlConnector.connect();
} catch (err) {
console.log(err);
}
})();
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],
};
};
}
+15 -55
View File
@@ -1,12 +1,13 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { merge } = require("webpack-merge");
module.exports = (env = {}) => ({
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
mode: env.production ? "production" : "development",
devtool: env.production ? "source-map" : "eval-source-map",
devServer: {
contentBase: "./static",
},
@@ -14,58 +15,17 @@ module.exports = (env = {}) => ({
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
performance: {
maxAssetSize: 2**21,
maxEntrypointSize: 2**21,
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
new webpack.container.ModuleFederationPlugin({
name: "master",
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
},
}),
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
function(resource) {
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
}
),
],
module: {
rules: [
{
test: /node_modules.fs-extra/,
use: "null-loader",
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
sourceMap: !env.production,
},
},
],
},
{
test: /\.jsx$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
},
],
},
resolve: {
extensions: [".js", ".json", ".jsx"],
},
node: {
"crypto": "empty",
},
});
+111
View File
@@ -0,0 +1,111 @@
Clusterio Slave
===============
Node hosting Factorio servers in a Clusterio cluster. Clusterio slaves
connect to the master server and waits for commands from the master
server to start up and stop instances. A cluster can have any number of
slaves in it located on different computers, and each slave can host any
number of instances each of which is a Factorio server that talks with
the rest of the cluster.
Usage
-----
npx clusterioslave <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the slave. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
slave. Defaults to `config-slave.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the slave. The available
plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioslave plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioslave plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
#### `plugin list`
Lists the plugins set up to be available by name followed by path.
### `config`
Manage the slave configuration offline. This should only be used when
the slave is stopped, otherwise the config read might be out of date and
config changes will be overwritten when the slave shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
#### `config show <config-entry>`
Shows the value for a single config entry.
#### `config list`
Lists up all configuration entries with their currently configured values.
### `run`
Runs the slave.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.0",
"version": "2.0.0-alpha.2",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,10 +15,10 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 11.13"
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "=2.0.0-alpha.0",
"@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"
}
}
@@ -1,6 +1,6 @@
// Calculate basedir of application based on current location and
// the provided web_root from master server.
const location = new URL(window.web_root, document.location);
const location = new URL(window.webRoot, document.location);
const basename = location.pathname !== "/" ? location.pathname : "";
export default basename;
+71
View File
@@ -0,0 +1,71 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
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;
script.type = "text/javascript";
script.async = true;
let result = new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
});
document.head.appendChild(script);
return result;
}
async function loadPluginInfos() {
let response = await fetch(`${webRoot}api/plugins`);
let pluginList;
if (response.ok) {
pluginList = await response.json();
} else {
console.log("Failed to get plugin data, running without plugins");
pluginList = [];
}
let pluginInfos = [];
await __webpack_init_sharing__("default");
for (let plugin of pluginList) {
try {
await loadScript(`${webRoot}plugin/${plugin.name}/remoteEntry.js`);
let container = window[`plugin_${plugin.name}`];
await container.init(__webpack_share_scopes__.default);
let info = (await container.get("./info"))();
info.container = container;
pluginInfos.push(info);
} catch (err) {
console.log(`Failed to load plugin info for ${plugin.name}`);
console.log(err);
}
}
return pluginInfos;
}
async function load() {
let pluginInfos = await loadPluginInfos();
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10);
let control = new Control(controlConnector, []);
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");
}
}
+79
View File
@@ -0,0 +1,79 @@
"use strict";
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = (env = {}) => ({
mode: env.production ? "production" : "development",
devtool: env.production ? "source-map" : "eval-source-map",
performance: {
maxAssetSize: 2**21,
maxEntrypointSize: 2**21,
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
}),
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
function(resource) {
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
}
),
],
module: {
rules: [
{
test: /node_modules.fs-extra/,
use: "null-loader",
},
{
test: /node_modules.jsonwebtoken/,
use: "null-loader",
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
sourceMap: !env.production,
},
},
],
},
{
test: /\.js$/,
resolve: {
// XXX Required due to bug in babel-runtime
// see: https://github.com/babel/babel/issues/12058
fullySpecified: false,
},
},
{
test: /\.jsx$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
},
{
test: /\.png$/,
use: "file-loader",
},
],
},
resolve: {
extensions: [".js", ".json", ".jsx"],
fallback: {
"events": require.resolve("events/"),
"path": require.resolve("path-browserify"),
},
},
});
+16
View File
@@ -0,0 +1,16 @@
Clusterio Global Chat Plugin
============================
Plugin sharing the in-game chat between instances.
Installation
============
Run the following commands in the folder Clusterio is installed to:
npm install @clusterio/plugin-global_chat
npx clusteriomaster plugin add @clusterio/plugin-global_chat
Substitute clusteriomaster with clusterioslave or clusterioctl if this a
dedicate slave or ctl installation respectively.
+10 -1
View File
@@ -1,12 +1,13 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.0",
"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",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"scripts": {
"build": "webpack-cli --env.production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -15,6 +16,14 @@
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
+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");
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "global_chat",
library: { type: "var", name: "plugin_global_chat" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
],
});
+17
View File
@@ -0,0 +1,17 @@
Clusterio Research Sync Plugin
==============================
Plugin synchronizing research progress between instances and the
cluster.
Installation
============
Run the following commands in the folder Clusterio is installed to:
npm install @clusterio/plugin-research_sync
npx clusteriomaster plugin add @clusterio/plugin-research_sync
Substitute clusteriomaster with clusterioslave or clusterioctl if this a
dedicate slave or ctl installation respectively.
+11 -2
View File
@@ -1,12 +1,13 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.0",
"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",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"scripts": {
"build": "webpack-cli --env.production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -15,9 +16,17 @@
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"ependencies": {
"dependencies": {
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "research_sync",
library: { type: "var", name: "plugin_research_sync" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
],
});
+18
View File
@@ -0,0 +1,18 @@
Clusterio Statistics Exporter Plugin
====================================
Plugin exporting item production, fluid production, kill count, entity
build and poluttion statistics to the Prometheus integration to
Clusterio.
Installation
============
Run the following commands in the folder Clusterio is installed to:
npm install @clusterio/plugin-statistics_exporter
npx clusteriomaster plugin add @clusterio/plugin-statistics_exporter
Substitute clusteriomaster with clusterioslave or clusterioctl if this a
dedicate slave or ctl installation respectively.
+10 -1
View File
@@ -1,12 +1,13 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.0",
"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",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"scripts": {
"build": "webpack-cli --env.production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -15,6 +16,14 @@
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
Whitespace-only changes.
@@ -0,0 +1,30 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "statistics_exporter",
library: { type: "var", name: "plugin_statistics_exporter" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
},
}),
],
});
+16
View File
@@ -0,0 +1,16 @@
Clusterio Subspace Storage Plugin
=================================
Plugin for the Subpsace Storage mod.
Installation
============
Run the following commands in the folder Clusterio is installed to:
npm install @clusterio/plugin-subspace_storage
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
Substitute clusteriomaster with clusterioslave or clusterioctl if this a
dedicate slave or ctl installation respectively.
+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,
+13 -1
View File
@@ -1,12 +1,13 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.0",
"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",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"scripts": {
"build": "webpack-cli --env.production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -18,6 +19,17 @@
"dependencies": {
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"antd": "^4.6.5",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
Whitespace-only changes.
@@ -0,0 +1,33 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "subspace_storage",
library: { type: "var", name: "plugin_subspace_storage" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"ajv": { import: false },
"antd": { import: false },
"react": { import: false },
"react-dom": { 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");
}
});