mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36865cfcf9 | ||
|
|
d493c2d671 | ||
|
|
2a0ae12829 | ||
|
|
6a4875c5ec | ||
|
|
325c08389e | ||
|
|
bcd1290301 | ||
|
|
bd1414dd1c | ||
|
|
d6623f89aa | ||
|
|
0494858886 | ||
|
|
df94ff1d8d | ||
|
|
85111e233e | ||
|
|
5337280eec | ||
|
|
3ce32ba870 | ||
|
|
6cb33a8b63 | ||
|
|
10cebd9e09 | ||
|
|
0c554cc754 | ||
|
|
f3b918e2c2 | ||
|
|
f67f56e6de | ||
|
|
0c0626564a | ||
|
|
019ffc25f0 | ||
|
|
690e566556 | ||
|
|
eee4c69575 | ||
|
|
adbbdef1a8 | ||
|
|
36e5606e25 | ||
|
|
5fb7a33199 | ||
|
|
db0a3fb85e | ||
|
|
1215bbf3ab | ||
|
|
64ac5a181a | ||
|
|
4834c289ff | ||
|
|
5fffc282fe | ||
|
|
6dbf29153e | ||
|
|
542d8a798f | ||
|
|
7d8b55adb6 | ||
|
|
1eca2ec6e6 | ||
|
|
9ee3fe6c37 |
No files matched your search
@@ -1 +1,2 @@
|
||||
packages/master/static
|
||||
plugins/*/static
|
||||
+11
-4
@@ -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",
|
||||
|
||||
@@ -45,6 +45,10 @@ static/export
|
||||
saves
|
||||
/temp/
|
||||
|
||||
# Web build
|
||||
/packages/master/static
|
||||
/plugins/*/static
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vs/
|
||||
+1
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||

|
||||
- 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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -3,5 +3,5 @@
|
||||
"packages/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"version": "2.0.0-alpha.0"
|
||||
"version": "2.0.0-alpha.2"
|
||||
}
|
||||
+5
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -34,7 +34,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
let dropCount = 0;
|
||||
for (let index = 0; index < this._sendBuffer.length; index++) {
|
||||
if (this._sendBuffer[index].seq <= seq) {
|
||||
dropCount++;
|
||||
dropCount += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -102,12 +102,14 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
if (!["handshake", "connected", "closing"].includes(this._state)) {
|
||||
throw new libErrors.SessionLost("No session");
|
||||
}
|
||||
let message = { seq: this._seq, type, data };
|
||||
let seq = this._seq;
|
||||
this._seq += 1;
|
||||
let message = { seq, type, data };
|
||||
this._sendBuffer.push(message);
|
||||
if (["connected", "closing"].includes(this._state)) {
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
return this._seq++;
|
||||
return seq;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,8 +452,10 @@ class VirtualConnector extends events.EventEmitter {
|
||||
* @returns {number} the sequence number of the message sent
|
||||
*/
|
||||
send(type, data) {
|
||||
this.other.emit("message", { seq: this._seq, type, data });
|
||||
return this._seq++;
|
||||
let seq = this._seq;
|
||||
this._seq += 1;
|
||||
this.other.emit("message", { seq, type, data });
|
||||
return seq;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
# Webpack output
|
||||
static
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 +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");
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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.
File renamed without changes.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
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: "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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
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: "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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ describe("lib/factorio", function() {
|
||||
} catch (err) { }
|
||||
if (success) {
|
||||
validate(modules);
|
||||
successCount++;
|
||||
successCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user