mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e518d570 | ||
|
|
3b13dfdcb5 | ||
|
|
2e5333af29 | ||
|
|
450e27e001 | ||
|
|
71164faa14 | ||
|
|
a89106f4a3 | ||
|
|
d768019e1b | ||
|
|
30b7fd8767 | ||
|
|
faae6ccf97 | ||
|
|
6c11cefb99 | ||
|
|
dd8f9dbfd5 | ||
|
|
93752e671e | ||
|
|
7b75d4f0bb | ||
|
|
1c8a2fef32 | ||
|
|
69221a310c | ||
|
|
49b5b4412f | ||
|
|
fe86041fc4 | ||
|
|
957983d8a5 | ||
|
|
8f176c969b | ||
|
|
79d20260c5 | ||
|
|
0079d8634a | ||
|
|
a08087c5e8 | ||
|
|
cdb048741c | ||
|
|
4d0720ade8 | ||
|
|
e5e1851852 | ||
|
|
0b249b6018 | ||
|
|
c65494f093 | ||
|
|
e82f849118 | ||
|
|
87da492ef8 | ||
|
|
9d2ac2b81f | ||
|
|
c78bf02ce4 | ||
|
|
2f7419cf3b | ||
|
|
10303f979d | ||
|
|
b742291131 | ||
|
|
a06781ebc3 | ||
|
|
f5e28f6815 | ||
|
|
770a6664d4 | ||
|
|
f89e66e3cb | ||
|
|
65a25e2510 | ||
|
|
5b74432ee3 | ||
|
|
49eb966315 | ||
|
|
42e680f09d | ||
|
|
31c0925354 | ||
|
|
4c9b91ab57 | ||
|
|
a92ae95ab1 | ||
|
|
fc8edeba5f | ||
|
|
db1f239a0f | ||
|
|
c8c65d2e92 | ||
|
|
67654fdf85 | ||
|
|
63a656cd61 | ||
|
|
36865cfcf9 | ||
|
|
d493c2d671 | ||
|
|
2a0ae12829 | ||
|
|
6a4875c5ec | ||
|
|
325c08389e | ||
|
|
bcd1290301 | ||
|
|
bd1414dd1c | ||
|
|
d6623f89aa | ||
|
|
0494858886 | ||
|
|
df94ff1d8d | ||
|
|
85111e233e | ||
|
|
5337280eec | ||
|
|
3ce32ba870 | ||
|
|
6cb33a8b63 | ||
|
|
10cebd9e09 | ||
|
|
0c554cc754 | ||
|
|
f3b918e2c2 | ||
|
|
f67f56e6de | ||
|
|
0c0626564a | ||
|
|
019ffc25f0 | ||
|
|
690e566556 |
No files matched your search
+13
-6
@@ -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",
|
||||
@@ -124,7 +124,7 @@ module.exports = {
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "error",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "off",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
@@ -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",
|
||||
@@ -214,7 +214,7 @@ module.exports = {
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "warn",
|
||||
"no-void": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package-lock.json -diff
|
||||
+3
-3
@@ -7,9 +7,9 @@ before_install:
|
||||
- npm install -g codecov
|
||||
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
|
||||
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
|
||||
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.6/subspace_storage_1.99.8.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
|
||||
@@ -27,6 +27,8 @@ Version 2.0.0
|
||||
configuration remotely.
|
||||
- Packaged and uploaded to npm again, greatly simplifying distribution and
|
||||
installation of both Clusterio and plugins.
|
||||
- Added centralized logging for the cluster. All logs from both Clusterio
|
||||
and Factorio is stored in a shared log which can be inspected and queried.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,6 +48,11 @@ Version 2.0.0
|
||||
player data.
|
||||
- Added synchronization of in-game adminlist, banlist and whitelist to
|
||||
Clusterio core. Previously this was handled by the playerManager plugin.
|
||||
- Added stripping of long paths in the Factorio server log.
|
||||
- Added option to configure maximum number of commands to send in parallel.
|
||||
- Added option to auto start instances on slave startup.
|
||||
- Added metric mapping of slave and instance ids to their names, allowing
|
||||
them to be displayed by name in queries that join with the mapping.
|
||||
|
||||
### Changes
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ cloud storage of items via teleporter chests. But this functionality
|
||||
has been pulled out of Clusterio into its own plugin for Clusterio named
|
||||
[Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
|
||||
|
||||
By itself Clusterio does change the gameplay in any way, you could even
|
||||
use Clusterio to manage completely vanilla Factorio servers. Plugins do
|
||||
the work of modding in the visible changes into the game, see the
|
||||
[Plugins section](#plugins) for ready made plugins you can install into
|
||||
a Clusterio cluster.
|
||||
By itself Clusterio doesn't change the gameplay in any way, you could
|
||||
even use Clusterio to manage completely vanilla Factorio servers.
|
||||
Plugins do the work of modding in the visible changes into the game, see
|
||||
the [Plugins section](#plugins) for ready made plugins you can install
|
||||
into a Clusterio cluster.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
+80
-2
@@ -84,7 +84,7 @@ 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
|
||||
Defaults to 45
|
||||
|
||||
|
||||
### master.connector_shutdown_timeout
|
||||
@@ -129,7 +129,7 @@ Defaults to "New Slave".
|
||||
|
||||
### slave.id
|
||||
|
||||
Imutable numeric id of the slave which uniquely identifies this slave in
|
||||
Immutable numeric id of the slave which uniquely identifies this slave in
|
||||
the cluster.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
@@ -218,6 +218,15 @@ directory for the instance is created on the slave.
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
|
||||
### instance.auto_start
|
||||
|
||||
If enabled start this instance when the slave it is assigned to is
|
||||
started up. Does not affect start or stop of the instance while the
|
||||
slave is running.
|
||||
|
||||
Defaults to false
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance. Can be a version like
|
||||
@@ -277,9 +286,45 @@ 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.
|
||||
|
||||
Changes to the following properties will be applied live if the instance
|
||||
is running while it is changed: `afk_autokick_interval`,
|
||||
`allow_commands`, `autosave_interval`, `autosave_only_on_server`,
|
||||
`description`, `ignore_player_limit_for_returning_players`,
|
||||
`max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`,
|
||||
`name`, `only_admins_can_pause_the_game`, `game_password`,
|
||||
`require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
Defaults to {"tags":["clusterio"],"auto_pause":false}.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
|
||||
Pass the `--verbose` flag to Factorio when starting the server. This
|
||||
prints more messages to the log.
|
||||
|
||||
Defaults to false
|
||||
|
||||
|
||||
### factorio.strip_paths
|
||||
|
||||
Strip down absolute paths in the server log going to files inside the
|
||||
instance directory such that they are relative to the instance
|
||||
directory. This improves the signal to noise ratio of the log.
|
||||
|
||||
Sample output when disabled:
|
||||
|
||||
0.487 Loading map /srv/clusterio/instances/test/saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script /srv/clusterio/instances/test/temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @/srv/clusterio/instances/test/temp/currently-playing/modules/example/test.lua:7: Example log line.
|
||||
|
||||
Sample output when enabled:
|
||||
|
||||
0.487 Loading map saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @modules/example/test.lua:7: Example log line.
|
||||
|
||||
Defaults to true
|
||||
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in
|
||||
@@ -314,6 +359,39 @@ banned status while the instance is running.
|
||||
Defaults to true
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
|
||||
Maximum number of commands being transmitted into the game in parallel.
|
||||
Since the rate at which long commands are streamed into the game is by
|
||||
default limeted to 100 bytes/tick and scales down to 25 bytes/tick when
|
||||
there's 20 players connected, long commands can hold up the command
|
||||
interface and cause large latencies in getting data pushed into the
|
||||
game. (The rates are controlled by the `segment_size` options in the
|
||||
server settings.) To counteract this Clusterio sends up to this
|
||||
configured value number of commands in parallel over the RCON interface.
|
||||
This causes the game updates sent to clients to have this many command
|
||||
streams in parallel contained in them, drastically increasing the
|
||||
maxiumum size they can become. Since commands are only processed so
|
||||
ofter the maximum rate of commands that can be sustained is roughly 30
|
||||
times this configured value per second, though note that large commands
|
||||
will lower this value.
|
||||
|
||||
The game updates sent by the server is split into roughly 500 byte
|
||||
packets, and if any one of those parts are lost the entire update is
|
||||
resent. This means a high number of concurrent commands can amplify
|
||||
resends due to packet loss and degrade the player experience up to the
|
||||
point of becomming completely unplayable.
|
||||
|
||||
Prior to 0.18.7 the maximum practical size of game update sent where
|
||||
arround 8 kB due to the UDP receive buffer on windows defaulting to this
|
||||
value, larger updates would not get through the receive buffer and cause
|
||||
the connection to drop. To stay below the 8 kB limit the maximum safe
|
||||
value for this option would be around 7000 / (3 * game_speed *
|
||||
maximum_segment_size), which is around 20 in normal circumstances.
|
||||
|
||||
Defaults to 5
|
||||
|
||||
|
||||
### <plugin_name>.enabled
|
||||
|
||||
Whether to enable the given plugin on the instance. Note that plugins
|
||||
|
||||
@@ -223,8 +223,8 @@ should be named the same as the class in PascalCase.
|
||||
Imported modules from the lib package are prefixed with lib to make them
|
||||
easier to distinguish, as the names tend to be very generic. E.g.:
|
||||
|
||||
const libLink = require("lib/link");
|
||||
const libLuaTools = require("lib/lua_tools");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
|
||||
For lua code lowercase_underscore is used for everything.
|
||||
|
||||
@@ -27,4 +27,4 @@ that log into instances under its control. These accounts are used to
|
||||
store per player data and authorize management of the cluster. By
|
||||
default users accounts are assigned a Player role that gives a limited
|
||||
read access to the cluster resources, and admins of the cluster can be
|
||||
assigned an Admin role to get access to manage it.
|
||||
assigned a Cluster Admin role to get access to manage it.
|
||||
@@ -107,7 +107,7 @@ Replaces the roles the user has in the cluster with a new list of roles.
|
||||
Calling with an empty roles argument will remove all roles from the
|
||||
user.
|
||||
|
||||
By default there's a roled named Admin which grants access to
|
||||
By default there's a roled named Cluster Admin which grants access to
|
||||
everything and a role named Player which grants a limited read access to
|
||||
the cluster, see [the section on roles](#roles) for more information
|
||||
about setting up roles and permissions.
|
||||
|
||||
+90
-36
@@ -11,6 +11,7 @@ Contents
|
||||
|
||||
- [Plugin Structure](#plugin-structure)
|
||||
- [Defining the plugin class](#defining-the-plugin-class)
|
||||
- [Logging Messages](#logging-messages)
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
@@ -55,7 +56,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 +137,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() {
|
||||
@@ -170,6 +171,59 @@ result in unpredicatable behaviour. The async init method is always called
|
||||
immediatly after the constructor, so there's little reason to do this.
|
||||
|
||||
|
||||
Logging Messages
|
||||
----------------
|
||||
|
||||
The base plugin classes provide a winston logger for logging messages to
|
||||
the shared cluster log. For instances a copy of the log is also stored
|
||||
on the slave the instance is on. To use it, pass a string to one of the
|
||||
log levels functions, for example:
|
||||
|
||||
async init() {
|
||||
this.logger.info("Initializing frobbing");
|
||||
}
|
||||
|
||||
Metadata covering which plugin and instance (for instance plugin
|
||||
classes) the log event happened on will automatically be attached to the
|
||||
log message.
|
||||
|
||||
The available levels are fatal, error, warn, audit, info, and verbose.
|
||||
These levels have roughly the following meanings:
|
||||
|
||||
- fatal: Unrecoverable error that prevents continuing execution of
|
||||
the Node.js process. Note that Plugins shouldn't have fatal errors.
|
||||
- error: An unexpected error condition occured, usually an exception
|
||||
thrown when it wasn't expected. Should be investigated and fixed.
|
||||
- warn: An potential error was detected, but operation continued
|
||||
gracefully. These may indicate that something is running
|
||||
sub-optimally, or is not working as intedend.
|
||||
- audit: Record of actions performed by users of the cluster. This
|
||||
is usually emitted in response to management requests by a control
|
||||
link.
|
||||
- info: A general log message. May indicae a status or operation
|
||||
completed. This should not be emitted by periodic tasks that run
|
||||
often.
|
||||
- verbose: A verbose log message which might be useful when
|
||||
investigating issues, does not show by default. Note that logging
|
||||
messages is not a cheap operation even when the messages do not show
|
||||
up and spammy logging should be avoided for the verbose level.
|
||||
If the logs are still valuable in certain cases consider putting them
|
||||
behind a config option.
|
||||
|
||||
For guarding unexpected errors the best option is to log a short
|
||||
description along with the`stack` property of the Error:
|
||||
|
||||
try {
|
||||
// Operation that should not throw but may end up throwing
|
||||
} catch (err) {
|
||||
this.logger.error(`Operation failed:\n${err.stack}`);
|
||||
)
|
||||
|
||||
For plugin hooks expections thrown are automatically catched and logged,
|
||||
but for event handlers registered on EventEmitters it's critical that
|
||||
exceptions are catched and handled appropriately.
|
||||
|
||||
|
||||
Defining Configuration
|
||||
----------------------
|
||||
|
||||
@@ -203,7 +257,7 @@ for example in the MasterPluginClass:
|
||||
|
||||
async init() {
|
||||
let level = this.master.config.get("foo_frobber.level");
|
||||
console.log("I got a frobnication level of", level);
|
||||
this.logger.info(`I got a frobnication level of ${level}`);
|
||||
}
|
||||
|
||||
The same applies for instance configs, replace "master" with "instance"
|
||||
@@ -230,11 +284,11 @@ Communicating with Factorio
|
||||
|
||||
For pushing data into Factorio there's RCON, which lets you send
|
||||
arbitrary Lua commands to invoke whatever code you want in the game.
|
||||
This is done by calling the `sendRcon` method on the instance's server object.
|
||||
This is done by calling the `sendRcon` method on the plugin object.
|
||||
For example:
|
||||
|
||||
async onStart() {
|
||||
let response = await this.instance.server.sendRcon(
|
||||
let response = await this.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
@@ -257,9 +311,9 @@ 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.handleFoo(content).catch(err => console.log(
|
||||
"Error handling foo:", foo
|
||||
this.instance.server.on("ipc-my_plugin_foo", content =>
|
||||
this.handleFoo(content).catch(err => this.logger.error(
|
||||
`Error handling foo:\n${err.stack}`
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -270,10 +324,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 +365,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 +378,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 +405,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 +444,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 +463,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 +481,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 +499,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 +611,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 +628,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.1"
|
||||
"version": "2.0.0-alpha.3"
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
"node": ">=10.16 <11 || >= 12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.3",
|
||||
"@babel/preset-react": "^7.12.1",
|
||||
"@clusterio/ctl": "file:./packages/ctl",
|
||||
"@clusterio/lib": "file:./packages/lib",
|
||||
"@clusterio/master": "file:packages/master",
|
||||
|
||||
+151
-86
@@ -9,54 +9,24 @@ const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
const version = require("./package").version;
|
||||
const asTable = require("as-table").configure({ delimiter: " | " });
|
||||
const chalk = require("chalk");
|
||||
const events = require("events");
|
||||
const path = require("path");
|
||||
const winston = require("winston");
|
||||
|
||||
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");
|
||||
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
|
||||
|
||||
/**
|
||||
* Format a parsed Factorio output message with colors
|
||||
*
|
||||
* Formats a parsed Factorio output from lib/factorio into a readable
|
||||
* colorized output using terminal escape codes that can be printed.
|
||||
*
|
||||
* @param {Object} output - Factorio server output.
|
||||
* @returns {string} terminal colorized message.
|
||||
* @private
|
||||
*/
|
||||
function formatOutputColored(output) {
|
||||
let time = "";
|
||||
if (output.format === "seconds") {
|
||||
time = chalk.yellow(output.time.padStart(8)) + " ";
|
||||
} else if (output.format === "date") {
|
||||
time = chalk.yellow(output.time) + " ";
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (output.type === "log") {
|
||||
let level = output.level;
|
||||
if (level === "Info") {
|
||||
level = chalk.bold.blueBright(level);
|
||||
} else if (output.level === "Warning") {
|
||||
level = chalk.bold.yellowBright(level);
|
||||
} else if (output.level === "Error") {
|
||||
level = chalk.bold.redBright(level);
|
||||
}
|
||||
|
||||
info = level + " " + chalk.gray(output.file) + ": ";
|
||||
|
||||
} else if (output.type === "action") {
|
||||
info = "[" + chalk.yellow(output.action) + "] ";
|
||||
}
|
||||
|
||||
return time + info + output.message;
|
||||
function print(...content) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
|
||||
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
|
||||
@@ -64,7 +34,7 @@ slaveCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List slaves connected to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listSlaves.send(control);
|
||||
console.log(asTable(response.list));
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -73,8 +43,8 @@ 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 });
|
||||
console.log(response.token);
|
||||
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: args.id });
|
||||
print(response.token);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -96,9 +66,9 @@ slaveCommands.add(new libCommand.Command({
|
||||
|
||||
let content = JSON.stringify(response.serialized_config, null, 4);
|
||||
if (args.output === "-") {
|
||||
console.log(content);
|
||||
print(content);
|
||||
} else {
|
||||
console.log(`Writing ${args.output}`);
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.outputFile(args.output, content, { flag: "wx" });
|
||||
} catch (err) {
|
||||
@@ -119,7 +89,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List instances known to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listInstances.send(control);
|
||||
console.log(asTable(response.list));
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -156,7 +126,7 @@ instanceConfigCommands.add(new libCommand.Command({
|
||||
|
||||
for (let group of response.serialized_config.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
console.log(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
print(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -198,13 +168,13 @@ instanceConfigCommands.add(new libCommand.Command({
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["assign <instance> <slave>", "Assign instance to a slave", (yargs) => {
|
||||
definition: ["assign <instance> [slave]", "Assign instance to a slave", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
|
||||
yargs.positional("slave", { describe: "Slave to assign to", type: "string" });
|
||||
yargs.positional("slave", { describe: "Slave to assign to or unassign if none", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let slaveId = await libCommand.resolveSlave(control, args.slave);
|
||||
let slaveId = args.slave ? await libCommand.resolveSlave(control, args.slave) : null;
|
||||
await libLink.messages.assignInstanceCommand.send(control, {
|
||||
instance_id: instanceId,
|
||||
slave_id: slaveId,
|
||||
@@ -218,7 +188,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.createSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
@@ -231,7 +201,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.exportData.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
@@ -248,11 +218,12 @@ instanceCommands.add(new libCommand.Command({
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.startInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save || null,
|
||||
});
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -266,11 +237,12 @@ instanceCommands.add(new libCommand.Command({
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.loadScenario.send(control, {
|
||||
instance_id: instanceId,
|
||||
scenario: args.scenario || null,
|
||||
});
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -280,7 +252,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.stopInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
@@ -319,7 +291,7 @@ permissionCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List permissions in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listPermissions.send(control);
|
||||
console.log(asTable(response.list));
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -329,7 +301,7 @@ roleCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List roles in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listRoles.send(control);
|
||||
console.log(asTable(response.list));
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -347,7 +319,7 @@ roleCommands.add(new libCommand.Command({
|
||||
description: args.description,
|
||||
permissions: args.permissions,
|
||||
});
|
||||
console.log(`Created role ID ${response.id}`);
|
||||
logger.info(`Created role ID ${response.id}`);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -397,7 +369,7 @@ userCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List user in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listUsers.send(control);
|
||||
console.log(asTable(response.list));
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -499,12 +471,75 @@ userCommands.add(new libCommand.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
const logCommands = new libCommand.CommandTree({ name: "log", description: "Log inspection" });
|
||||
logCommands.add(new libCommand.Command({
|
||||
definition: ["follow", "follow cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"master": { describe: "Follow log of the master server", nargs: 0, type: "boolean", default: false },
|
||||
"slave": { describe: "Follow log of given slave", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (!args.all && !args.master && !args.slave && !args.instance) {
|
||||
logger.error("At least one of --all, --master, --slave and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
|
||||
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
|
||||
await control.setLogSubscriptions({ all: args.all, master: args.master, slave_ids, instance_ids });
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
logCommands.add(new libCommand.Command({
|
||||
definition: ["query", "Query cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"master": { describe: "Query log of the master server", nargs: 0, type: "boolean", default: false },
|
||||
"slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
|
||||
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (!args.all && !args.master && !args.slave && !args.instance) {
|
||||
logger.error("At least one of --all, --master, --slave and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
|
||||
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
|
||||
let result = await libLink.messages.queryLog.send(control, {
|
||||
all: args.all,
|
||||
master: args.master,
|
||||
slave_ids,
|
||||
instance_ids,
|
||||
max_level: args.maxLevel,
|
||||
});
|
||||
|
||||
let stdoutLogger = winston.createLogger({
|
||||
level: "verbose",
|
||||
levels,
|
||||
format: new libLoggingUtils.TerminalFormat({ showTimestamp: true }),
|
||||
transports: [
|
||||
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
|
||||
],
|
||||
});
|
||||
for (let info of result.log) {
|
||||
stdoutLogger.log(info);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" });
|
||||
debugCommands.add(new libCommand.Command({
|
||||
definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.debugDumpWs.send(control);
|
||||
return new Promise(() => {});
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -520,7 +555,7 @@ class ControlConnector extends libLink.WebSocketClientConnector {
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering control");
|
||||
logger.verbose("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "clusterioctl",
|
||||
@@ -549,15 +584,32 @@ class Control extends libLink.Link {
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the control connection alive after the command completes.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.keepOpen = false;
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
console.log(formatOutputColored(output));
|
||||
async setLogSubscriptions({
|
||||
all = false,
|
||||
master = false,
|
||||
slave_ids = [],
|
||||
instance_ids = [],
|
||||
max_level = null,
|
||||
}) {
|
||||
await libLink.messages.setLogSubscriptions.send(this, {
|
||||
all, master, slave_ids, instance_ids, max_level,
|
||||
});
|
||||
}
|
||||
|
||||
async logMessageEventHandler(message) {
|
||||
logger.log(message.data.info);
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
console.log("WS", message.data.direction, message.data.content);
|
||||
print("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
@@ -580,6 +632,13 @@ async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stderr",
|
||||
default: "server",
|
||||
choices: Object.keys(levels),
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
@@ -594,7 +653,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.
|
||||
;
|
||||
@@ -602,7 +661,14 @@ async function startControl() {
|
||||
// Parse the args first to get the configured plugin list.
|
||||
let args = yargs.argv;
|
||||
|
||||
console.log(`Loading available plugins from ${args.pluginList}`);
|
||||
// Log stream for the ctl session.
|
||||
logger.add(new ConsoleTransport({
|
||||
errorLevels: Object.keys(levels),
|
||||
level: args.logLevel,
|
||||
format: new libLoggingUtils.TerminalFormat(),
|
||||
}));
|
||||
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
|
||||
@@ -625,10 +691,11 @@ async function startControl() {
|
||||
rootCommands.add(permissionCommands);
|
||||
rootCommands.add(roleCommands);
|
||||
rootCommands.add(userCommands);
|
||||
rootCommands.add(logCommands);
|
||||
rootCommands.add(debugCommands);
|
||||
|
||||
console.log("Loading Plugin info");
|
||||
let pluginInfos = await libPlugin.loadPluginInfos(pluginList);
|
||||
logger.verbose("Loading Plugin info");
|
||||
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
|
||||
@@ -639,7 +706,7 @@ async function startControl() {
|
||||
}
|
||||
|
||||
let { ControlPlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
|
||||
let controlPlugin = new ControlPlugin(pluginInfo);
|
||||
let controlPlugin = new ControlPlugin(pluginInfo, logger);
|
||||
controlPlugins.set(pluginInfo.name, controlPlugin);
|
||||
await controlPlugin.init();
|
||||
await controlPlugin.addCommands(rootCommands);
|
||||
@@ -658,14 +725,14 @@ async function startControl() {
|
||||
.argv
|
||||
;
|
||||
|
||||
console.log(`Loading config from ${args.config}`);
|
||||
logger.verbose(`Loading config from ${args.config}`);
|
||||
let controlConfig = new libConfig.ControlConfig();
|
||||
try {
|
||||
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Config not found, initializing new config");
|
||||
logger.verbose("Config not found, initializing new config");
|
||||
await controlConfig.init();
|
||||
|
||||
} else {
|
||||
@@ -680,7 +747,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;
|
||||
}
|
||||
|
||||
@@ -693,7 +760,7 @@ async function startControl() {
|
||||
|
||||
// The remaining commands require connecting to the master server.
|
||||
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
|
||||
console.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -714,25 +781,24 @@ async function startControl() {
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Caught interrupt signal, closing connection");
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
console.error(err);
|
||||
logger.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
let keepOpen = Boolean(args.keepOpen);
|
||||
try {
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
keepOpen = false;
|
||||
control.keepOpen = false;
|
||||
if (err instanceof libErrors.CommandError) {
|
||||
console.error(`Error running command: ${err.message}`);
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof libErrors.RequestError) {
|
||||
console.error(`Error sending request: ${err.message}`);
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
@@ -740,7 +806,7 @@ async function startControl() {
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!keepOpen) {
|
||||
if (!control.keepOpen) {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -748,13 +814,11 @@ async function startControl() {
|
||||
|
||||
module.exports = {
|
||||
Control,
|
||||
|
||||
// for testing only
|
||||
_formatOutputColored: formatOutputColored,
|
||||
};
|
||||
|
||||
|
||||
if (module === require.main) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
@@ -764,21 +828,22 @@ I version of clusterio. Expect things to break. I
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (!(err instanceof libErrors.StartupError)) {
|
||||
console.error(`
|
||||
logger.fatal(`
|
||||
+----------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+----------------------------------------------------------------+`
|
||||
+----------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else {
|
||||
console.error(`
|
||||
logger.error(`
|
||||
+---------------------------------+
|
||||
| Unable to to start clusterioctl |
|
||||
+---------------------------------+`
|
||||
+---------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"main": "ctl.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
@@ -18,10 +18,11 @@
|
||||
"node": ">=10.16 <11 || >= 12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.1",
|
||||
"@clusterio/lib": "^2.0.0-alpha.3",
|
||||
"as-table": "^1.0.31",
|
||||
"chalk": "^3.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"winston": "^3.3.3",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -5,4 +5,4 @@ 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.
|
||||
for more information on Clusterio.
|
||||
+22
-13
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
@@ -12,11 +13,10 @@ async function main() {
|
||||
.scriptName("build")
|
||||
.options({
|
||||
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
|
||||
"build": { describe: "Build mod", type: "boolean", default: true },
|
||||
"build": { describe: "Build mod(s)", type: "boolean", default: true },
|
||||
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
|
||||
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
|
||||
"output-dir": { describe: "Path to output built mod", nargs: 1, type: "string", default: "dist" },
|
||||
"bump-patch": { describe: "Increment patch number of build", type: "boolean", default: false },
|
||||
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
|
||||
"factorio-version": { describe: "Override factorio_version", type: "string" },
|
||||
})
|
||||
.strict()
|
||||
@@ -29,12 +29,6 @@ async function main() {
|
||||
async function build(args) {
|
||||
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
|
||||
|
||||
if (args.bumpPatch) {
|
||||
let [major, minor, patch] = info.version.split(".");
|
||||
patch = String(Number.parseInt(patch, 10) + 1);
|
||||
info.version = [major, minor, patch].join(".");
|
||||
}
|
||||
|
||||
if (args.factorioVersion) {
|
||||
info.factorio_version = args.factorioVersion;
|
||||
}
|
||||
@@ -58,6 +52,21 @@ async function build(args) {
|
||||
}
|
||||
}
|
||||
|
||||
if (info.variants) {
|
||||
for (let [variant, variantOverrides] of Object.entries(info.variants)) {
|
||||
let variantInfo = {
|
||||
...info,
|
||||
...variantOverrides,
|
||||
};
|
||||
delete variantInfo.variants;
|
||||
await buildMod(args, variantInfo);
|
||||
}
|
||||
} else {
|
||||
await buildMod(args, info);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildMod(args, info) {
|
||||
if (args.build) {
|
||||
await fs.ensureDir(args.outputDir);
|
||||
let modName = `${info.name}_${info.version}`;
|
||||
@@ -77,12 +86,12 @@ async function build(args) {
|
||||
;
|
||||
await events.once(walker, "end");
|
||||
|
||||
for (let pathParts of info.additional_files || []) {
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
|
||||
let filePath = path.join(args.sourceDir, ...pathParts);
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
throw new Error(`Additional file ${filePath} does not exist`);
|
||||
}
|
||||
zip.file(path.posix.join(modName, path.basename(filePath)), fs.createReadStream(filePath));
|
||||
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
@@ -101,9 +110,9 @@ async function build(args) {
|
||||
}
|
||||
console.log(`Building ${modDir}`);
|
||||
await fs.copy(args.sourceDir, modDir);
|
||||
for (let pathParts of info.additional_files || []) {
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
|
||||
let filePath = path.join(...pathParts);
|
||||
await fs.copy(filePath, path.join(modDir, path.basename(filePath)));
|
||||
await fs.copy(filePath, path.join(modDir, fileName));
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -80,7 +78,7 @@ MasterGroup.define({
|
||||
title: "Heartbeat Interval",
|
||||
description: "Interval heartbeats are sent out on WebSocket connections",
|
||||
type: "number",
|
||||
initial_value: 60,
|
||||
initial_value: 45,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "connector_shutdown_timeout",
|
||||
@@ -198,6 +196,12 @@ InstanceGroup.define({
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "auto_start",
|
||||
description: "Automatically start this instance when the slave hosting it is started up",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
InstanceGroup.finalize();
|
||||
|
||||
/**
|
||||
@@ -252,6 +256,18 @@ FactorioGroup.define({
|
||||
"auto_pause": false,
|
||||
},
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "verbose_logging",
|
||||
description: "Enable verbose logging on the Factorio server",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "strip_paths",
|
||||
description: "Strip down instance paths in the log",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "sync_adminlist",
|
||||
description: "Synchronize adminlist with master server",
|
||||
@@ -270,6 +286,12 @@ FactorioGroup.define({
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "max_concurrent_commands",
|
||||
description: "Maximum number of RCON commands trasmitted in parallel",
|
||||
type: "number",
|
||||
initial_value: 5,
|
||||
});
|
||||
FactorioGroup.finalize();
|
||||
|
||||
/**
|
||||
@@ -379,77 +401,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 +415,4 @@ module.exports = {
|
||||
|
||||
registerPluginConfigGroups,
|
||||
finalizeConfigs,
|
||||
configCommand,
|
||||
handleConfigCommand,
|
||||
};
|
||||
@@ -140,7 +140,7 @@ async function exportItems(server, modVersions, items) {
|
||||
iconCache.set(iconPath, icon);
|
||||
} else {
|
||||
icon = null;
|
||||
console.log(`Warning: ${iconPath} not found`);
|
||||
server._logger.warn(`${iconPath} not found`);
|
||||
}
|
||||
iconCache.set(iconPath, icon);
|
||||
}
|
||||
@@ -324,9 +324,9 @@ async function exportData(server) {
|
||||
|
||||
server.on("ipc-item_export", data => items.push(data));
|
||||
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
|
||||
server.on("output", data => {
|
||||
if (data.format === "seconds" && data.type === "generic") {
|
||||
let match = /^Checksum of (.*): \d+$/.exec(data.message);
|
||||
server.on("output", parsed => {
|
||||
if (parsed.format === "seconds" && parsed.type === "generic") {
|
||||
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
|
||||
if (match) {
|
||||
modOrder.push(match[1]);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
+100
-26
@@ -10,8 +10,25 @@ const crypto = require("crypto");
|
||||
const ini = require("ini");
|
||||
const rconClient = require("rcon-client");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
|
||||
|
||||
/**
|
||||
* Escapes text for inclusion in a RegExp
|
||||
*
|
||||
* Adds \ character in front of special meta characters in the passsed in
|
||||
* text so that it can be embedded into a RegExp and only match the text.
|
||||
*
|
||||
* See https://stackoverflow.com/a/9310752
|
||||
*
|
||||
* @param {string} text - Text to escape RegExp meta chars in.
|
||||
* @returns {string} escaped text.
|
||||
* @inner
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the version of Factorio the datadir is pointing to by
|
||||
* reading the changelog.txt in it.
|
||||
@@ -286,7 +303,6 @@ class LineSplitter {
|
||||
function parseOutput(line, source) {
|
||||
let output = {
|
||||
source,
|
||||
received: Date.now(),
|
||||
};
|
||||
|
||||
// There are three broad categories of output from Factorio, the first kind
|
||||
@@ -302,13 +318,15 @@ function parseOutput(line, source) {
|
||||
// A seconds output has two different kinds of formats: The first is a
|
||||
// log level and source location and has a format of
|
||||
// "Level File.cpp:123: message"
|
||||
const secLogRegex = /^(\w+) (\w+\.cpp:\d+): (.*)$/;
|
||||
// "Script @/path/to/file.lua:123: message"
|
||||
// "Script =(command):123: message"
|
||||
const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/;
|
||||
let secLogMatch = secLogRegex.exec(secContent);
|
||||
if (secLogMatch) {
|
||||
output.type = "log";
|
||||
output.level = secLogMatch[1];
|
||||
output.file = secLogMatch[2];
|
||||
output.message = secLogMatch[3];
|
||||
output.message = secLogMatch[5];
|
||||
|
||||
// The other possibility is that the content is a generic message
|
||||
} else {
|
||||
@@ -365,7 +383,7 @@ const outputHeuristics = [
|
||||
// Message indicating the RCON interface has started
|
||||
{
|
||||
filter: { type: "log", message: /^Starting RCON interface/ },
|
||||
action: function(output) {
|
||||
action: function() {
|
||||
this._startRcon().catch((err) => { this.emit("error", err); });
|
||||
},
|
||||
},
|
||||
@@ -373,7 +391,7 @@ const outputHeuristics = [
|
||||
// Message indicating the server is done starting up
|
||||
{
|
||||
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
|
||||
action: function(output) {
|
||||
action: function() {
|
||||
this._notifyGameReady().catch((err) => { this.emit("error", err); });
|
||||
},
|
||||
},
|
||||
@@ -384,8 +402,8 @@ const outputHeuristics = [
|
||||
type: "log",
|
||||
message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/,
|
||||
},
|
||||
action: function(output) {
|
||||
let name = /^Saving to (_autosave\d+) /.exec(output.message)[1];
|
||||
action: function(parsed) {
|
||||
let name = /^Saving to (_autosave\d+) /.exec(parsed.message)[1];
|
||||
this.emit("_autosave", name);
|
||||
},
|
||||
},
|
||||
@@ -396,7 +414,7 @@ const outputHeuristics = [
|
||||
type: "log",
|
||||
message: /^Saving game as /,
|
||||
},
|
||||
action: function(output) {
|
||||
action: function() {
|
||||
this.emit("_saving");
|
||||
},
|
||||
},
|
||||
@@ -412,7 +430,7 @@ const outputHeuristics = [
|
||||
"Can't save to default location: Default location not known",
|
||||
],
|
||||
},
|
||||
action: function(output) {
|
||||
action: function() {
|
||||
this.emit("_saved");
|
||||
},
|
||||
},
|
||||
@@ -423,13 +441,13 @@ const outputHeuristics = [
|
||||
type: "generic",
|
||||
message: /^Quitting: /,
|
||||
},
|
||||
action: function(output) {
|
||||
action: function(parsed) {
|
||||
this.emit("_quitting");
|
||||
|
||||
// On windows the Factorio server will wait for input on the console input buffer
|
||||
// when an error occurs. Since we can't send input there we terminate the server
|
||||
// process when this happens.
|
||||
if (process.platform === "win32" && output.message === "Quitting: multiplayer error.") {
|
||||
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
|
||||
this._server.kill();
|
||||
}
|
||||
},
|
||||
@@ -445,8 +463,8 @@ const outputHeuristics = [
|
||||
"MultiplayerManager failed: Host address is already in use.",
|
||||
],
|
||||
},
|
||||
action: function(output) {
|
||||
this._unexpected.push(`Factorio failed to bind to game port: ${output.message}`);
|
||||
action: function(parsed) {
|
||||
this._unexpected.push(`Factorio failed to bind to game port: ${parsed.message}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -458,8 +476,8 @@ const outputHeuristics = [
|
||||
"Can't bind socket: Permission denied",
|
||||
],
|
||||
},
|
||||
action: function(output) {
|
||||
this._unexpected.push(`Factorio failed to bind to RCON port: ${output.message}`);
|
||||
action: function(parsed) {
|
||||
this._unexpected.push(`Factorio failed to bind to RCON port: ${parsed.message}`);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -491,12 +509,19 @@ class FactorioServer extends events.EventEmitter {
|
||||
* @param {string} factorioDir - Directory of the Factorio install(s).
|
||||
* @param {string} writeDir - Directory to write runtime data to.
|
||||
* @param {object} options - Optional parameters.
|
||||
* @param {Logger} options.logger - Logger to use for reporting errors.
|
||||
* @param {string} options.version -
|
||||
* Version of Factorio to use. Can also be the string "latest" to
|
||||
* use the latest version found in `factorioDir`.
|
||||
* @param {number} options.gamePort - UDP port to host game on.
|
||||
* @param {number} options.rconPort - TCP port to use for RCON.
|
||||
* @param {string} options.rconPassword - Password use for RCON.
|
||||
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
|
||||
* @param {boolean} options.verboseLogging - Enable verbose logging.
|
||||
* @param {boolean} options.stripPaths - Strip paths in the console.
|
||||
* @param {number} options.maxConcurrentCommands -
|
||||
* Maximum number of RCON commands transmitted in parallel on the
|
||||
* RCON connection.
|
||||
*/
|
||||
constructor(factorioDir, writeDir, options) {
|
||||
super();
|
||||
@@ -508,6 +533,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
this._version = null;
|
||||
this._dataDir = null;
|
||||
|
||||
this._logger = options.logger || logger;
|
||||
this._targetVersion = options.version || "latest";
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
this.gamePort = options.gamePort || randomDynamicPort();
|
||||
@@ -517,12 +543,29 @@ class FactorioServer extends events.EventEmitter {
|
||||
this.rconPassword = options.rconPassword;
|
||||
/** Enable player whitelist */
|
||||
this.enableWhitelist = options.enableWhitelist;
|
||||
/** Enable verbose logging */
|
||||
this.verboseLogging = options.verboseLogging;
|
||||
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
|
||||
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
|
||||
this._state = "new";
|
||||
this._server = null;
|
||||
this._rconClient = null;
|
||||
this._rconReady = false;
|
||||
this._gameReady = false;
|
||||
|
||||
if (options.stripPaths) {
|
||||
let chars = new Set(this.writePath("temp"));
|
||||
chars.delete(":"); // Having a colon could lead to matching the line number
|
||||
chars = [...chars].join("");
|
||||
|
||||
this._stripRegExp = new RegExp(
|
||||
`(${escapeRegExp(this.writePath("temp", "currently-playing", path.sep))})|` +
|
||||
`(${escapeRegExp(this.writePath(path.sep))})|` +
|
||||
`(\\.\\.\\.[${escapeRegExp(chars)}]*?currently-playing\\${path.sep})`,
|
||||
"g"
|
||||
);
|
||||
}
|
||||
|
||||
// Array of possible causes for an unexpected shutdown
|
||||
this._unexpected = [];
|
||||
|
||||
@@ -594,7 +637,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
|
||||
if (!this.emit(`ipc-${channel}`, content)) {
|
||||
console.warn(`Warning: Unhandled ipc-${channel}`, content);
|
||||
this.logger.warn(`Warning: Unhandled ipc-${channel}`, { content });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,31 +649,37 @@ class FactorioServer extends events.EventEmitter {
|
||||
|
||||
this.emit(source, line);
|
||||
|
||||
let output = parseOutput(line.toString("utf-8"), source);
|
||||
line = line.toString("utf-8");
|
||||
if (this._stripRegExp) {
|
||||
line = line.replace(this._stripRegExp, "");
|
||||
}
|
||||
|
||||
|
||||
let parsed = parseOutput(line, source);
|
||||
heuristicLoop: for (let heuristic of outputHeuristics) {
|
||||
for (let [name, expected] of Object.entries(heuristic.filter)) {
|
||||
if (!Object.hasOwnProperty.call(output, name)) {
|
||||
if (!Object.hasOwnProperty.call(parsed, name)) {
|
||||
continue heuristicLoop;
|
||||
}
|
||||
|
||||
if (expected instanceof RegExp) {
|
||||
if (!expected.test(output[name])) {
|
||||
if (!expected.test(parsed[name])) {
|
||||
continue heuristicLoop;
|
||||
}
|
||||
} else if (expected instanceof Array) {
|
||||
if (!expected.includes(output[name])) {
|
||||
if (!expected.includes(parsed[name])) {
|
||||
continue heuristicLoop;
|
||||
}
|
||||
} else if (expected !== output[name]) {
|
||||
} else if (expected !== parsed[name]) {
|
||||
continue heuristicLoop;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here the filter matched the output
|
||||
heuristic.action.call(this, output);
|
||||
heuristic.action.call(this, parsed);
|
||||
}
|
||||
|
||||
this.emit("output", output);
|
||||
this.emit("output", parsed, line);
|
||||
}
|
||||
|
||||
async _startRcon() {
|
||||
@@ -647,7 +696,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
port: this.rconPort,
|
||||
password: this.rconPassword,
|
||||
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
|
||||
maxPending: 5,
|
||||
maxPending: this.maxConcurrentCommands,
|
||||
};
|
||||
|
||||
this._rconClient = new rconClient.Rcon(config);
|
||||
@@ -658,9 +707,31 @@ class FactorioServer extends events.EventEmitter {
|
||||
this._rconReady = false;
|
||||
this._startRcon().catch(() => { /* Ignore */ });
|
||||
});
|
||||
// XXX: Workaround to suppress bogus event listener warning
|
||||
if (this._rconClient.emitter instanceof events.EventEmitter) {
|
||||
this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5);
|
||||
}
|
||||
await this._rconClient.connect();
|
||||
}
|
||||
|
||||
get maxConcurrentCommands() {
|
||||
return this._maxConcurrentCommands;
|
||||
}
|
||||
|
||||
set maxConcurrentCommands(value) {
|
||||
this._maxConcurrentCommands = value;
|
||||
this.setMaxListeners(value + 5);
|
||||
if (this._rconClient) {
|
||||
if (this._rconClient.sendQueue) {
|
||||
this._rconClient.sendQueue.maxConcurrent = value;
|
||||
}
|
||||
// XXX: Workaround to suppress bogus event listener warning
|
||||
if (this._rconClient.emitter instanceof events.EventEmitter) {
|
||||
this._rconClient.emitter.setMaxListeners(value + 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
@@ -774,6 +845,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
[
|
||||
"--config", this.writePath("config.ini"),
|
||||
"--create", this.writePath("saves", name),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
@@ -816,6 +888,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
"--rcon-port", this.rconPort,
|
||||
"--rcon-password", this.rconPassword,
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
@@ -850,6 +923,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
"--rcon-port", this.rconPort,
|
||||
"--rcon-password", this.rconPassword,
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
@@ -898,11 +972,11 @@ class FactorioServer extends events.EventEmitter {
|
||||
this._check(["running"]);
|
||||
|
||||
let hanged = true;
|
||||
function setAlive(output) { hanged = false; }
|
||||
function setAlive() { hanged = false; }
|
||||
|
||||
let timeoutId = setTimeout(() => {
|
||||
if (hanged) {
|
||||
console.error("Factorio appears to have hanged, sending SIGKILL");
|
||||
logger.error("Factorio appears to have hanged, sending SIGKILL");
|
||||
if (this._rconClient) {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,41 @@ function basicType(value) {
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asynchronously wait for the given duration
|
||||
*
|
||||
* @param {number} duration - Time to wait for in milliseconds.
|
||||
*/
|
||||
async function wait(duration) {
|
||||
await new Promise(resolve => { setTimeout(resolve, duration); });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a promise with a timeout.
|
||||
*
|
||||
* @param {Promise} promise - Promise to wait for.
|
||||
* @param {number} time - Maximum time im milliseconds to wait for.
|
||||
* @param {*=} timeoutResult - Value to return if the operation timed out.
|
||||
*/
|
||||
async function timeout(promise, time, timeoutResult) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise(resolve => {
|
||||
timer = setTimeout(() => resolve(timeoutResult), time);
|
||||
}),
|
||||
]);
|
||||
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
basicType,
|
||||
wait,
|
||||
timeout,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const WebSocket = require("isomorphic-ws");
|
||||
|
||||
const libSchema = require("@clusterio/lib/schema");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
|
||||
|
||||
/**
|
||||
@@ -34,7 +35,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;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
_doHeartbeat() {
|
||||
this._check(["connected", "closing"]);
|
||||
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
|
||||
console.log("SOCKET | closing after heartbeat timed out");
|
||||
logger.verbose("SOCKET | closing after heartbeat timed out");
|
||||
this._socket.close(1008, "Heartbeat timeout");
|
||||
clearInterval(this._heartbeatId);
|
||||
this._heartbeatId = null;
|
||||
@@ -61,9 +62,8 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
|
||||
_processHeartbeat(message) {
|
||||
if (!libSchema.heartbeat(message)) {
|
||||
console.log("SOCKET | closing after received invalid heartbeat:", message);
|
||||
logger.warn("SOCKET | closing after received invalid heartbeat");
|
||||
this._socket.close(1002, "Invalid heartbeat");
|
||||
console.log(libSchema.heartbeat.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +236,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
url.pathname += "api/socket";
|
||||
|
||||
// Open WebSocket to master
|
||||
console.log(`SOCKET | connecting to ${url}`);
|
||||
logger.verbose(`SOCKET | connecting to ${url}`);
|
||||
|
||||
// eslint-disable-next-line no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
@@ -264,7 +266,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
if (this._startedReconnect + this._timeout * 1000 < Date.now()) {
|
||||
console.log("SOCKET | Timed out trying to reconnect");
|
||||
logger.error("SOCKET | Timed out trying to reconnect");
|
||||
this._heartbeatInterval = null;
|
||||
this._lastReceivedSeq = null;
|
||||
this._sessionToken = null;
|
||||
@@ -275,7 +277,9 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
let delay = Math.random() * this._reconnectDelay;
|
||||
console.log(`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`);
|
||||
logger.verbose(
|
||||
`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`
|
||||
);
|
||||
this._reconnectId = setTimeout(() => {
|
||||
this._reconnectId = null;
|
||||
this._doConnect();
|
||||
@@ -284,7 +288,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
|
||||
_attachSocketHandlers() {
|
||||
this._socket.onclose = event => {
|
||||
console.log(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
|
||||
logger.verbose(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
|
||||
// Authentication failed
|
||||
if (event.code === 4003) {
|
||||
this.emit("error", new libErrors.AuthenticationFailed(event.reason));
|
||||
@@ -313,11 +317,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
|
||||
this._socket.onerror = event => {
|
||||
// It's assumed that close is always called by ws
|
||||
console.error("SOCKET | Error:", event);
|
||||
logger.error(`SOCKET | Error: ${event.message || "unknown error"}`);
|
||||
};
|
||||
|
||||
this._socket.onopen = () => {
|
||||
console.log("SOCKET | Open");
|
||||
logger.verbose("SOCKET | Open");
|
||||
};
|
||||
|
||||
// Handle messages
|
||||
@@ -346,17 +350,17 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
|
||||
_processHandshake(message) {
|
||||
if (!libSchema.serverHandshake(message)) {
|
||||
console.log("SOCKET | closing after received invalid handshake:", message);
|
||||
logger.verbose("SOCKET | closing after received invalid handshake");
|
||||
this._socket.close(1002, "Invalid handshake");
|
||||
return;
|
||||
}
|
||||
|
||||
let { seq, type, data } = message;
|
||||
if (type === "hello") {
|
||||
console.log(`SOCKET | received hello from master version ${data.version}`);
|
||||
logger.verbose(`SOCKET | received hello from master version ${data.version}`);
|
||||
this.emit("hello", data);
|
||||
if (this._sessionToken) {
|
||||
console.log("SOCKET | Attempting resume");
|
||||
logger.verbose("SOCKET | Attempting resume");
|
||||
this.sendHandshake("resume", {
|
||||
session_token: this._sessionToken,
|
||||
last_seq: this._lastReceivedSeq,
|
||||
@@ -366,7 +370,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
} else if (type === "ready") {
|
||||
console.log("SOCKET | received ready from master");
|
||||
logger.verbose("SOCKET | received ready from master");
|
||||
this._state = "connected";
|
||||
this._sessionToken = data.session_token;
|
||||
this._heartbeatInterval = data.heartbeat_interval;
|
||||
@@ -379,7 +383,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this.emit("connect");
|
||||
|
||||
} else if (type === "continue") {
|
||||
console.log("SOCKET | resuming existing session");
|
||||
logger.verbose("SOCKET | resuming existing session");
|
||||
this._state = "connected";
|
||||
this._heartbeatInterval = data.heartbeat_interval;
|
||||
this.startHeartbeat();
|
||||
@@ -450,8 +454,10 @@ class VirtualConnector extends events.EventEmitter {
|
||||
* @returns {number} the sequence number of the message sent
|
||||
*/
|
||||
send(type, data) {
|
||||
this.other.emit("message", { seq: this._seq, type, data });
|
||||
return this._seq++;
|
||||
let seq = this._seq;
|
||||
this._seq += 1;
|
||||
this.other.emit("message", { seq, type, data });
|
||||
return seq;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
const libSchema = require("@clusterio/lib/schema");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
const messages = require("./messages");
|
||||
|
||||
// Some definitions for the terminology used here:
|
||||
@@ -35,9 +36,21 @@ class Link {
|
||||
this.processMessage(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof libErrors.InvalidMessage) {
|
||||
console.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
|
||||
logger.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
|
||||
if (err.errors) {
|
||||
console.error(err.errors);
|
||||
logger.error(JSON.stringify(err.errors, null, 4));
|
||||
}
|
||||
|
||||
// Send back an error response if this was a request.
|
||||
if (
|
||||
typeof payload.type === "string"
|
||||
&& payload.type.endsWith("_request")
|
||||
&& Number.isInteger(payload.seq)
|
||||
) {
|
||||
this.connector.send(
|
||||
`${payload.type.slice(0, -8)}_response`,
|
||||
{ seq: payload.seq, error: err.message }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.connector.emit("error", err);
|
||||
|
||||
+135
-77
@@ -2,6 +2,26 @@
|
||||
"use strict";
|
||||
const libSchema = require("@clusterio/lib/schema");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
|
||||
class MissingLinkHandlerError extends Error {
|
||||
constructor(type, source, target) {
|
||||
super();
|
||||
this.code = "MISSING_LINK_HANDLER";
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
this.handler = null;
|
||||
this.plugin = null;
|
||||
}
|
||||
|
||||
get message() {
|
||||
let handler = this.handler ? `${this.handler}` : "handler";
|
||||
let plugin = this.plugin ? ` on plugin ${this.plugin}` : "";
|
||||
|
||||
return `Missing ${handler}${plugin} for ${this.type} on ${this.source}-${this.target} link`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a message that can be sent over the link
|
||||
@@ -168,7 +188,7 @@ class Request extends Message {
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
|
||||
throw new MissingLinkHandlerError(this.requestType, link.source, link.target);
|
||||
}
|
||||
|
||||
// Check permission if this is a handler for a control connection on the master
|
||||
@@ -188,7 +208,7 @@ class Request extends Message {
|
||||
}
|
||||
|
||||
if (!this._responseValidator({ seq: 0, type: this.responseType, response })) {
|
||||
console.error(this._responseValidator.errors);
|
||||
logger.error(JSON.stringify(this._responseValidator.errors, null, 4));
|
||||
throw new Error(`Validation failed responding to ${this.requestType}`);
|
||||
}
|
||||
|
||||
@@ -196,7 +216,7 @@ class Request extends Message {
|
||||
|
||||
}).catch(err => {
|
||||
if (!(err instanceof libErrors.RequestError)) {
|
||||
console.error(`Unexpected error while responding to ${this.requestType}`, err);
|
||||
logger.error(`Unexpected error while responding to ${this.requestType}:\n${err.stack}`);
|
||||
}
|
||||
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
|
||||
});
|
||||
@@ -217,7 +237,7 @@ class Request extends Message {
|
||||
async send(link, data = {}) {
|
||||
// XXX validate link target/source?
|
||||
if (!this._requestValidator({ seq: 0, type: this.requestType, data })) {
|
||||
console.error(this._requestValidator.errors);
|
||||
logger.error(JSON.stringify(this._requestValidator.errors, null, 4));
|
||||
throw new Error(`Validation failed sending ${this.requestType}`);
|
||||
}
|
||||
let seq = link.connector.send(this.requestType, data);
|
||||
@@ -330,18 +350,6 @@ messages.createSlaveConfig = new Request({
|
||||
},
|
||||
});
|
||||
|
||||
messages.setInstanceOutputSubscriptions = new Request({
|
||||
type: "set_instance_output_subscriptions",
|
||||
links: ["control-master"],
|
||||
permission: "core.instance.follow_log",
|
||||
requestProperties: {
|
||||
"instance_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.createInstance = new Request({
|
||||
type: "create_instance",
|
||||
links: ["control-master"],
|
||||
@@ -392,19 +400,10 @@ messages.assignInstanceCommand = new Request({
|
||||
permission: "core.instance.assign",
|
||||
requestProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"slave_id": { type: "integer" },
|
||||
"slave_id": { type: ["integer", "null"] },
|
||||
},
|
||||
});
|
||||
|
||||
messages.assignInstance = new Request({
|
||||
type: "assign_instance",
|
||||
links: ["master-slave"],
|
||||
requestProperties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
forwardTo: "instance",
|
||||
});
|
||||
|
||||
messages.startInstance = new Request({
|
||||
type: "start_instance",
|
||||
links: ["control-master", "master-slave", "slave-instance"],
|
||||
@@ -636,8 +635,90 @@ messages.deleteUser = new Request({
|
||||
},
|
||||
});
|
||||
|
||||
messages.setLogSubscriptions = new Request({
|
||||
type: "set_log_subscriptions",
|
||||
links: ["control-master"],
|
||||
permission: "core.log.follow",
|
||||
requestProperties: {
|
||||
"all": { type: "boolean" },
|
||||
"master": { type: "boolean" },
|
||||
"slave_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
"instance_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
"max_level": { type: ["string", "null"] },
|
||||
},
|
||||
});
|
||||
|
||||
messages.queryLog = new Request({
|
||||
type: "query_log",
|
||||
links: ["control-master"],
|
||||
permission: "core.log.query",
|
||||
requestProperties: {
|
||||
"all": { type: "boolean" },
|
||||
"master": { type: "boolean" },
|
||||
"slave_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
"instance_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
"max_level": { type: ["string", "null"] },
|
||||
},
|
||||
responseProperties: {
|
||||
"log": {
|
||||
type: "array",
|
||||
items: { type: "object" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Internal requests
|
||||
messages.updateInstances = new Request({
|
||||
type: "update_instances",
|
||||
links: ["slave-master"],
|
||||
requestProperties: {
|
||||
"instances": {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["serialized_config", "status"],
|
||||
properties: {
|
||||
"serialized_config": { type: "object" },
|
||||
"status": { enum: [
|
||||
"stopped", "starting", "running", "creating_save", "exporting_data",
|
||||
]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.assignInstance = new Request({
|
||||
type: "assign_instance",
|
||||
links: ["master-slave"],
|
||||
requestProperties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
forwardTo: "instance",
|
||||
});
|
||||
|
||||
messages.unassignInstance = new Request({
|
||||
type: "unassigne_instance",
|
||||
links: ["master-slave"],
|
||||
forwardTo: "instance",
|
||||
});
|
||||
|
||||
messages.getMetrics = new Request({
|
||||
type: "statistics_exporter:get_metrics",
|
||||
type: "get_metrics",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
responseProperties: {
|
||||
"results": { type: "array" },
|
||||
@@ -766,13 +847,13 @@ class Event extends Message {
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
|
||||
throw new MissingLinkHandlerError(this.eventType, link.source, link.target);
|
||||
}
|
||||
|
||||
link.setHandler(this.eventType, message => {
|
||||
// XXX Should event handlers even be allowed to be async?
|
||||
handler.call(link, message, this).catch(err => {
|
||||
console.error(`Unexpected error while handling ${this.eventType}`, err);
|
||||
logger.error(`Unexpected error while handling ${this.eventType}:\n${err.stack}`);
|
||||
});
|
||||
}, this._eventValidator);
|
||||
}
|
||||
@@ -788,7 +869,7 @@ class Event extends Message {
|
||||
*/
|
||||
send(link, data = {}) {
|
||||
if (!this._eventValidator({ seq: 0, type: this.eventType, data })) {
|
||||
console.error(this._eventValidator.errors);
|
||||
logger.error(JSON.stringify(this._eventValidator.errors, null, 4));
|
||||
throw new Error(`Validation failed sending ${this.eventType}`);
|
||||
}
|
||||
|
||||
@@ -805,6 +886,21 @@ messages.debugWsMessage = new Event({
|
||||
},
|
||||
});
|
||||
|
||||
messages.logMessage = new Event({
|
||||
type: "log_message",
|
||||
links: ["slave-master", "master-control"],
|
||||
eventProperties: {
|
||||
"info": {
|
||||
type: "object",
|
||||
required: ["level", "message"],
|
||||
properties: {
|
||||
"level": { type: "string" },
|
||||
"message": { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.instanceInitialized = new Event({
|
||||
type: "instance_initialized",
|
||||
links: ["instance-slave"],
|
||||
@@ -827,52 +923,6 @@ messages.instanceStatusChanged = new Event({
|
||||
},
|
||||
});
|
||||
|
||||
messages.updateInstances = new Event({
|
||||
type: "update_instances",
|
||||
links: ["slave-master"],
|
||||
eventProperties: {
|
||||
"instances": {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["serialized_config", "status"],
|
||||
properties: {
|
||||
"serialized_config": { type: "object" },
|
||||
"status": { enum: [
|
||||
"stopped", "starting", "running", "creating_save", "exporting_data",
|
||||
]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.instanceOutput = new Event({
|
||||
type: "instance_output",
|
||||
links: ["instance-slave", "slave-master", "master-control"],
|
||||
forwardTo: "master",
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"output": {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source", "received", "format", "type", "message"],
|
||||
properties: {
|
||||
"source": { type: "string" },
|
||||
"received": { type: "number" },
|
||||
"format": { type: "string" },
|
||||
"time": { type: "string" },
|
||||
"level": { type: "string" },
|
||||
"file": { type: "string" },
|
||||
"type": { type: "string" },
|
||||
"action": { type: "string" },
|
||||
"message": { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.masterConnectionEvent = new Event({
|
||||
type: "master_connection_event",
|
||||
links: ["slave-instance"],
|
||||
@@ -959,7 +1009,15 @@ messages.playerEvent = new Event({
|
||||
*/
|
||||
function attachAllMessages(link) {
|
||||
for (let [name, message] of Object.entries(messages)) {
|
||||
message.attach(link, link[name + message.handlerSuffix]);
|
||||
let handler = `${name}${message.handlerSuffix}`;
|
||||
try {
|
||||
message.attach(link, link[handler]);
|
||||
} catch (err) {
|
||||
if (err.code === "MISSING_LINK_HANDLER") {
|
||||
err.handler = handler;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
const winston = require("winston");
|
||||
const Transport = require("winston-transport");
|
||||
const { LEVEL, MESSAGE } = require("triple-beam");
|
||||
|
||||
const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
audit: 3,
|
||||
info: 4,
|
||||
server: 5,
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold brightRed",
|
||||
warn: "bold brightYellow",
|
||||
audit: "bold brightGreen",
|
||||
info: "bold brightBlue",
|
||||
server: "bold",
|
||||
verbose: "bold grey",
|
||||
};
|
||||
winston.addColors(colors);
|
||||
|
||||
// The Console Transport is replicated here because it's overly complicated
|
||||
// in winston, writes to stdout/err instead of console and doesn't work in
|
||||
// the browser.
|
||||
class ConsoleTransport extends Transport {
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
this.errorLevels = new Set(options.errorLevels || ["fatal", "error"]);
|
||||
this.warnLevels = new Set(options.warnLevels || ["warn"]);
|
||||
}
|
||||
|
||||
log(info, callback) {
|
||||
/* eslint-disable no-console */
|
||||
if (this.errorLevels.has(info[LEVEL])) {
|
||||
console.error(info[MESSAGE]);
|
||||
} else if (this.warnLevels.has(info[LEVEL])) {
|
||||
console.warn(info[MESSAGE]);
|
||||
} else {
|
||||
console.log(info[MESSAGE]);
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats winston log messages for the web console
|
||||
*/
|
||||
class WebConsoleFormat {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
transform(info, options) {
|
||||
let src = " ";
|
||||
if (info.slave_id) {
|
||||
let slaveName = info.slave_id;
|
||||
src += `s:${slaveName} - `;
|
||||
}
|
||||
if (info.instance_id) {
|
||||
let instanceName = info.instance_id;
|
||||
src += `i:${instanceName}`;
|
||||
}
|
||||
if (info.plugin) {
|
||||
src += `${info.plugin}: `;
|
||||
}
|
||||
|
||||
if (info.stack) {
|
||||
info[MESSAGE] = `[${info.level}]${src}${info.stack}`;
|
||||
} else {
|
||||
info[MESSAGE] = `[${info.level}]${src}${info.message}`;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: "verbose",
|
||||
levels,
|
||||
format: winston.format.timestamp(),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ConsoleTransport,
|
||||
WebConsoleFormat,
|
||||
colors,
|
||||
levels,
|
||||
logger,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
"use strict";
|
||||
const winston = require("winston");
|
||||
const Transport = require("winston-transport");
|
||||
const { LEVEL, MESSAGE } = require("triple-beam");
|
||||
const chalk = require("chalk");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
|
||||
/**
|
||||
* Format a parsed Factorio message with colors
|
||||
*
|
||||
* Formats a parsed Factorio output from lib/factorio into a readable
|
||||
* colorized output using terminal escape codes that can be printed.
|
||||
*
|
||||
* @param {Object} parsed - Parsed Factorio server output.
|
||||
* @returns {string} terminal colorized message.
|
||||
* @private
|
||||
*/
|
||||
function formatServerOutput(parsed) {
|
||||
let time = "";
|
||||
if (parsed.format === "seconds") {
|
||||
time = chalk.yellow(parsed.time.padStart(8)) + " ";
|
||||
} else if (parsed.format === "date") {
|
||||
time = chalk.yellow(parsed.time) + " ";
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (parsed.type === "log") {
|
||||
let level = parsed.level;
|
||||
if (level === "Script") {
|
||||
level = chalk.bold.greenBright(level);
|
||||
} else if (level === "Verbose") {
|
||||
level = chalk.bold.gray(level);
|
||||
} else if (level === "Info") {
|
||||
level = chalk.bold.blueBright(level);
|
||||
} else if (parsed.level === "Warning") {
|
||||
level = chalk.bold.yellowBright(level);
|
||||
} else if (parsed.level === "Error") {
|
||||
level = chalk.bold.redBright(level);
|
||||
}
|
||||
|
||||
info = level + " " + chalk.gray(parsed.file) + ": ";
|
||||
|
||||
} else if (parsed.type === "action") {
|
||||
info = "[" + chalk.yellow(parsed.action) + "] ";
|
||||
}
|
||||
|
||||
return time + info + parsed.message;
|
||||
}
|
||||
|
||||
// These are defined here to avoid circular dependencies and pulling them
|
||||
// into the web interface code.
|
||||
|
||||
/**
|
||||
* Formats winston log messages for a character terminal.
|
||||
*/
|
||||
class TerminalFormat {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.colorize = winston.format.colorize(options);
|
||||
}
|
||||
|
||||
transform(info, options) {
|
||||
info = this.colorize.transform(info, this.colorize.options);
|
||||
let ts = "";
|
||||
if (options.showTimestamp && info.timestamp) {
|
||||
ts = `${info.timestamp.replace("T", " ")} `;
|
||||
}
|
||||
|
||||
let src = " ";
|
||||
if (info.instance_name) {
|
||||
src += `i:${info.instance_name} `;
|
||||
|
||||
} else if (info.slave_name) {
|
||||
src += `s:${info.slave_name} `;
|
||||
}
|
||||
if (info.plugin) {
|
||||
src += `${info.plugin}: `;
|
||||
}
|
||||
|
||||
if (info[LEVEL] === "server" && info.parsed) {
|
||||
info.message = formatServerOutput(info.parsed);
|
||||
}
|
||||
|
||||
if (info.stack) {
|
||||
info[MESSAGE] = `${ts}[${info.level}]${src}${info.stack}`;
|
||||
} else {
|
||||
info[MESSAGE] = `${ts}[${info.level}]${src}${info.message}`;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends logs over a lib/link connection.
|
||||
*/
|
||||
class LinkTransport extends Transport {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.link = options.link;
|
||||
this.filter = options.filter || null;
|
||||
}
|
||||
|
||||
log(info, callback) {
|
||||
if (this.filter && !this.filter(info)) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
try {
|
||||
libLink.messages.logMessage.send(this.link, { info });
|
||||
} catch (err) {
|
||||
// Ignore session lost errors.
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return callback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
TerminalFormat,
|
||||
LinkTransport,
|
||||
|
||||
// for testing only
|
||||
_formatServerOutput: formatServerOutput,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"repository": "https://github.com/clusterio/factorioClusterio",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@@ -22,6 +22,9 @@
|
||||
"node-forge": "^0.10.0",
|
||||
"rcon-client": "^4.2.0",
|
||||
"semver": "^7.1.1",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-transport": "^4.4.0",
|
||||
"ws": "^7.2.3",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
|
||||
+61
-62
@@ -3,9 +3,6 @@
|
||||
* @module lib/plugin
|
||||
*/
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
|
||||
/**
|
||||
@@ -54,6 +51,15 @@ class BaseInstancePlugin {
|
||||
* @type {module:slave/slave~Slave}
|
||||
*/
|
||||
this.slave = slave;
|
||||
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
this.logger = instance.logger.child({ plugin: this.info.name });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,23 +126,24 @@ class BaseInstancePlugin {
|
||||
* stderr. The output is a parsed form of the line and contains the
|
||||
* following properties:
|
||||
*
|
||||
* @param {Object} output - parsed server output.
|
||||
* @param {string} output.source -
|
||||
* @param {Object} parsed - parsed server output.
|
||||
* @param {string} parsed.source -
|
||||
* Where the message came from, one of "stdout" and "stderr".
|
||||
* @param {string} output.format -
|
||||
* @param {string} parsed.format -
|
||||
* Timestamp format, one of "date", "seconds" and "none".
|
||||
* @param {string=} output.time -
|
||||
* @param {string=} parsed.time -
|
||||
* Timestamp of the message. Not present if format is "none".
|
||||
* @param {string} output.type -
|
||||
* @param {string} parsed.type -
|
||||
* Type of message, one of "log", "action" and "generic".
|
||||
* @param {string=} output.level -
|
||||
* @param {string=} parsed.level -
|
||||
* Log level for "log" type. i.e "Info" normally.
|
||||
* @param {string=} output.file - File reported for "log" type.
|
||||
* @param {string=} output.action -
|
||||
* @param {string=} parsed.file - File reported for "log" type.
|
||||
* @param {string=} parsed.action -
|
||||
* Kind of action for "action" type. i.e "CHAT" for chat.
|
||||
* @param {string} output.message - Main content of the line.
|
||||
* @param {string} parsed.message - Main content of the line.
|
||||
* @param {string} line - raw line of server output.
|
||||
*/
|
||||
async onOutput(output) { }
|
||||
async onOutput(parsed, line) { }
|
||||
|
||||
/**
|
||||
* Called when an event on the master connection happens
|
||||
@@ -195,6 +202,10 @@ class BaseInstancePlugin {
|
||||
* @param {string} event.name - Name of the player that joined/left.
|
||||
*/
|
||||
async onPlayerEvent(event) { }
|
||||
|
||||
async sendRcon(message, expectEmpty) {
|
||||
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,10 +220,19 @@ class BaseInstancePlugin {
|
||||
* @static
|
||||
*/
|
||||
class BaseMasterPlugin {
|
||||
constructor(info, master, metrics) {
|
||||
constructor(info, master, metrics, logger) {
|
||||
this.info = info;
|
||||
this.master = master;
|
||||
this.metrics = metrics;
|
||||
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
this.logger = logger.child({ plugin: this.info.name });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,11 +381,20 @@ class BaseMasterPlugin {
|
||||
* @static
|
||||
*/
|
||||
class BaseControlPlugin {
|
||||
constructor(info) {
|
||||
constructor(info, logger) {
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
this.info = info;
|
||||
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
this.logger = logger.child({ plugin: this.info.name });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,45 +415,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
|
||||
*
|
||||
@@ -444,13 +434,23 @@ function attachPluginMessages(link, pluginInfo, plugin) {
|
||||
}
|
||||
|
||||
let handler = name + messageFormat.handlerSuffix;
|
||||
if (plugin === null || !plugin[handler]) {
|
||||
messageFormat.attach(link);
|
||||
try {
|
||||
if (plugin === null || !plugin[handler]) {
|
||||
messageFormat.attach(link);
|
||||
|
||||
} else {
|
||||
messageFormat.attach(link, async function(message, format) {
|
||||
return await plugin[handler](message, format, this);
|
||||
});
|
||||
} else {
|
||||
messageFormat.attach(link, async function(message, format) {
|
||||
return await plugin[handler](message, format, this);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "MISSING_LINK_HANDLER") {
|
||||
err.plugin = pluginInfo.name;
|
||||
err.handler = handler;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,7 +473,7 @@ async function invokeHook(plugins, hook, ...args) {
|
||||
results.push(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Ignoring error from plugin ${name} in ${hook}:`, err);
|
||||
plugin.logger.error(`Ignoring error from plugin ${name} in ${hook}:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
@@ -485,7 +485,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,
|
||||
};
|
||||
+1166
-53
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,15 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
|
||||
|
||||
function print(...content) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yargs plugin command
|
||||
*
|
||||
@@ -47,40 +56,112 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
|
||||
try {
|
||||
pluginInfo = require(path.posix.join(pluginPath, "info"));
|
||||
} catch (err) {
|
||||
console.error(`Unable to import plugin info from ${args.path}`);
|
||||
console.error(err);
|
||||
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginList.has(pluginInfo.name)) {
|
||||
console.error(`Plugin with the same ${pluginInfo.name} already exists`);
|
||||
logger.error(`Plugin with the same ${pluginInfo.name} already exists`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
pluginList.set(pluginInfo.name, pluginPath);
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
console.log(`Added ${pluginInfo.name}`);
|
||||
print(`Added ${pluginInfo.name}`);
|
||||
|
||||
} else if (command === "remove") {
|
||||
if (!pluginList.delete(args.name)) {
|
||||
console.error(`Plugin with name ${args.name} does not exist`);
|
||||
logger.error(`Plugin with name ${args.name} does not exist`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
console.log(`Removed ${args.name}`);
|
||||
print(`Removed ${args.name}`);
|
||||
|
||||
} else if (command === "list") {
|
||||
for (let [pluginName, pluginPath] of pluginList) {
|
||||
console.log(`${pluginName} - ${pluginPath}`);
|
||||
print(`${pluginName} - ${pluginPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
print(`${def.fullName} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (command === "show") {
|
||||
try {
|
||||
print(instance.get(args.field));
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField) {
|
||||
logger.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) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
pluginCommand,
|
||||
handlePluginCommand,
|
||||
|
||||
configCommand,
|
||||
handleConfigCommand,
|
||||
};
|
||||
+11
-6
@@ -102,7 +102,7 @@ class Role {
|
||||
function ensureDefaultAdminRole(roles) {
|
||||
let admin = roles.get(0);
|
||||
if (!admin) {
|
||||
admin = new Role({ id: 0, name: "Admin", description: "Cluster wide administrator." });
|
||||
admin = new Role({ id: 0, name: "Cluster Admin", description: "Cluster wide administrator." });
|
||||
roles.set(0, admin);
|
||||
}
|
||||
admin.permissions.add("core.admin");
|
||||
@@ -345,11 +345,6 @@ definePermission({
|
||||
title: "Assign instance",
|
||||
description: "Assign or reassign instances to a slave.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.follow_log",
|
||||
title: "Follow instance log",
|
||||
description: "Listen for the stdout log of instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.create_save",
|
||||
title: "Create new instance save",
|
||||
@@ -450,6 +445,16 @@ definePermission({
|
||||
title: "Delete user",
|
||||
description: "Delete users and all data stored for them.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.log.follow",
|
||||
title: "Follow cluster log",
|
||||
description: "Receive new entries in the cluster log. Required to see instance console.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.log.query",
|
||||
title: "Query cluster log",
|
||||
description: "Query past entries in the cluster log. Required to see past entries in instance console.",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.debug.dump_ws",
|
||||
|
||||
@@ -110,6 +110,13 @@ exist in the cluster and grant it full cluster admin permission. You
|
||||
should be using your own Factorio multiplayer username here.
|
||||
|
||||
|
||||
#### `bootstrap generate-user-token <name>`
|
||||
|
||||
Generate an access token for logging in and possibly managing the
|
||||
cluster from the given user account. This can be used both in the web
|
||||
interface and as the `master_token` to connect with clusterioctl.
|
||||
|
||||
|
||||
#### `bootstrap create-ctl-config <name>`
|
||||
|
||||
Creates a clusterioctl config for the given user with url and token set
|
||||
|
||||
+328
-111
@@ -30,6 +30,7 @@ const WebSocket = require("ws");
|
||||
const JSZip = require("jszip");
|
||||
const version = require("./package").version;
|
||||
const util = require("util");
|
||||
const winston = require("winston");
|
||||
|
||||
// ugly globals
|
||||
let masterConfig;
|
||||
@@ -40,6 +41,11 @@ let stopAcceptingNewSessions = false;
|
||||
let debugEvents = new events.EventEmitter();
|
||||
let loadedPlugins = {};
|
||||
let devMiddleware;
|
||||
let clusterLogger;
|
||||
let db = {
|
||||
instances: new Map(),
|
||||
slaves: new Map(),
|
||||
};
|
||||
|
||||
// homebrew modules
|
||||
const generateSslCert = require("./src/generate_ssl_cert");
|
||||
@@ -49,10 +55,14 @@ 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");
|
||||
const libSharedCommands = require("@clusterio/lib/shared_commands");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
|
||||
const express = require("express");
|
||||
const compression = require("compression");
|
||||
@@ -60,9 +70,9 @@ const cookieParser = require("cookie-parser");
|
||||
// Required for express post requests
|
||||
const bodyParser = require("body-parser");
|
||||
const fileUpload = require("express-fileupload");
|
||||
var app = express();
|
||||
var httpServer;
|
||||
var httpsServer;
|
||||
const app = express();
|
||||
let httpServer;
|
||||
let httpsServer;
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(bodyParser.json({
|
||||
@@ -100,6 +110,41 @@ function serveWeb(route) {
|
||||
app.use(express.static(path.join(__dirname, "static")));
|
||||
app.use(express.static("static")); // Used for data export files
|
||||
|
||||
const slaveMappingGauge = new libPrometheus.Gauge(
|
||||
"clusterio_master_slave_mapping",
|
||||
"Mapping of Slave ID to name",
|
||||
{
|
||||
labels: ["slave_id", "slave_name"],
|
||||
callback: function() {
|
||||
slaveMappingGauge.clear();
|
||||
for (let [id, slave] of db.slaves) {
|
||||
slaveMappingGauge.labels({
|
||||
slave_id: String(id),
|
||||
slave_name: slave.name,
|
||||
}).set(1);
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const instanceMappingGauge = new libPrometheus.Gauge(
|
||||
"clusterio_master_instance_mapping",
|
||||
"Mapping of Instance ID to name and slave",
|
||||
{
|
||||
labels: ["instance_id", "instance_name", "slave_id"],
|
||||
callback: function() {
|
||||
instanceMappingGauge.clear();
|
||||
for (let [id, instance] of db.instances) {
|
||||
instanceMappingGauge.labels({
|
||||
instance_id: String(id),
|
||||
instance_name: String(instance.config.get("instance.name")),
|
||||
slave_id: String(instance.config.get("instance.assigned_slave")),
|
||||
}).set(1);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const endpointHitCounter = new libPrometheus.Counter(
|
||||
"clusterio_master_http_endpoint_hits_total",
|
||||
"How many requests a particular HTTP endpoint has gotten",
|
||||
@@ -146,8 +191,7 @@ async function getMetrics(req, res, next) {
|
||||
|
||||
let requests = [];
|
||||
for (let slaveConnection of slaveConnections.values()) {
|
||||
// XXX this needs a timeout
|
||||
requests.push(libLink.messages.getMetrics.send(slaveConnection));
|
||||
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), 8e3, null));
|
||||
}
|
||||
|
||||
for await (let result of await libPrometheus.defaultRegistry.collect()) {
|
||||
@@ -156,14 +200,28 @@ async function getMetrics(req, res, next) {
|
||||
|
||||
let resultMap = new Map();
|
||||
for (let response of await Promise.all(requests)) {
|
||||
if (!response) {
|
||||
// TODO: Log timeout occured?
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let result of response.results) {
|
||||
if (!resultMap.has(result.metric.name)) {
|
||||
resultMap.set(result.metric.name, result);
|
||||
|
||||
} else {
|
||||
// Merge metrics received by multiple slaves
|
||||
let stored = resultMap.get(result.metric.name);
|
||||
stored.samples.push(...result.samples);
|
||||
let receivedSamples = new Map(result.samples);
|
||||
for (let [suffix, suffixSamples] of resultMap.get(result.metric.name).samples) {
|
||||
if (receivedSamples.has(suffix)) {
|
||||
suffixSamples.push(...receivedSamples.get(suffix));
|
||||
receivedSamples.delete(suffix);
|
||||
}
|
||||
}
|
||||
|
||||
for (let entry of receivedSamples) {
|
||||
result.samples.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,9 +319,6 @@ const masterConnectedClientsCount = new libPrometheus.Gauge(
|
||||
},
|
||||
);
|
||||
|
||||
// set up database
|
||||
const db = {};
|
||||
|
||||
/**
|
||||
* Load Map from JSON file in the database directory.
|
||||
*
|
||||
@@ -273,13 +328,13 @@ const db = {};
|
||||
*/
|
||||
async function loadMap(databaseDirectory, file) {
|
||||
let databasePath = path.resolve(databaseDirectory, file);
|
||||
console.log(`Loading ${databasePath}`);
|
||||
logger.info(`Loading ${databasePath}`);
|
||||
return await libDatabase.loadJsonArrayAsMap(databasePath);
|
||||
}
|
||||
|
||||
async function loadInstances(databaseDirectory, file) {
|
||||
let filePath = path.join(databaseDirectory, file);
|
||||
console.log(`Loading ${filePath}`);
|
||||
logger.info(`Loading ${filePath}`);
|
||||
|
||||
let instances = new Map();
|
||||
try {
|
||||
@@ -357,7 +412,7 @@ function createUser(name) {
|
||||
*/
|
||||
async function saveMap(databaseDirectory, file, map) {
|
||||
let databasePath = path.resolve(databaseDirectory, file);
|
||||
console.log(`Saving ${databasePath}`);
|
||||
logger.info(`Saving ${databasePath}`);
|
||||
await libDatabase.saveMapAsJsonArray(databasePath, map);
|
||||
}
|
||||
|
||||
@@ -394,10 +449,10 @@ async function saveUsers(databaseDirectory, file) {
|
||||
* Innitiate shutdown of master server
|
||||
*/
|
||||
async function shutdown() {
|
||||
console.log("Shutting down");
|
||||
logger.info("Shutting down");
|
||||
let exitStartTime = Date.now();
|
||||
try {
|
||||
console.log("Saving configs");
|
||||
logger.info("Saving configs");
|
||||
await fs.outputFile(masterConfigPath, JSON.stringify(masterConfig.serialize(), null, 4));
|
||||
|
||||
await saveMap(masterConfig.get("master.database_directory"), "slaves.json", db.slaves);
|
||||
@@ -423,14 +478,13 @@ async function shutdown() {
|
||||
disconnectTasks.push(slaveConnection.disconnect(1001, "Server Quit"));
|
||||
}
|
||||
|
||||
console.log(`Waiting for ${disconnectTasks.length} connectors to close`);
|
||||
logger.info(`Waiting for ${disconnectTasks.length} connectors to close`);
|
||||
for (let task of disconnectTasks) {
|
||||
try {
|
||||
await task;
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
console.log("Unexpected error disconnecting connector");
|
||||
console.log(err);
|
||||
logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,22 +494,22 @@ async function shutdown() {
|
||||
}
|
||||
|
||||
let stopTasks = [];
|
||||
console.log("Stopping HTTP(S) server");
|
||||
logger.info("Stopping HTTP(S) server");
|
||||
if (httpServer) { stopTasks.push(new Promise(resolve => httpServer.close(resolve))); }
|
||||
if (httpsServer) { stopTasks.push(new Promise(resolve => httpsServer.close(resolve))); }
|
||||
await Promise.all(stopTasks);
|
||||
|
||||
console.log(`Clusterio cleanly exited in ${Date.now() - exitStartTime}ms`);
|
||||
logger.info(`Clusterio cleanly exited in ${Date.now() - exitStartTime}ms`);
|
||||
|
||||
} catch(err) {
|
||||
setBlocking(true);
|
||||
console.error(`
|
||||
logger.fatal(`
|
||||
+--------------------------------------------------------------------+
|
||||
| Unexpected error occured while shutting down master, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+--------------------------------------------------------------------+`
|
||||
+--------------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -536,8 +590,7 @@ class BaseConnection extends libLink.Link {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
console.error("Unexpected error preparing disconnect");
|
||||
console.error(err);
|
||||
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +598,11 @@ class BaseConnection extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
const lastQueryLogTime = new libPrometheus.Gauge(
|
||||
"clusterio_master_last_query_log_duration_seconds",
|
||||
"Time in seconds the last log query took to execute."
|
||||
);
|
||||
|
||||
let controlConnections = [];
|
||||
class ControlConnection extends BaseConnection {
|
||||
constructor(registerData, connector, user) {
|
||||
@@ -566,13 +624,23 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
});
|
||||
|
||||
this.instanceOutputSubscriptions = new Set();
|
||||
this.logTransport = null;
|
||||
this.logSubscriptions = {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [],
|
||||
};
|
||||
|
||||
this.ws_dumper = null;
|
||||
this.connector.on("connect", () => {
|
||||
this.connector._socket.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
this.connector.on("close", () => {
|
||||
if (this.logTransport) {
|
||||
this.logTransport = null;
|
||||
logger.remove(this.logTransport);
|
||||
}
|
||||
if (this.ws_dumper) {
|
||||
debugEvents.off("message", this.ws_dumper);
|
||||
}
|
||||
@@ -598,7 +666,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) {
|
||||
@@ -711,33 +779,116 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async assignInstanceCommandRequestHandler(message, request) {
|
||||
let instance = db.instances.get(message.data.instance_id);
|
||||
let { slave_id, instance_id } = message.data;
|
||||
let instance = db.instances.get(instance_id);
|
||||
if (!instance) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
|
||||
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
|
||||
}
|
||||
|
||||
// XXX: Should the instance be stopped if it's running on another slave?
|
||||
|
||||
let connection = slaveConnections.get(message.data.slave_id);
|
||||
if (!connection) {
|
||||
// The case of the slave not getting the assign instance message
|
||||
// still have to be handled, so it's not a requirement that the
|
||||
// target slave be connected to the master while doing the
|
||||
// assignment, but it is IMHO a better user experience if this
|
||||
// is the case.
|
||||
throw new libErrors.RequestError("Target slave is not connected to the master server");
|
||||
// Check if target slave is connected
|
||||
let newSlaveConnection;
|
||||
if (slave_id !== null) {
|
||||
newSlaveConnection = slaveConnections.get(slave_id);
|
||||
if (!newSlaveConnection) {
|
||||
// The case of the slave not getting the assign instance message
|
||||
// still have to be handled, so it's not a requirement that the
|
||||
// target slave be connected to the master while doing the
|
||||
// assignment, but it is IMHO a better user experience if this
|
||||
// is the case.
|
||||
throw new libErrors.RequestError("Target slave is not connected to the master server");
|
||||
}
|
||||
}
|
||||
|
||||
instance.config.set("instance.assigned_slave", message.data.slave_id);
|
||||
// Unassign from currently assigned slave if it is connected.
|
||||
let currentAssignedSlave = instance.config.get("instance.assigned_slave");
|
||||
if (currentAssignedSlave !== null && slave_id !== currentAssignedSlave) {
|
||||
let oldSlaveConnection = slaveConnections.get(currentAssignedSlave);
|
||||
if (oldSlaveConnection && !oldSlaveConnection.connector.closing) {
|
||||
await libLink.messages.unassignInstance.send(oldSlaveConnection, { instance_id });
|
||||
}
|
||||
}
|
||||
|
||||
return await libLink.messages.assignInstance.send(connection, {
|
||||
instance_id: instance.config.get("instance.id"),
|
||||
serialized_config: instance.config.serialize(),
|
||||
});
|
||||
// Assign to target
|
||||
instance.config.set("instance.assigned_slave", slave_id);
|
||||
if (slave_id !== null) {
|
||||
await libLink.messages.assignInstance.send(newSlaveConnection, {
|
||||
instance_id,
|
||||
serialized_config: instance.config.serialize(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setInstanceOutputSubscriptionsRequestHandler(message) {
|
||||
this.instanceOutputSubscriptions = new Set(message.data.instance_ids);
|
||||
async setLogSubscriptionsRequestHandler(message) {
|
||||
this.logSubscriptions = message.data;
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
|
||||
static logFilter({ all, master, slave_ids, instance_ids, max_level }) {
|
||||
return info => {
|
||||
// Note: reversed to filter out undefined levels
|
||||
if (max_level && !(levels[info.level] <= levels[max_level])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return true;
|
||||
}
|
||||
if (master && info.slave_id === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (info.slave_id && slave_ids.includes(info.slave_id)) {
|
||||
return true;
|
||||
}
|
||||
if (info.instance_id && instance_ids.includes(info.instance_id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
updateLogSubscriptions() {
|
||||
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
|
||||
if (all || master || slave_ids.length || instance_ids.length) {
|
||||
if (!this.logTransport) {
|
||||
this.logTransport = new libLoggingUtils.LinkTransport({ link: this });
|
||||
clusterLogger.add(this.logTransport);
|
||||
}
|
||||
this.logTransport.filter = this.constructor.logFilter(this.logSubscriptions);
|
||||
|
||||
} else if (this.logTransport) {
|
||||
clusterLogger.remove(this.logTransport);
|
||||
this.logTransport = null;
|
||||
}
|
||||
}
|
||||
|
||||
async queryLogRequestHandler(message) {
|
||||
let transport = new winston.transports.File({ filename: "cluster.log" });
|
||||
let query = util.promisify((options, callback) => transport.query(options, callback));
|
||||
|
||||
// Available options and defaults inferred from reading the source
|
||||
// rows: number = limit || 10
|
||||
// limit: number // alias of rows.
|
||||
// start: number = 0
|
||||
// until: Date = now
|
||||
// from: Date = until - 24h
|
||||
// order: "asc"|"desc" = "desc"
|
||||
// fields: Array<string>
|
||||
// level: string
|
||||
// This interface is junk:
|
||||
// - The level option filters by exact match
|
||||
// - No way to add your own filter
|
||||
// - No way to stream results
|
||||
// - The log file is read starting from the beginning
|
||||
let setDuration = lastQueryLogTime.startTimer();
|
||||
let log = await query({
|
||||
order: "asc",
|
||||
limit: Infinity,
|
||||
from: new Date(0),
|
||||
});
|
||||
|
||||
log = log.filter(this.constructor.logFilter(message.data));
|
||||
setDuration();
|
||||
return { log };
|
||||
}
|
||||
|
||||
async listPermissionsRequestHandler(message) {
|
||||
@@ -924,7 +1075,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
var slaveConnections = new Map();
|
||||
let slaveConnections = new Map();
|
||||
/**
|
||||
* Represents the connection to a slave
|
||||
*
|
||||
@@ -966,13 +1117,23 @@ class SlaveConnection extends BaseConnection {
|
||||
|
||||
async instanceStatusChangedEventHandler(message, event) {
|
||||
let instance = db.instances.get(message.data.instance_id);
|
||||
|
||||
// It's possible to get updates from an instance that does not exist
|
||||
// or is not assigned to the slave it originated from if it was
|
||||
// reassigned or deleted while the connection to the slave it was
|
||||
// originally on was down at the time.
|
||||
if (!instance || instance.config.get("instance.assigned_slave") !== this._id) {
|
||||
logger.warn(`Got bogus update for instance id ${message.data.instance_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = message.data.status;
|
||||
console.log(`Clusterio | Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
await libPlugin.invokeHook(masterPlugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
|
||||
async updateInstancesEventHandler(message) {
|
||||
async updateInstancesRequestHandler(message) {
|
||||
// Push updated instance configs
|
||||
for (let instance of db.instances.values()) {
|
||||
if (instance.config.get("instance.assigned_slave") === this._id) {
|
||||
@@ -990,10 +1151,19 @@ class SlaveConnection extends BaseConnection {
|
||||
|
||||
let masterInstance = db.instances.get(instanceConfig.get("instance.id"));
|
||||
if (masterInstance) {
|
||||
// Check if this instance is assigned somewhere else.
|
||||
if (masterInstance.config.get("instance.assigned_slave") !== this._id) {
|
||||
await libLink.messages.unassignInstance.send(this, {
|
||||
instance_id: masterInstance.config.get("instance.id"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already have this instance, update state instead
|
||||
if (masterInstance.status !== instance.status) {
|
||||
let prev = masterInstance.status;
|
||||
masterInstance.status = instance.status;
|
||||
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instance.status}`);
|
||||
await libPlugin.invokeHook(masterPlugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
continue;
|
||||
@@ -1030,13 +1200,12 @@ class SlaveConnection extends BaseConnection {
|
||||
libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist });
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
for (let controlConnection of controlConnections) {
|
||||
if (controlConnection.instanceOutputSubscriptions.has(instance_id)) {
|
||||
libLink.messages.instanceOutput.send(controlConnection, message.data);
|
||||
}
|
||||
}
|
||||
async logMessageEventHandler(message) {
|
||||
clusterLogger.log({
|
||||
...message.data.info,
|
||||
slave_id: this._id,
|
||||
slave_name: this._name,
|
||||
});
|
||||
}
|
||||
|
||||
async playerEventEventHandler(message) {
|
||||
@@ -1178,7 +1347,7 @@ class WebSocketServerConnector extends libLink.WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
_timedOut() {
|
||||
console.log("SOCKET | Connection timed out");
|
||||
logger.verbose("SOCKET | Connection timed out");
|
||||
this._timeoutId = null;
|
||||
this._lastReceivedSeq = null;
|
||||
this._sendBuffer.length = 0;
|
||||
@@ -1192,7 +1361,7 @@ class WebSocketServerConnector extends libLink.WebSocketBaseConnector {
|
||||
this.startHeartbeat();
|
||||
|
||||
this._socket.on("close", (code, reason) => {
|
||||
console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`);
|
||||
logger.verbose(`SOCKET | Close (code: ${code}, reason: ${reason})`);
|
||||
if (this._state === "closing") {
|
||||
this._lastReceivedSeq = null;
|
||||
this._sendBuffer.length = 0;
|
||||
@@ -1220,17 +1389,17 @@ class WebSocketServerConnector extends libLink.WebSocketBaseConnector {
|
||||
|
||||
this._socket.on("error", err => {
|
||||
// It's assumed that close is always called by ws
|
||||
console.error("SOCKET | Error:", err);
|
||||
logger.verbose("SOCKET | Error:", err);
|
||||
});
|
||||
|
||||
this._socket.on("open", () => {
|
||||
console.log("SOCKET | Open");
|
||||
logger.verbose("SOCKET | Open");
|
||||
});
|
||||
this._socket.on("ping", data => {
|
||||
console.log(`SOCKET | Ping (data: ${data}`);
|
||||
logger.verbose(`SOCKET | Ping (data: ${data}`);
|
||||
});
|
||||
this._socket.on("pong", data => {
|
||||
console.log(`SOCKET | Pong (data: ${data}`);
|
||||
logger.verbose(`SOCKET | Pong (data: ${data}`);
|
||||
});
|
||||
|
||||
// Handle messages
|
||||
@@ -1278,7 +1447,7 @@ let pendingSockets = new Set();
|
||||
let activeConnectors = new Map();
|
||||
|
||||
wss.on("connection", function (socket, req) {
|
||||
console.log(`SOCKET | new connection from ${req.socket.remoteAddress}`);
|
||||
logger.verbose(`SOCKET | new connection from ${req.socket.remoteAddress}`);
|
||||
|
||||
// Track statistics
|
||||
wsConnectionsCounter.inc();
|
||||
@@ -1307,7 +1476,7 @@ wss.on("connection", function (socket, req) {
|
||||
pendingSockets.add(socket);
|
||||
|
||||
let timeoutId = setTimeout(() => {
|
||||
console.log(`SOCKET | closing ${req.socket.remoteAddress} after timing out on handshake`);
|
||||
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress} after timing out on handshake`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1008, "Handshake timeout");
|
||||
pendingSockets.delete(socket);
|
||||
@@ -1319,13 +1488,13 @@ wss.on("connection", function (socket, req) {
|
||||
handleHandshake(
|
||||
message, socket, req, attachHandler
|
||||
).catch(err => {
|
||||
console.error(`
|
||||
logger.error(`
|
||||
+----------------------------------------------------------------+
|
||||
| Unexpected error occured in WebSocket handshake, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+----------------------------------------------------------------+`
|
||||
+----------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
console.error(err);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1011, "Unexpected error");
|
||||
});
|
||||
@@ -1345,14 +1514,14 @@ async function handleHandshake(message, socket, req, attachHandler) {
|
||||
try {
|
||||
message = JSON.parse(message);
|
||||
} catch (err) {
|
||||
console.log(`SOCKET | closing ${req.socket.remoteAddress} after receiving invalid JSON`);
|
||||
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress} after receiving invalid JSON`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1002, "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!libSchema.clientHandshake(message)) {
|
||||
console.log(`SOCKET | closing ${req.socket.remoteAddress} after receiving invalid handshake:`, message);
|
||||
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress} after receiving invalid handshake`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1002, "Bad handshake");
|
||||
return;
|
||||
@@ -1385,7 +1554,7 @@ async function handleHandshake(message, socket, req, attachHandler) {
|
||||
}
|
||||
|
||||
if (stopAcceptingNewSessions) {
|
||||
console.log(`SOCKET | closing ${req.socket.remoteAddress}, server is shutting down`);
|
||||
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress}, server is shutting down`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1001, "Shutting down");
|
||||
return;
|
||||
@@ -1423,13 +1592,14 @@ async function handleHandshake(message, socket, req, attachHandler) {
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`SOCKET | authentication failed for ${req.socket.remoteAddress}`);
|
||||
logger.verbose(`SOCKET | authentication failed for ${req.socket.remoteAddress}`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(4003, `Authentication failed: ${err.message}`);
|
||||
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);
|
||||
@@ -1437,16 +1607,16 @@ async function handleHandshake(message, socket, req, attachHandler) {
|
||||
if (type === "register_slave") {
|
||||
let connection = slaveConnections.get(data.id);
|
||||
if (connection) {
|
||||
console.log(`SOCKET | disconnecting existing connection for slave ${data.id}`);
|
||||
logger.verbose(`SOCKET | disconnecting existing connection for slave ${data.id}`);
|
||||
connection.connector.setTimeout(15); // Slave connection is likely stalled
|
||||
await connection.disconnect(1008, "Registered from another connection");
|
||||
}
|
||||
|
||||
console.log(`SOCKET | registered slave ${data.id} version ${data.version}`);
|
||||
logger.verbose(`SOCKET | registered slave ${data.id} version ${data.version}`);
|
||||
slaveConnections.set(data.id, new SlaveConnection(data, connector));
|
||||
|
||||
} else if (type === "register_control") {
|
||||
console.log(`SOCKET | registered control from ${req.socket.remoteAddress}`);
|
||||
logger.verbose(`SOCKET | registered control from ${req.socket.remoteAddress}`);
|
||||
controlConnections.push(new ControlConnection(data, connector, user));
|
||||
}
|
||||
|
||||
@@ -1457,7 +1627,7 @@ async function handleHandshake(message, socket, req, attachHandler) {
|
||||
async function pluginManagement() {
|
||||
let startPluginLoad = Date.now();
|
||||
masterPlugins = await loadPlugins();
|
||||
console.log("All plugins loaded in "+(Date.now() - startPluginLoad)+"ms");
|
||||
logger.info(`All plugins loaded in ${Date.now() - startPluginLoad}ms`);
|
||||
}
|
||||
|
||||
async function loadPlugins() {
|
||||
@@ -1477,7 +1647,7 @@ async function loadPlugins() {
|
||||
}
|
||||
|
||||
let masterPlugin = new MasterPlugin(
|
||||
pluginInfo, { app, config: masterConfig, db, slaveConnections }, { endpointHitCounter }
|
||||
pluginInfo, { app, config: masterConfig, db, slaveConnections }, { endpointHitCounter }, logger
|
||||
);
|
||||
await masterPlugin.init();
|
||||
plugins.set(pluginInfo.name, masterPlugin);
|
||||
@@ -1486,7 +1656,7 @@ async function loadPlugins() {
|
||||
throw new libErrors.PluginError(pluginInfo.name, err);
|
||||
}
|
||||
|
||||
console.log(`Clusterio | Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
||||
logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
@@ -1503,7 +1673,7 @@ async function loadPlugins() {
|
||||
function listen(server, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
console.log("handling upgrade");
|
||||
logger.verbose("handling WebSocket upgrade");
|
||||
|
||||
// For reasons that defy common sense, the connection event has
|
||||
// to be emitted explictly when using noServer.
|
||||
@@ -1552,13 +1722,17 @@ async function startServer() {
|
||||
// and as the process name in ps/top on linux.
|
||||
process.title = "clusterioMaster";
|
||||
|
||||
// add better stack traces on promise rejection
|
||||
process.on("unhandledRejection", r => console.log(r));
|
||||
|
||||
// argument parsing
|
||||
let args = yargs
|
||||
.scriptName("master")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stdout",
|
||||
default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)),
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "master config file to use",
|
||||
@@ -1572,10 +1746,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",
|
||||
@@ -1592,7 +1767,35 @@ async function startServer() {
|
||||
.argv
|
||||
;
|
||||
|
||||
console.log(`Loading available plugins from ${args.pluginList}`);
|
||||
// Combined log stream of the whole cluster.
|
||||
clusterLogger = winston.createLogger({
|
||||
format: winston.format.json(),
|
||||
level: "verbose",
|
||||
levels,
|
||||
});
|
||||
clusterLogger.add(new winston.transports.File({
|
||||
filename: "cluster.log",
|
||||
}));
|
||||
|
||||
// Log stream for the master server.
|
||||
logger.add(new winston.transports.File({
|
||||
format: winston.format.json(),
|
||||
filename: "master.log",
|
||||
}));
|
||||
logger.add(new winston.transports.Stream({
|
||||
stream: clusterLogger,
|
||||
}));
|
||||
if (args.logLevel !== "none") {
|
||||
logger.add(new ConsoleTransport({
|
||||
level: args.logLevel,
|
||||
format: new libLoggingUtils.TerminalFormat(),
|
||||
}));
|
||||
}
|
||||
|
||||
// add better stack traces on promise rejection
|
||||
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
|
||||
@@ -1609,20 +1812,20 @@ async function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Loading Plugin info");
|
||||
pluginInfos = await libPlugin.loadPluginInfos(pluginList);
|
||||
logger.info("Loading Plugin info");
|
||||
pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
|
||||
masterConfigPath = args.config;
|
||||
console.log(`Loading config from ${masterConfigPath}`);
|
||||
logger.info(`Loading config from ${masterConfigPath}`);
|
||||
masterConfig = new libConfig.MasterConfig();
|
||||
try {
|
||||
await masterConfig.load(JSON.parse(await fs.readFile(masterConfigPath)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Config not found, initializing new config");
|
||||
logger.info("Config not found, initializing new config");
|
||||
await masterConfig.init();
|
||||
|
||||
} else {
|
||||
@@ -1631,7 +1834,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
if (!masterConfig.get("master.auth_secret")) {
|
||||
console.log("Generating new master authentication secret");
|
||||
logger.info("Generating new master authentication secret");
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let bytes = await asyncRandomBytes(256);
|
||||
masterConfig.set("master.auth_secret", bytes.toString("base64"));
|
||||
@@ -1639,7 +1842,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await libConfig.handleConfigCommand(args, masterConfig, masterConfigPath);
|
||||
await libSharedCommands.handleConfigCommand(args, masterConfig, masterConfigPath);
|
||||
return;
|
||||
|
||||
} else if (command === "bootstrap") {
|
||||
@@ -1647,7 +1850,7 @@ async function startServer() {
|
||||
await loadUsers(masterConfig.get("master.database_directory"), "users.json");
|
||||
if (subCommand === "create-admin") {
|
||||
if (!args.name) {
|
||||
console.error("name cannot be blank");
|
||||
logger.error("name cannot be blank");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1662,10 +1865,20 @@ 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) {
|
||||
logger.error(`No user named '${args.name}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(user.createToken(masterConfig.get("master.auth_secret")));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
let admin = db.users.get(args.name);
|
||||
if (!admin) {
|
||||
console.error(`No user named '${args.name}'`);
|
||||
logger.error(`No user named '${args.name}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1677,9 +1890,10 @@ async function startServer() {
|
||||
|
||||
let content = JSON.stringify(controlConfig.serialize(), null, 4);
|
||||
if (args.output === "-") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(content);
|
||||
} else {
|
||||
console.log(`Writing ${args.output}`);
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await fs.outputFile(args.output, content);
|
||||
}
|
||||
}
|
||||
@@ -1691,7 +1905,7 @@ async function startServer() {
|
||||
|
||||
// Start webpack development server if enabled
|
||||
if (args.dev) {
|
||||
console.log("Webpack development mode enabled");
|
||||
logger.warn("Webpack development mode enabled");
|
||||
const webpack = require("webpack");
|
||||
const webpackDevMiddleware = require("webpack-dev-middleware");
|
||||
const webpackConfig = require("./webpack.config");
|
||||
@@ -1704,12 +1918,12 @@ async function startServer() {
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
console.log("Caught second interrupt, terminating immediately");
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
console.log("Caught interrupt signal, shutting down");
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
shutdown();
|
||||
});
|
||||
|
||||
@@ -1730,7 +1944,7 @@ async function startServer() {
|
||||
let httpPort = masterConfig.get("master.http_port");
|
||||
let httpsPort = masterConfig.get("master.https_port");
|
||||
if (!httpPort && !httpsPort) {
|
||||
console.error("Error: at least one of http_port and https_port must be configured");
|
||||
logger.fatal("Error: at least one of http_port and https_port must be configured");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1757,7 +1971,6 @@ async function startServer() {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1768,7 +1981,7 @@ async function startServer() {
|
||||
if (httpPort) {
|
||||
httpServer = new (require("http").Server)(app);
|
||||
await listen(httpServer, httpPort);
|
||||
console.log("Listening for HTTP on port %s...", httpServer.address().port);
|
||||
logger.info(`Listening for HTTP on port ${httpServer.address().port}`);
|
||||
}
|
||||
|
||||
if (httpsPort) {
|
||||
@@ -1788,7 +2001,7 @@ async function startServer() {
|
||||
cert: certificate,
|
||||
}, app);
|
||||
await listen(httpsServer, httpsPort);
|
||||
console.log("Listening for HTTPS on port %s...", httpsServer.address().port);
|
||||
logger.info(`Listening for HTTPS on port ${httpsServer.address().port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1806,6 +2019,7 @@ module.exports = {
|
||||
};
|
||||
|
||||
if (module === require.main) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
@@ -1815,28 +2029,31 @@ I version of clusterio. Expect things to break. I
|
||||
);
|
||||
startServer().catch(err => {
|
||||
if (err instanceof libErrors.StartupError) {
|
||||
console.error(`
|
||||
logger.fatal(`
|
||||
+----------------------------------+
|
||||
| Unable to to start master server |
|
||||
+----------------------------------+`
|
||||
+----------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else if (err instanceof libErrors.PluginError) {
|
||||
console.error(`
|
||||
Error: ${err.pluginName} plugin threw an unexpected error
|
||||
during startup, please report it to the plugin author.
|
||||
--------------------------------------------------------------`
|
||||
logger.fatal(`
|
||||
${err.pluginName} plugin threw an unexpected error
|
||||
during startup, please report it to the plugin author.
|
||||
------------------------------------------------------
|
||||
${err.original.stack}`
|
||||
);
|
||||
err = err.original;
|
||||
} else {
|
||||
console.error(`
|
||||
logger.fatal(`
|
||||
+---------------------------------------------------------------+
|
||||
| Unexpected error occured while starting master, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+---------------------------------------------------------------+`
|
||||
+---------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return shutdown();
|
||||
if (masterConfig) {
|
||||
shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio master server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"main": "master.js",
|
||||
"scripts": {
|
||||
"build": "webpack-cli --env.production",
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=10.16 <11 || >= 12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.1",
|
||||
"@clusterio/lib": "^2.0.0-alpha.3",
|
||||
"body-parser": "^1.18.2",
|
||||
"compression": "^1.7.2",
|
||||
"cookie-parser": "^1.4.3",
|
||||
@@ -31,21 +31,17 @@
|
||||
"node-factorio-api": "^0.3.8",
|
||||
"node-forge": "^0.10.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.3.3",
|
||||
"ws": "^7.2.3",
|
||||
"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.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^4.3.0",
|
||||
"events": "^3.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
|
||||
@@ -3,6 +3,8 @@ const forge = require("node-forge");
|
||||
const fs = require("fs-extra");
|
||||
const util = require("util");
|
||||
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
|
||||
const hrtime = process.hrtime.bigint;
|
||||
|
||||
|
||||
@@ -10,16 +12,12 @@ module.exports = async function({
|
||||
bits = 2048,
|
||||
sslCertPath,
|
||||
sslPrivKeyPath,
|
||||
doLogging = false,
|
||||
} = {}){
|
||||
function log(t) {
|
||||
if(doLogging) console.log("src/generate_ssl_cert.js | "+t);
|
||||
}
|
||||
log("Generating SSL certificate with "+bits+" bits at "+sslCertPath);
|
||||
logger.info(`Generating SSL certificate with ${bits} bits at ${sslCertPath}`);
|
||||
|
||||
let startNs = hrtime();
|
||||
let keypair = await util.promisify(forge.pki.rsa.generateKeyPair)({ bits: bits, e: 0x10001 });
|
||||
log("Generated 2048bit keypair in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
|
||||
logger.info(`Generated ${bits}bit keypair in: ${Number(hrtime()-startNs) / 1e6} ms`);
|
||||
|
||||
startNs = hrtime();
|
||||
let cert = forge.pki.createCertificate();
|
||||
@@ -41,5 +39,5 @@ module.exports = async function({
|
||||
await fs.outputFile(sslPrivKeyPath, forge.pki.privateKeyToPem(keypair.privateKey));
|
||||
await fs.outputFile(sslCertPath, forge.pki.certificateToPem(cert));
|
||||
|
||||
log("Generated certificate in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
|
||||
logger.info(`Generated certificate in: ${Number(hrtime()-startNs) / 1e6} ms`);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
import("./bootstrap");
|
||||
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,70 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card, Button, Input, Form } from "antd";
|
||||
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 4,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 16,
|
||||
},
|
||||
};
|
||||
const tailLayout = {
|
||||
wrapperCol: {
|
||||
offset: 4,
|
||||
span: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export class Settings extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
role: {},
|
||||
};
|
||||
}
|
||||
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(this.state);
|
||||
return <Card>
|
||||
<h2>Web interface settings</h2>
|
||||
<Form
|
||||
{...layout}
|
||||
name="basic"
|
||||
initialValues={{
|
||||
master_token: localStorage.getItem("master_token"),
|
||||
}}
|
||||
onFinish={values => {
|
||||
console.log("Saving settings",values);
|
||||
for (let key of Object.keys(values)){
|
||||
localStorage.setItem(key, values[key].replace("https://", "wss://").replace("http://", "ws://"));
|
||||
}
|
||||
// Force refresh to apply changes
|
||||
document.location = document.location;
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="User auth token"
|
||||
name="master_token"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input your user auth token from config-control.json",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item {...tailLayout}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
|
||||
export class SlavesTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Slaves</h2>
|
||||
<DataTable
|
||||
DataFunction={listSlaves}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/slaves/${record.id}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -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,100 +0,0 @@
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
super(url, reconnectDelay);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "web",
|
||||
version: "2.0.0-alpha",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Control extends libLink.Link {
|
||||
constructor(connector, controlPlugins) {
|
||||
super("control", "master", connector);
|
||||
libLink.attachAllMessages(this);
|
||||
|
||||
/**
|
||||
* Mapping of plugin names to their instance for loaded plugins.
|
||||
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
|
||||
*/
|
||||
this.plugins = controlPlugins;
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
console.log(formatOutput(output));
|
||||
if (window.instanceOutputEventHandler) {
|
||||
window.instanceOutputEventHandler({instance_id, output});
|
||||
}
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
console.log("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.connector.setTimeout(30);
|
||||
|
||||
try {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await this.connector.close(1001, "Control Quit");
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as errors from "@clusterio/lib/errors";
|
||||
import * as link from "@clusterio/lib/link";
|
||||
|
||||
// Note: control is created as in bootstrap.jsx as a global.
|
||||
|
||||
let listSlaves = createListFunction("listSlaves");
|
||||
let listInstances = createListFunction("listInstances");
|
||||
let listUsers = createListFunction("listUsers", "name");
|
||||
let listRoles = createListFunction("listRoles");
|
||||
let listPermissions = createListFunction("listPermissions", "name");
|
||||
|
||||
async function startInstance({ instance_id, save = null }) {
|
||||
return await link.messages.startInstance.send(control, { instance_id, save });
|
||||
};
|
||||
async function stopInstance({ instance_id }) {
|
||||
return await link.messages.stopInstance.send(control, { instance_id });
|
||||
};
|
||||
async function createInstance(serialized_config) {
|
||||
return await link.messages.createInstance.send(control, { serialized_config });
|
||||
};
|
||||
async function deleteInstance({ instance_id }) {
|
||||
return await link.messages.deleteInstance.send(control, { instance_id });
|
||||
};
|
||||
async function assignInstance({ instance_id, slave_id }) {
|
||||
return await link.messages.assignInstanceCommand.send(control, {
|
||||
instance_id,
|
||||
slave_id,
|
||||
});
|
||||
};
|
||||
async function createSave({ instance_id }) {
|
||||
return await link.messages.createSave.send(control, { instance_id });
|
||||
};
|
||||
async function getInstanceConfig({ instance_id }) {
|
||||
return await link.messages.getInstanceConfig.send(control, { instance_id });
|
||||
};
|
||||
async function setInstanceConfigField({ instance_id, field, value }) {
|
||||
return await link.messages.setInstanceConfigField.send(control, {
|
||||
instance_id,
|
||||
field,
|
||||
value: typeof value === "object" ? JSON.stringify(value) : value,
|
||||
});
|
||||
};
|
||||
async function setInstanceConfigProp({ instance_id, field, prop, value }) {
|
||||
return await link.messages.setInstanceConfigProp.send(control, {
|
||||
instance_id,
|
||||
field,
|
||||
prop,
|
||||
value, // JSON Object
|
||||
});
|
||||
};
|
||||
async function sendRcon({ instance_id, command }) {
|
||||
return await link.messages.sendRcon.send(control, { instance_id, command });
|
||||
};
|
||||
async function setInstanceOutputSubscriptions({ instance_id }) {
|
||||
return await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instance_id] });
|
||||
};
|
||||
|
||||
async function createUser({ name }) {
|
||||
return await link.messages.createUser.send(control, { name });
|
||||
};
|
||||
async function deleteUser({ name }) {
|
||||
return await link.messages.deleteUser.send(control, { name });
|
||||
};
|
||||
async function setRoles({ name, roles }) {
|
||||
return await link.messages.updateUserRoles.send(control, {
|
||||
name,
|
||||
roles,
|
||||
});
|
||||
};
|
||||
|
||||
async function createRole({ name, description, permissions = [] }) {
|
||||
return await link.messages.createRole.send(control, { name, description, permissions });
|
||||
};
|
||||
async function updateRole({ id, name, description, permissions }) {
|
||||
return await link.messages.updateRole.send(control, {
|
||||
id, name, description, permissions,
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
listSlaves,
|
||||
listInstances,
|
||||
listUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
listRoles,
|
||||
setRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
listPermissions,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
assignInstance,
|
||||
createSave,
|
||||
getInstanceConfig,
|
||||
setInstanceConfigField,
|
||||
setInstanceConfigProp,
|
||||
sendRcon,
|
||||
setInstanceOutputSubscriptions,
|
||||
};
|
||||
|
||||
function createListFunction(name, key) {
|
||||
return async () => (
|
||||
(await link.messages[name].send(control)).list.map(addKey(key || "id"))
|
||||
);
|
||||
}
|
||||
function addKey(key) {
|
||||
return function (el) {
|
||||
return {
|
||||
...el,
|
||||
key: el[key],
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,10 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("./webpack.common");
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
devServer: {
|
||||
contentBase: "./static",
|
||||
@@ -19,6 +20,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
name: "master",
|
||||
shared: {
|
||||
"@clusterio/lib": { singleton: true },
|
||||
"@clusterio/web_ui": { singleton: true },
|
||||
"ajv": {},
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.2
|
||||
Features:
|
||||
- Added support for Factorio 1.1.
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.1
|
||||
Features:
|
||||
- Added support for Factorio 0.18.x
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
{
|
||||
"name": "clusterio_lib",
|
||||
"version": "0.1.1",
|
||||
"variants": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"factorio_version": "0.17"
|
||||
},
|
||||
{
|
||||
"version": "0.1.1",
|
||||
"factorio_version": "0.18"
|
||||
},
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"factorio_version": "1.1"
|
||||
}
|
||||
],
|
||||
"title": "Clusterio Library (Alpha)",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio",
|
||||
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
|
||||
"factorio_version": "0.17",
|
||||
"additional_files": [
|
||||
["..", "..", "modules", "clusterio", "serialize.lua"]
|
||||
]
|
||||
"additional_files": {
|
||||
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio slave server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"main": "slave.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
@@ -18,11 +18,12 @@
|
||||
"node": ">=10.16 <11 || >= 12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.1",
|
||||
"@clusterio/lib": "^2.0.0-alpha.3",
|
||||
"fs-extra": "^8.1.0",
|
||||
"phin": "^3.4.1",
|
||||
"pidusage": "^2.0.18",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.3.3",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
+258
-134
@@ -22,25 +22,38 @@ const setBlocking = require("set-blocking");
|
||||
const phin = require("phin");
|
||||
const util = require("util");
|
||||
const version = require("./package").version;
|
||||
const winston = require("winston");
|
||||
|
||||
// internal libraries
|
||||
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");
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libSharedCommands = require("@clusterio/lib/shared_commands");
|
||||
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
|
||||
|
||||
const instanceRconCommandsCounter = new libPrometheus.Counter(
|
||||
"clusterio_instance_rcon_commands_total",
|
||||
"How many commands have been sent to the instance",
|
||||
const instanceRconCommandDuration = new libPrometheus.Histogram(
|
||||
"clusterio_instance_rcon_command_duration_seconds",
|
||||
"Histogram of the RCON command duration from request to response.",
|
||||
{ labels: ["instance_id"] }
|
||||
);
|
||||
|
||||
const instanceRconCommandSize = new libPrometheus.Histogram(
|
||||
"clusterio_instance_rcon_command_size_bytes",
|
||||
"Histogram of the RCON command sizes that are sent.",
|
||||
{
|
||||
labels: ["instance_id", "plugin"],
|
||||
buckets: libPrometheus.Histogram.exponential(16, 2, 12),
|
||||
}
|
||||
);
|
||||
|
||||
const instanceFactorioCpuTime = new libPrometheus.Gauge(
|
||||
"clusterio_instance_factorio_cpu_time_total",
|
||||
"Factorio CPU time spent in seconds.",
|
||||
@@ -60,7 +73,7 @@ const instanceFactorioAutosaveSize = new libPrometheus.Gauge(
|
||||
);
|
||||
|
||||
function applyAsConfig(name) {
|
||||
return async function action(server, value) {
|
||||
return async function action(instance, value) {
|
||||
if (name === "tags" && value instanceof Array) {
|
||||
// Replace spaces with non-break spaces and delimit by spaces.
|
||||
// This does change the defined tags, but there doesn't seem to
|
||||
@@ -68,9 +81,9 @@ function applyAsConfig(name) {
|
||||
value = value.map(tag => tag.replace(/ /g, "\u00a0")).join(" ");
|
||||
}
|
||||
try {
|
||||
await server.sendRcon(`/config set ${name} ${value}`);
|
||||
await instance.sendRcon(`/config set ${name} ${value}`);
|
||||
} catch (err) {
|
||||
console.error(`Error applying server setting ${name}`, err);
|
||||
logger.error(`Error applying server setting ${name} ${err.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -90,13 +103,13 @@ const serverSettingsActions = {
|
||||
"game_password": applyAsConfig("password"),
|
||||
"require_user_verification": applyAsConfig("require-user-verification"),
|
||||
"tags": applyAsConfig("tags"),
|
||||
"visibility": async (server, value) => {
|
||||
"visibility": async (instance, value) => {
|
||||
for (let scope of ["lan", "public", "steam"]) {
|
||||
try {
|
||||
let enabled = Boolean(value[scope]);
|
||||
await server.sendRcon(`/config set visibility-${scope} ${enabled}`);
|
||||
await instance.sendRcon(`/config set visibility-${scope} ${enabled}`);
|
||||
} catch (err) {
|
||||
console.error(`Error applying visibility ${scope}`, err);
|
||||
logger.error(`Error applying visibility ${scope} ${err}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -115,6 +128,11 @@ class Instance extends libLink.Link{
|
||||
this.plugins = new Map();
|
||||
this.config = instanceConfig;
|
||||
|
||||
this.logger = logger.child({
|
||||
instance_id: this.config.get("instance.id"),
|
||||
instance_name: this.config.get("instance.name"),
|
||||
});
|
||||
|
||||
this._configFieldChanged = (group, field, prev) => {
|
||||
let hook = () => libPlugin.invokeHook(this.plugins, "onInstanceConfigFieldChanged", group, field, prev);
|
||||
|
||||
@@ -123,43 +141,45 @@ class Instance extends libLink.Link{
|
||||
} else if (group.name === "factorio" && field === "enable_whitelist") {
|
||||
this.updateFactorioWhitelist(group.get(field)).finally(hook);
|
||||
} else {
|
||||
if (group.name === "factorio" && field === "max_concurrent_commands") {
|
||||
this.server.maxConcurrentCommands = group.get(field);
|
||||
}
|
||||
hook();
|
||||
}
|
||||
};
|
||||
this.config.on("fieldChanged", this._configFieldChanged);
|
||||
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
version: this.config.get("factorio.version"),
|
||||
gamePort: this.config.get("factorio.game_port"),
|
||||
rconPort: this.config.get("factorio.rcon_port"),
|
||||
rconPassword: this.config.get("factorio.rcon_password"),
|
||||
enableWhitelist: this.config.get("factorio.enable_whitelist"),
|
||||
verboseLogging: this.config.get("factorio.verbose_logging"),
|
||||
stripPaths: this.config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
|
||||
};
|
||||
|
||||
this._status = "stopped";
|
||||
this._running = false;
|
||||
this.server = new libFactorio.FactorioServer(
|
||||
factorioDir, this._dir, serverOptions
|
||||
);
|
||||
|
||||
let originalSendRcon = this.server.sendRcon;
|
||||
this.server.sendRcon = (...args) => {
|
||||
instanceRconCommandsCounter.labels(String(this.config.get("instance.id"))).inc();
|
||||
return originalSendRcon.call(this.server, ...args);
|
||||
};
|
||||
this.server.on("output", (parsed, line) => {
|
||||
this.logger.log("server", { message: line, instance_id: this.config.get("instance.id"), parsed });
|
||||
|
||||
this.server.on("output", (output) => {
|
||||
libLink.messages.instanceOutput.send(this, { instance_id: this.config.get("instance.id"), output });
|
||||
|
||||
libPlugin.invokeHook(this.plugins, "onOutput", output);
|
||||
libPlugin.invokeHook(this.plugins, "onOutput", parsed, line);
|
||||
});
|
||||
|
||||
this.server.on("error", err => {
|
||||
console.log(`Error in instance ${this.name}:`, err);
|
||||
this.logger.error(`Error in instance ${this.name}:\n${err.stack}`);
|
||||
});
|
||||
|
||||
this.server.on("autosave-finished", name => {
|
||||
this._autosave(name).catch(err => {
|
||||
console.error("Error handling autosave-finished:", err);
|
||||
this.logger.error(`Error handling autosave-finished in instance ${this.name}:\n${err.stack}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,17 +192,40 @@ class Instance extends libLink.Link{
|
||||
});
|
||||
}
|
||||
|
||||
async sendRcon(message, expectEmpty, plugin = "") {
|
||||
let instanceId = String(this.config.get("instance.id"));
|
||||
let observeDuration = instanceRconCommandDuration.labels(instanceId).startTimer();
|
||||
try {
|
||||
return await this.server.sendRcon(message, expectEmpty);
|
||||
} finally {
|
||||
observeDuration();
|
||||
instanceRconCommandSize.labels(instanceId, plugin).observe(Buffer.byteLength(message, "utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
async _autosave(name) {
|
||||
let stat = await fs.stat(this.path("saves", `${name}.zip`));
|
||||
instanceFactorioAutosaveSize.labels(String(this.config.get("instance.id"))).set(stat.size);
|
||||
}
|
||||
|
||||
notifyStatus(status) {
|
||||
this._status = status;
|
||||
libLink.messages.instanceStatusChanged.send(this, {
|
||||
instance_id: this.config.get("instance.id"), status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current state of the instance
|
||||
*
|
||||
* One of stopped, starting, running, creating_save and exporting_data
|
||||
*
|
||||
* @returns {string} instance status.
|
||||
*/
|
||||
get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
notifyExit() {
|
||||
this._running = false;
|
||||
this.notifyStatus("stopped");
|
||||
@@ -213,10 +256,11 @@ class Instance extends libLink.Link{
|
||||
await instancePlugin.init();
|
||||
libPlugin.attachPluginMessages(this, pluginInfo, instancePlugin);
|
||||
|
||||
console.log(`Clusterio | Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
||||
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
||||
}
|
||||
|
||||
async init(pluginInfos) {
|
||||
this.notifyStatus("starting");
|
||||
await this.server.init();
|
||||
|
||||
// load plugins
|
||||
@@ -259,7 +303,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
for (let [key, value] of Object.entries(overrides)) {
|
||||
if (!Object.hasOwnProperty.call(serverSettings, key)) {
|
||||
console.log(`Warning: Server settings does not have the property '${key}'`);
|
||||
this.logger.warn(`Server settings does not have the property '${key}'`);
|
||||
}
|
||||
serverSettings[key] = value;
|
||||
}
|
||||
@@ -293,7 +337,7 @@ class Instance extends libLink.Link{
|
||||
* @param {String} factorioDir - Path to factorio installation.
|
||||
*/
|
||||
static async create(instanceDir, factorioDir) {
|
||||
console.log(`Clusterio | Creating ${instanceDir}`);
|
||||
logger.info(`Creating ${instanceDir}`);
|
||||
await fs.ensureDir(instanceDir);
|
||||
await fs.ensureDir(path.join(instanceDir, "script-output"));
|
||||
await fs.ensureDir(path.join(instanceDir, "saves"));
|
||||
@@ -305,11 +349,11 @@ class Instance extends libLink.Link{
|
||||
* Writes server settings, admin/ban/white-lists and links mods.
|
||||
*/
|
||||
async prepare() {
|
||||
console.log("Clusterio | Writing server-settings.json");
|
||||
this.logger.verbose("Writing server-settings.json");
|
||||
await this.writeServerSettings();
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist")) {
|
||||
console.log("Clusterio | Writing server-adminlist.json");
|
||||
this.logger.verbose("Writing server-adminlist.json");
|
||||
fs.outputFile(
|
||||
this.server.writePath("server-adminlist.json"),
|
||||
JSON.stringify([...this._slave.adminlist], null, 4)
|
||||
@@ -317,7 +361,7 @@ class Instance extends libLink.Link{
|
||||
}
|
||||
|
||||
if (this.config.get("factorio.sync_banlist")) {
|
||||
console.log("Clusterio | Writing server-banlist.json");
|
||||
this.logger.verbose("Writing server-banlist.json");
|
||||
fs.outputFile(
|
||||
this.server.writePath("server-banlist.json"),
|
||||
JSON.stringify([...this._slave.banlist].map(
|
||||
@@ -327,14 +371,14 @@ class Instance extends libLink.Link{
|
||||
}
|
||||
|
||||
if (this.config.get("factorio.sync_whitelist")) {
|
||||
console.log("Clusterio | Writing server-whitelist.json");
|
||||
this.logger.verbose("Writing server-whitelist.json");
|
||||
fs.outputFile(
|
||||
this.server.writePath("server-whitelist.json"),
|
||||
JSON.stringify([...this._slave.whitelist], null, 4)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Clusterio | Rotating old logs...");
|
||||
this.logger.verbose("Rotating old logs...");
|
||||
// clean old log file to avoid crash
|
||||
try{
|
||||
let logPath = this.path("factorio-current.log");
|
||||
@@ -342,11 +386,11 @@ class Instance extends libLink.Link{
|
||||
if(stat.isFile()){
|
||||
let logFilename = `factorio-${Math.floor(Date.parse(stat.mtime)/1000)}.log`;
|
||||
await fs.rename(logPath, this.path(logFilename));
|
||||
console.log(`Log rotated as ${logFilename}`);
|
||||
this.logger.verbose(`Log rotated as ${logFilename}`);
|
||||
}
|
||||
}catch(e){}
|
||||
|
||||
await symlinkMods(this, "sharedMods", console);
|
||||
await symlinkMods(this, "sharedMods");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,7 +413,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
// Create save if no save was found.
|
||||
if (saveName === null) {
|
||||
console.log("Clusterio | Creating new save");
|
||||
this.logger.info("Creating new save");
|
||||
await this.server.create("world.zip");
|
||||
saveName = "world.zip";
|
||||
}
|
||||
@@ -379,7 +423,7 @@ class Instance extends libLink.Link{
|
||||
}
|
||||
|
||||
// Patch save with lua modules from plugins
|
||||
console.log("Clusterio | Patching save");
|
||||
this.logger.verbose("Patching save");
|
||||
|
||||
// Find plugin modules to patch in
|
||||
let modules = new Map();
|
||||
@@ -453,7 +497,7 @@ class Instance extends libLink.Link{
|
||||
*/
|
||||
async start(saveName) {
|
||||
this.server.on("rcon-ready", () => {
|
||||
console.log("Clusterio | RCON connection established");
|
||||
this.logger.verbose("RCON connection established");
|
||||
});
|
||||
|
||||
this.server.on("exit", () => this.notifyExit());
|
||||
@@ -480,7 +524,7 @@ class Instance extends libLink.Link{
|
||||
*/
|
||||
async startScenario(scenario) {
|
||||
this.server.on("rcon-ready", () => {
|
||||
console.log("Clusterio | RCON connection established");
|
||||
this.logger.verbose("RCON connection established");
|
||||
});
|
||||
|
||||
this.server.on("exit", () => this.notifyExit());
|
||||
@@ -498,7 +542,7 @@ class Instance extends libLink.Link{
|
||||
async updateInstanceData() {
|
||||
let name = libLuaTools.escapeString(this.config.get("instance.name"));
|
||||
let id = this.config.get("instance.id");
|
||||
await this.server.sendRcon(`/sc clusterio_private.update_instance(${id}, "${name}")`, true);
|
||||
await this.sendRcon(`/sc clusterio_private.update_instance(${id}, "${name}")`, true);
|
||||
}
|
||||
|
||||
async updateFactorioSettings(current, previous) {
|
||||
@@ -507,7 +551,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
for (let [key, action] of Object.entries(serverSettingsActions)) {
|
||||
if (current[key] !== undefined && !util.isDeepStrictEqual(current[key], previous[key])) {
|
||||
await action(this.server, current[key]);
|
||||
await action(this, current[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,18 +564,18 @@ class Instance extends libLink.Link{
|
||||
*/
|
||||
async updateFactorioWhitelist(enable) {
|
||||
if (!enable) {
|
||||
await this.server.sendRcon("/whitelist disable");
|
||||
await this.sendRcon("/whitelist disable");
|
||||
}
|
||||
|
||||
if (this.config.get("factorio.sync_whitelist")) {
|
||||
await this.server.sendRcon("/whitelist clear");
|
||||
await this.sendRcon("/whitelist clear");
|
||||
for (let player of this._slave.whitelist) {
|
||||
await this.server.sendRcon(`/whitelist ${player}`);
|
||||
await this.sendRcon(`/whitelist ${player}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
await this.server.sendRcon("/whitelist enable");
|
||||
await this.sendRcon("/whitelist enable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +586,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
let { name, admin } = message.data;
|
||||
let command = admin ? `/promote ${name}` : `/demote ${name}`;
|
||||
await this.server.sendRcon(command);
|
||||
await this.sendRcon(command);
|
||||
}
|
||||
|
||||
async banlistUpdateEventHandler(message) {
|
||||
@@ -552,7 +596,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
let { name, banned, reason } = message.data;
|
||||
let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`;
|
||||
await this.server.sendRcon(command);
|
||||
await this.sendRcon(command);
|
||||
}
|
||||
|
||||
async whitelistUpdateEventHandler(message) {
|
||||
@@ -562,7 +606,7 @@ class Instance extends libLink.Link{
|
||||
|
||||
let { name, whitelisted } = message.data;
|
||||
let command = whitelisted ? `/whitelist add ${name}` : `/whiteliste remove ${name}`;
|
||||
await this.server.sendRcon(command);
|
||||
await this.sendRcon(command);
|
||||
}
|
||||
|
||||
|
||||
@@ -650,11 +694,11 @@ class Instance extends libLink.Link{
|
||||
async createSaveRequestHandler() {
|
||||
this.notifyStatus("creating_save");
|
||||
try {
|
||||
console.log("Clusterio | Writing server-settings.json");
|
||||
this.logger.verbose("Writing server-settings.json");
|
||||
await this.writeServerSettings();
|
||||
|
||||
console.log("Creating save .....");
|
||||
await symlinkMods(this, "sharedMods", console);
|
||||
this.logger.verbose("Creating save .....");
|
||||
await symlinkMods(this, "sharedMods");
|
||||
|
||||
} catch (err) {
|
||||
this.notifyExit();
|
||||
@@ -663,17 +707,17 @@ class Instance extends libLink.Link{
|
||||
|
||||
this.server.on("exit", () => this.notifyExit());
|
||||
await this.server.create("world");
|
||||
console.log("Clusterio | Successfully created save");
|
||||
this.logger.info("Successfully created save");
|
||||
}
|
||||
|
||||
async exportDataRequestHandler() {
|
||||
this.notifyStatus("exporting_data");
|
||||
try {
|
||||
console.log("Clusterio | Writing server-settings.json");
|
||||
this.logger.verbose("Writing server-settings.json");
|
||||
await this.writeServerSettings();
|
||||
|
||||
console.log("Exporting data .....");
|
||||
await symlinkMods(this, "sharedMods", console);
|
||||
this.logger.info("Exporting data .....");
|
||||
await symlinkMods(this, "sharedMods");
|
||||
let zip = await libFactorio.exportData(this.server);
|
||||
|
||||
let content = await zip.generateAsync({ type: "nodebuffer" });
|
||||
@@ -702,7 +746,7 @@ class Instance extends libLink.Link{
|
||||
}
|
||||
|
||||
async sendRconRequestHandler(message) {
|
||||
let result = await this.server.sendRcon(message.data.command);
|
||||
let result = await this.sendRcon(message.data.command);
|
||||
return { result };
|
||||
}
|
||||
|
||||
@@ -734,15 +778,15 @@ class Instance extends libLink.Link{
|
||||
* Searches for instances in the provided directory
|
||||
*
|
||||
* Looks through all sub-dirs of the provided directory for valid
|
||||
* instance definitions and updates the mapping of instance id to
|
||||
* instance definitions and return a mapping of instance id to
|
||||
* instanceInfo objects.
|
||||
*
|
||||
* @param {Map<integer, Object>} instanceInfos -
|
||||
* mapping between instance id and information about this instance.
|
||||
* @param {string} instancesDir - Directory containing instances
|
||||
* @param {Object} logger - console like logging interface.
|
||||
* @returns {Map<integer, Object>}
|
||||
* mapping between instance id and information about this instance.
|
||||
*/
|
||||
async function discoverInstances(instanceInfos, instancesDir, logger) {
|
||||
async function discoverInstances(instancesDir) {
|
||||
let instanceInfos = new Map();
|
||||
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
let instanceConfig = new libConfig.InstanceConfig();
|
||||
@@ -756,23 +800,19 @@ async function discoverInstances(instanceInfos, instancesDir, logger) {
|
||||
continue; // Ignore folders without config.json
|
||||
}
|
||||
|
||||
logger.error(`Error occured while parsing ${configPath}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore instances we have already discovered before.
|
||||
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
|
||||
logger.error(`Error occured while parsing ${configPath}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let instancePath = path.join(instancesDir, entry.name);
|
||||
logger.log(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
instanceInfos.set(instanceConfig.get("instance.id"), {
|
||||
path: instancePath,
|
||||
config: instanceConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
return instanceInfos;
|
||||
}
|
||||
|
||||
class InstanceConnection extends libLink.Link {
|
||||
@@ -793,12 +833,28 @@ class InstanceConnection extends libLink.Link {
|
||||
return await request.send(this.slave, message.data);
|
||||
}
|
||||
|
||||
async forwardRequestToInstance(message, request) {
|
||||
let instanceId = message.data.instance_id;
|
||||
let instanceConnection = this.slave.instanceConnections.get(instanceId);
|
||||
if (!instanceConnection) {
|
||||
// Instance is probably on another slave
|
||||
return await this.forwardRequestToMaster(message, request);
|
||||
}
|
||||
|
||||
if (request.plugin && !instanceConnection.plugins.has(request.plugin)) {
|
||||
throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`);
|
||||
}
|
||||
|
||||
return await request.send(instanceConnection, message.data);
|
||||
}
|
||||
|
||||
|
||||
async forwardEventToInstance(message, event) {
|
||||
let instanceId = message.data.instance_id;
|
||||
let instanceConnection = this.slave.instanceConnections.get(instanceId);
|
||||
if (!instanceConnection) {
|
||||
// Instance is probably on another slave
|
||||
await this.slave.forwardEventToMaster(message, event);
|
||||
await this.forwardEventToMaster(message, event);
|
||||
return;
|
||||
}
|
||||
if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; }
|
||||
@@ -841,7 +897,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering slave");
|
||||
logger.info("SOCKET | registering slave");
|
||||
let plugins = {};
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
plugins[pluginInfo.name] = pluginInfo.version;
|
||||
@@ -878,6 +934,7 @@ class Slave extends libLink.Link {
|
||||
this.config = slaveConfig;
|
||||
|
||||
this.instanceConnections = new Map();
|
||||
this.discoveredInstanceInfos = new Map();
|
||||
this.instanceInfos = new Map();
|
||||
|
||||
this.adminlist = new Set();
|
||||
@@ -889,6 +946,7 @@ class Slave extends libLink.Link {
|
||||
this.serverPlugins = new Map(Object.entries(data.plugins));
|
||||
});
|
||||
|
||||
this._startup = true;
|
||||
this._disconnecting = false;
|
||||
this._shuttingDown = false;
|
||||
|
||||
@@ -898,14 +956,12 @@ class Slave extends libLink.Link {
|
||||
}
|
||||
|
||||
this.updateInstances().catch((err) => {
|
||||
console.error("ERROR: Unexpected error updating instances");
|
||||
console.error(err);
|
||||
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
|
||||
return this.shutdown();
|
||||
|
||||
}).catch((err) => {
|
||||
setBlocking(true);
|
||||
console.error("ERROR: Unexpected error during shutdown");
|
||||
console.error(err);
|
||||
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
@@ -918,23 +974,20 @@ class Slave extends libLink.Link {
|
||||
if (this._disconnecting) {
|
||||
this._disconnecting = false;
|
||||
this.connector.connect().catch((err) => {
|
||||
console.error("ERROR: Unexpected error reconnecting to master");
|
||||
console.error(err);
|
||||
logger.fatal(`Unexpected error reconnecting to master:\n${err.stack}`);
|
||||
return this.shutdown();
|
||||
|
||||
}).catch((err) => {
|
||||
setBlocking(true);
|
||||
console.error("ERROR: Unexpected error during shutdown");
|
||||
console.error(err);
|
||||
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} else {
|
||||
console.error("ERROR: Master connection was unexpectedly closed");
|
||||
logger.fatal("Master connection was unexpectedly closed");
|
||||
this.shutdown().catch((err) => {
|
||||
setBlocking(true);
|
||||
console.error("ERROR: Unexpected error during shutdown");
|
||||
console.error(err);
|
||||
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1006,8 +1059,8 @@ class Slave extends libLink.Link {
|
||||
let updateList = (list, updatedList, prop, event) => {
|
||||
let added = new Set(updatedList);
|
||||
let removed = new Set(list);
|
||||
list.forEach(Set.prototype.delete.bind(added));
|
||||
updatedList.forEach(Set.prototype.delete.bind(removed));
|
||||
list.forEach(el => added.delete(el));
|
||||
updatedList.forEach(el => removed.delete(el));
|
||||
|
||||
for (let name of added) {
|
||||
list.add(name);
|
||||
@@ -1027,15 +1080,14 @@ class Slave extends libLink.Link {
|
||||
updateList(this.adminlist, message.data.adminlist, "admin", libLink.messages.adminlistUpdate);
|
||||
updateList(this.whitelist, message.data.whitelist, "whitelisted", libLink.messages.whitelistUpdate);
|
||||
|
||||
let updatedBanlist = new Map(message.data.banlist);
|
||||
let addedOrChanged = new Map(updatedBanlist);
|
||||
let addedOrChanged = new Map(message.data.banlist);
|
||||
let removed = new Set(this.banlist.keys());
|
||||
removed.forEach((name, reason) => {
|
||||
addedOrChanged.forEach((_, name) => removed.delete(name));
|
||||
this.banlist.forEach((reason, name) => {
|
||||
if (addedOrChanged.get(name) === reason) {
|
||||
addedOrChanged.delete(name);
|
||||
}
|
||||
});
|
||||
updatedBanlist.forEach(Set.prototype.delete.bind(removed));
|
||||
|
||||
for (let [name, reason] of addedOrChanged) {
|
||||
this.banlist.set(name, reason);
|
||||
@@ -1084,25 +1136,41 @@ class Slave extends libLink.Link {
|
||||
let instanceInfo = this.instanceInfos.get(instance_id);
|
||||
if (instanceInfo) {
|
||||
instanceInfo.config.update(serialized_config, true);
|
||||
console.log(`Clusterio | Updated config for ${instanceInfo.path}`);
|
||||
// TODO: Notify of update if instance is running
|
||||
logger.verbose(`Updated config for ${instanceInfo.path}`);
|
||||
|
||||
} else {
|
||||
let instanceConfig = new libConfig.InstanceConfig();
|
||||
await instanceConfig.load(serialized_config);
|
||||
instanceInfo = this.discoveredInstanceInfos.get(instance_id);
|
||||
if (instanceInfo) {
|
||||
instanceInfo.config.update(serialized_config, true);
|
||||
|
||||
// XXX: race condition on multiple simultanious calls
|
||||
let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name"));
|
||||
} else {
|
||||
let instanceConfig = new libConfig.InstanceConfig();
|
||||
await instanceConfig.load(serialized_config);
|
||||
|
||||
// XXX: race condition on multiple simultanious calls
|
||||
let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name"));
|
||||
|
||||
await Instance.create(instanceDir, this.config.get("slave.factorio_directory"));
|
||||
instanceInfo = {
|
||||
path: instanceDir,
|
||||
config: instanceConfig,
|
||||
};
|
||||
|
||||
this.discoveredInstanceInfos.set(instance_id, instanceInfo);
|
||||
}
|
||||
|
||||
await Instance.create(instanceDir, this.config.get("slave.factorio_directory"));
|
||||
instanceInfo = {
|
||||
path: instanceDir,
|
||||
config: instanceConfig,
|
||||
};
|
||||
this.instanceInfos.set(instance_id, instanceInfo);
|
||||
console.log(`Clusterio | assigned instance ${instanceConfig.get("instance.name")}`);
|
||||
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||
}
|
||||
|
||||
// Somewhat hacky, but in the event of a lost session the status is
|
||||
// resent on assigment since the master server sends an assigment
|
||||
// request for all the instances it knows should be on this slave.
|
||||
let instanceConnection = this.instanceConnections.get(instance_id);
|
||||
libLink.messages.instanceStatusChanged.send(this, {
|
||||
instance_id,
|
||||
status: instanceConnection ? instanceConnection.status : "stopped",
|
||||
});
|
||||
|
||||
// save a copy of the instance config
|
||||
let warnedOutput = {
|
||||
@@ -1113,9 +1181,20 @@ class Slave extends libLink.Link {
|
||||
path.join(instanceInfo.path, "instance.json"),
|
||||
JSON.stringify(warnedOutput, null, 4)
|
||||
);
|
||||
}
|
||||
|
||||
// Notify master the instance is no longer usassigned
|
||||
libLink.messages.instanceStatusChanged.send(this, { instance_id, status: "stopped" });
|
||||
async unassignInstanceRequestHandler(message) {
|
||||
let instanceId = message.data.instance_id;
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (instanceInfo) {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
if (instanceConnection && ["starting", "running"].includes(instanceConnection.status)) {
|
||||
await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId });
|
||||
}
|
||||
|
||||
this.instanceInfos.delete(instanceId);
|
||||
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1139,10 +1218,10 @@ class Slave extends libLink.Link {
|
||||
let instance = new Instance(
|
||||
this, connectionClient, instanceInfo.path, this.config.get("slave.factorio_directory"), instanceInfo.config
|
||||
);
|
||||
|
||||
this.instanceConnections.set(instanceId, instanceConnection);
|
||||
await instance.init(this.pluginInfos);
|
||||
|
||||
// XXX: race condition on multiple simultanious calls
|
||||
this.instanceConnections.set(instanceId, instanceConnection);
|
||||
return instanceConnection;
|
||||
}
|
||||
|
||||
@@ -1207,13 +1286,14 @@ class Slave extends libLink.Link {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
await fs.remove(instanceInfo.path);
|
||||
this.discoveredInstanceInfos.delete(instanceId);
|
||||
this.instanceInfos.delete(instanceId);
|
||||
await fs.remove(instanceInfo.path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1223,16 +1303,37 @@ class Slave extends libLink.Link {
|
||||
* the slave and master server with the new list of instances.
|
||||
*/
|
||||
async updateInstances() {
|
||||
await discoverInstances(this.instanceInfos, this.config.get("slave.instances_directory"), console);
|
||||
this.discoveredInstanceInfos = await discoverInstances(this.config.get("slave.instances_directory"));
|
||||
let list = [];
|
||||
for (let [instanceId, instanceInfo] of this.instanceInfos) {
|
||||
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
list.push({
|
||||
serialized_config: instanceInfo.config.serialize(),
|
||||
status: instanceConnection ? instanceConnection.status : "stopped",
|
||||
});
|
||||
}
|
||||
libLink.messages.updateInstances.send(this, { instances: list });
|
||||
await libLink.messages.updateInstances.send(this, { instances: list });
|
||||
|
||||
// Handle configured auto startup instances
|
||||
if (this._startup) {
|
||||
this._startup = false;
|
||||
|
||||
for (let [instanceId, instanceInfo] of this.instanceInfos) {
|
||||
if (instanceInfo.config.get("instance.auto_start")) {
|
||||
try {
|
||||
let instanceConnection = await this._connectInstance(instanceId);
|
||||
await libLink.messages.startInstance.send(instanceConnection, {
|
||||
instance_id: instanceId,
|
||||
save: null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prepareDisconnectRequestHandler(message, request) {
|
||||
@@ -1255,8 +1356,7 @@ class Slave extends libLink.Link {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
console.error("Unexpected error preparing disconnect");
|
||||
console.error(err);
|
||||
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,16 +1425,15 @@ function checkFilename(name) {
|
||||
*
|
||||
* @param {Instance} instance - Instance to link mods for
|
||||
* @param {string} sharedMods - Path to folder to link mods from.
|
||||
* @param {object} logger - console like logging interface.
|
||||
*/
|
||||
async function symlinkMods(instance, sharedMods, logger) {
|
||||
async function symlinkMods(instance, sharedMods) {
|
||||
await fs.ensureDir(instance.path("mods"));
|
||||
|
||||
// Remove broken symlinks in instance mods.
|
||||
for (let entry of await fs.readdir(instance.path("mods"), { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
if (!await fs.pathExists(instance.path("mods", entry.name))) {
|
||||
logger.log(`Removing broken symlink ${entry.name}`);
|
||||
instance.logger.verbose(`Removing broken symlink ${entry.name}`);
|
||||
await fs.unlink(instance.path("mods", entry.name));
|
||||
}
|
||||
}
|
||||
@@ -1346,7 +1445,7 @@ async function symlinkMods(instance, sharedMods, logger) {
|
||||
if (entry.isFile()) {
|
||||
if ([".zip", ".dat"].includes(path.extname(entry.name))) {
|
||||
if (!instanceModsEntries.has(entry.name)) {
|
||||
logger.log(`linking ${entry.name} from ${sharedMods}`);
|
||||
instance.logger.verbose(`linking ${entry.name} from ${sharedMods}`);
|
||||
let target = path.join(sharedMods, entry.name);
|
||||
let link = instance.path("mods", entry.name);
|
||||
|
||||
@@ -1364,23 +1463,27 @@ async function symlinkMods(instance, sharedMods, logger) {
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.warn(`Warning: ignoring file '${entry.name}' in sharedMods`);
|
||||
instance.logger.warn(`Warning: ignoring file '${entry.name}' in sharedMods`);
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.warn(`Warning: ignoring non-file '${entry.name}' in sharedMods`);
|
||||
instance.logger.warn(`Warning: ignoring non-file '${entry.name}' in sharedMods`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startSlave() {
|
||||
// add better stack traces on promise rejection
|
||||
process.on("unhandledRejection", r => console.log(r));
|
||||
|
||||
// argument parsing
|
||||
const args = yargs
|
||||
.scriptName("slave")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stdout",
|
||||
default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)),
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "slave config file to use",
|
||||
@@ -1394,14 +1497,30 @@ 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()
|
||||
.argv
|
||||
;
|
||||
|
||||
console.log(`Loading available plugins from ${args.pluginList}`);
|
||||
logger.add(new winston.transports.File({
|
||||
format: winston.format.combine(
|
||||
winston.format.json(),
|
||||
),
|
||||
filename: "slave.log",
|
||||
}));
|
||||
if (args.logLevel !== "none") {
|
||||
logger.add(new ConsoleTransport({
|
||||
level: args.logLevel,
|
||||
format: new libLoggingUtils.TerminalFormat(),
|
||||
}));
|
||||
}
|
||||
|
||||
// add better stack traces on promise rejection
|
||||
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
|
||||
@@ -1418,19 +1537,19 @@ async function startSlave() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Loading Plugin info");
|
||||
let pluginInfos = await libPlugin.loadPluginInfos(pluginList);
|
||||
logger.info("Loading Plugin info");
|
||||
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
|
||||
console.log(`Loading config from ${args.config}`);
|
||||
logger.info(`Loading config from ${args.config}`);
|
||||
let slaveConfig = new libConfig.SlaveConfig();
|
||||
try {
|
||||
await slaveConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Config not found, initializing new config");
|
||||
logger.info("Config not found, initializing new config");
|
||||
await slaveConfig.init();
|
||||
|
||||
} else {
|
||||
@@ -1439,7 +1558,7 @@ async function startSlave() {
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await libConfig.handleConfigCommand(args, slaveConfig, args.config);
|
||||
await libSharedCommands.handleConfigCommand(args, slaveConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1455,8 +1574,8 @@ async function startSlave() {
|
||||
|
||||
// make sure we have the master access token
|
||||
if (slaveConfig.get("slave.master_token") === "enter token here") {
|
||||
console.error("ERROR invalid config!");
|
||||
console.error(
|
||||
logger.fatal("ERROR invalid config!");
|
||||
logger.fatal(
|
||||
"Master server requires an access token for socket operations. As clusterio\n"+
|
||||
"slaves depends upon this, please set your token using the command npx\n"+
|
||||
"clusterioslave config set slave.master_token <token>. You can generate an\n"+
|
||||
@@ -1468,8 +1587,8 @@ async function startSlave() {
|
||||
|
||||
// make sure url ends with /
|
||||
if (!slaveConfig.get("slave.master_url").endsWith("/")) {
|
||||
console.error("ERROR invalid config!");
|
||||
console.error("slave.master_url must end with '/'");
|
||||
logger.fatal("ERROR invalid config!");
|
||||
logger.fatal("slave.master_url must end with '/'");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1481,24 +1600,28 @@ async function startSlave() {
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
console.log("Caught second interrupt, terminating immediately");
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
console.log("Caught interrupt signal, shutting down");
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
slave.shutdown().catch(err => {
|
||||
console.error(`
|
||||
logger.error(`
|
||||
+---------------------------------------------------------------+
|
||||
| Unexpected error occured while stopping slave, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+---------------------------------------------------------------+`
|
||||
+---------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
slaveConnector.once("connect", () => {
|
||||
logger.add(new libLoggingUtils.LinkTransport({ link: slave }));
|
||||
});
|
||||
|
||||
await slaveConnector.connect();
|
||||
}
|
||||
|
||||
@@ -1512,6 +1635,7 @@ module.exports = {
|
||||
};
|
||||
|
||||
if (module === require.main) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
@@ -1521,16 +1645,16 @@ I version of clusterio. Expect things to break. I
|
||||
);
|
||||
startSlave().catch(err => {
|
||||
if (err instanceof libErrors.AuthenticationFailed) {
|
||||
console.error(err.message);
|
||||
logger.fatal(err.message);
|
||||
|
||||
} else {
|
||||
console.error(`
|
||||
logger.fatal(`
|
||||
+--------------------------------------------------------------+
|
||||
| Unexpected error occured while starting slave, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+--------------------------------------------------------------+`
|
||||
+--------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@clusterio/web_ui",
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"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.3",
|
||||
"antd": "^4.6.5",
|
||||
"buffer": "^6.0.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"events": "^3.2.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"util": "^0.12.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -4,10 +4,12 @@ import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import libConfig from "@clusterio/lib/config";
|
||||
import { ConsoleTransport, WebConsoleFormat, logger } from "@clusterio/lib/logging";
|
||||
|
||||
import App from "./components/App";
|
||||
import { Control, ControlConnector } from "./util/websocket";
|
||||
|
||||
|
||||
async function loadScript(url) {
|
||||
let script = document.createElement("script");
|
||||
script.src = url;
|
||||
@@ -30,7 +32,7 @@ async function loadPluginInfos() {
|
||||
pluginList = await response.json();
|
||||
|
||||
} else {
|
||||
console.log("Failed to get plugin data, running without plugins");
|
||||
logger.error("Failed to get plugin data, running without plugins");
|
||||
pluginList = [];
|
||||
}
|
||||
|
||||
@@ -46,14 +48,17 @@ async function loadPluginInfos() {
|
||||
pluginInfos.push(info);
|
||||
|
||||
} catch (err) {
|
||||
console.log(`Failed to load plugin info for ${plugin.name}`);
|
||||
console.log(err);
|
||||
logger.error(`Failed to load plugin info for ${plugin.name}`);
|
||||
}
|
||||
}
|
||||
return pluginInfos;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
logger.add(new ConsoleTransport({
|
||||
level: "verbose",
|
||||
format: new WebConsoleFormat(),
|
||||
}));
|
||||
let pluginInfos = await loadPluginInfos();
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
@@ -61,17 +66,10 @@ async function load() {
|
||||
let wsUrl = new URL(window.webRoot, document.location);
|
||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
||||
|
||||
let controlConnector = new ControlConnector(wsUrl, 10, localStorage.getItem("master_token"));
|
||||
// XXX come up with a better way of sharing this
|
||||
window.control = new Control(controlConnector, []);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
// XXX: What now?
|
||||
console.log(err);
|
||||
}
|
||||
let controlConnector = new ControlConnector(wsUrl, 10);
|
||||
let control = new Control(controlConnector, []);
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
||||
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
|
||||
}
|
||||
|
||||
load().catch(console.log);
|
||||
load().catch((err) => logger.fatal(err));
|
||||
@@ -0,0 +1,85 @@
|
||||
import events from "events";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { logger } from "@clusterio/lib/logging";
|
||||
|
||||
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) {
|
||||
logger.error(`Unexpected error in connector:\n${err.stack}`);
|
||||
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,637 @@
|
||||
import React, { useEffect, useContext, useRef, 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 formatParsedOutput(parsed, key) {
|
||||
let time = "";
|
||||
if (parsed.format === "seconds") {
|
||||
time = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
|
||||
} else if (parsed.format === "date") {
|
||||
time = <span className="factorio-time">{parsed.time} </span>;
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (parsed.type === "log") {
|
||||
let level = parsed.level;
|
||||
if (level === "Script") {
|
||||
level = <span className="factorio-script">{level}</span>;
|
||||
} else if (level === "Verbose") {
|
||||
level = <span className="factorio-verbose">{level}</span>;
|
||||
} else if (level === "Info") {
|
||||
level = <span className="factorio-info">{level}</span>;
|
||||
} else if (parsed.level === "Warning") {
|
||||
level = <span className="factorio-warning">{level}</span>;
|
||||
} else if (parsed.level === "Error") {
|
||||
level = <span className="factorio-error">{level}</span>;
|
||||
}
|
||||
|
||||
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
|
||||
|
||||
} else if (parsed.type === "action") {
|
||||
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
|
||||
}
|
||||
|
||||
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
|
||||
}
|
||||
|
||||
function formatLog(info, key) {
|
||||
if (info.level === "server" && info.parsed) {
|
||||
return formatParsedOutput(info.parsed, key);
|
||||
}
|
||||
let level = <span className={`log-${info.level}`}>{info.level}</span>;
|
||||
return <span key={key}>[{level}] {info.message}<br/></span>;
|
||||
}
|
||||
|
||||
function InstanceConsole(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let anchor = useRef();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
let [lines, setLines] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll view to the anchor so it sticks to the bottom
|
||||
let parent = anchor.current.parentElement;
|
||||
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
|
||||
|
||||
libLink.messages.queryLog.send(control, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [props.id],
|
||||
max_level: null,
|
||||
}).then(result => {
|
||||
setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index)));
|
||||
}).catch(err => {
|
||||
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
|
||||
function logHandler(info) {
|
||||
setLines(currentLines => currentLines.concat(
|
||||
[formatLog(info, currentLines.length)]
|
||||
));
|
||||
}
|
||||
|
||||
control.onInstanceLog(props.id, logHandler);
|
||||
return () => {
|
||||
control.offInstanceLog(props.id, logHandler);
|
||||
};
|
||||
}, [props.id]);
|
||||
|
||||
return <>
|
||||
<Paragraph code className="instance-console">
|
||||
{pastLines}
|
||||
{lines}
|
||||
<div className="scroll-anchor" ref={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,52 @@
|
||||
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();
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
let serialized_config = instanceConfig.serialize();
|
||||
let response = await libLink.messages.createInstance.send(control, { serialized_config });
|
||||
},
|
||||
}}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
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,150 @@
|
||||
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 => {
|
||||
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,50 @@
|
||||
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 => {
|
||||
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,68 @@
|
||||
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 => {
|
||||
history.push(`/users/${record.key}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
+15
-21
@@ -54,7 +54,6 @@ class DataTable extends Component {
|
||||
pagination = pagination || this.state.pagination;
|
||||
filters = filters || this.state.filters;
|
||||
sorter = sorter || this.state.sorter;
|
||||
// console.log(pagination, filters, sorter)
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
let data = await this.props.DataFunction();
|
||||
@@ -67,7 +66,6 @@ class DataTable extends Component {
|
||||
// sorter,
|
||||
// recordIdField: this.props.RecordIdField || "Id", // Sort by this
|
||||
// })
|
||||
console.log(data);
|
||||
data = data || {};
|
||||
this.setState({
|
||||
// eslint-disable-next-line new-cap
|
||||
@@ -96,8 +94,6 @@ class DataTable extends Component {
|
||||
}
|
||||
|
||||
async onEditFinish(values) {
|
||||
console.log("Finished editing", values);
|
||||
|
||||
if (this.state.inserting) { // Create new record
|
||||
await this.props.AddRecord.insert(values);
|
||||
} else { // Edit existing record, capable of doing sparse updates
|
||||
@@ -126,8 +122,6 @@ class DataTable extends Component {
|
||||
// record,
|
||||
// }, undefined, true)
|
||||
|
||||
console.log("Deleted record", status);
|
||||
|
||||
if (status.ok) notify("success", status.msg, "success");
|
||||
if (!status.ok) notify("error", status.msg, "error");
|
||||
|
||||
@@ -150,7 +144,6 @@ class DataTable extends Component {
|
||||
return {
|
||||
onClick: event => {
|
||||
if (event.target.type !== "button") {
|
||||
console.log("onRow", record, rowIndex);
|
||||
if (this.props.Editable) this.showEditModal(record, rowIndex);
|
||||
if (externalOnRow.onClick) externalOnRow.onClick(event);
|
||||
}
|
||||
@@ -242,20 +235,21 @@ class DataTable extends Component {
|
||||
onFinish={this.onEditFinish.bind(this)}
|
||||
initialValues={this.state.record}
|
||||
>
|
||||
{
|
||||
|
||||
(
|
||||
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields) // Use externally supplied form input fields
|
||||
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns
|
||||
|| this.inferColumns(this.state.tableData)).map(col => { // Generate input fields from autogenerated columns (only works with existing data)
|
||||
console.log(this.state.record);
|
||||
return <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
|
||||
{col.renderEdit && col.renderEdit(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
|
||||
placeholder={this.state.record[col.dataIndex]}
|
||||
/>}
|
||||
</Form.Item>;
|
||||
})
|
||||
}
|
||||
{(
|
||||
// Use externally supplied form input fields
|
||||
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
|
||||
// Generate input fields from external columns
|
||||
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
|
||||
// Generate input fields from autogenerated columns (only works with existing data)
|
||||
|| this.inferColumns(this.state.tableData)
|
||||
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
|
||||
{col.renderEdit && col.renderEdit(
|
||||
this.state.record[col.dataIndex],
|
||||
this.state.record,
|
||||
undefined /* Non spec compliant, is supposed to be index of record */,
|
||||
this.state.tableData
|
||||
) || <Input placeholder={this.state.record[col.dataIndex]} />}
|
||||
</Form.Item>)}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{this.state.inserting ? "Legg til" : "Lagre"}
|
||||
File renamed without changes.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,200 @@
|
||||
@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;
|
||||
white-space: pre-wrap;
|
||||
|
||||
/* Make sure there's at least one pixel of scroll space, this */
|
||||
/* is necessary for the view to stick on the scroll anchor. */
|
||||
padding-top: 300px;
|
||||
}
|
||||
|
||||
.instance-console .factorio-time {
|
||||
color: #afaf1f;
|
||||
}
|
||||
|
||||
.instance-console .factorio-filename {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.instance-console .factorio-script {
|
||||
font-weight: bold;
|
||||
color: #49c33c;
|
||||
}
|
||||
|
||||
.instance-console .factorio-verbose {
|
||||
font-weight: bold;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.log-verbose {
|
||||
font-weight: bold;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
font-weight: bold;
|
||||
color: #4646ea;
|
||||
}
|
||||
|
||||
.log-audit {
|
||||
font-weight: bold;
|
||||
color: #49c33c;
|
||||
}
|
||||
|
||||
.log-warn {
|
||||
font-weight: bold;
|
||||
color: #afaf1f;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
font-weight: bold;
|
||||
color: #ad3939;
|
||||
}
|
||||
|
||||
.log-fatal {
|
||||
font-weight: bold;
|
||||
background-color: #ad3939;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.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,36 @@
|
||||
import { notification } from "antd";
|
||||
import { logger } from "@clusterio/lib/logging";
|
||||
|
||||
|
||||
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") {
|
||||
logger.error(message.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
logger.error(err.stack);
|
||||
notification.error({
|
||||
message,
|
||||
description: err.message,
|
||||
duration: 0,
|
||||
placement: "bottomRight",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import libLink from "@clusterio/lib/link";
|
||||
import libPlugin from "@clusterio/lib/plugin";
|
||||
import libErrors from "@clusterio/lib/errors";
|
||||
import { logger } from "@clusterio/lib/logging";
|
||||
import packageJson from "../../package.json";
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to master server
|
||||
* @private
|
||||
*/
|
||||
export class ControlConnector extends libLink.WebSocketClientConnector {
|
||||
constructor(url, reconnectDelay) {
|
||||
super(url, reconnectDelay);
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
register() {
|
||||
if (!this.token) {
|
||||
throw new Error("Token not set");
|
||||
}
|
||||
|
||||
logger.verbose("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this.token,
|
||||
agent: "Clusterio Web UI",
|
||||
version: packageJson.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Control extends libLink.Link {
|
||||
constructor(connector, controlPlugins) {
|
||||
super("control", "master", connector);
|
||||
libLink.attachAllMessages(this);
|
||||
|
||||
/**
|
||||
* Mapping of plugin names to their instance for loaded plugins.
|
||||
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
|
||||
*/
|
||||
this.plugins = controlPlugins;
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||
}
|
||||
|
||||
this.instanceLogHandlers = new Map();
|
||||
|
||||
this.connector.on("connect", () => {
|
||||
this.updateLogSubscriptions().catch(err => logger.error(
|
||||
`Unexpected error updating log subscriptions:\n${err.stack}`
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async logMessageEventHandler(message) {
|
||||
let info = message.data.info;
|
||||
|
||||
if (info.instance_id !== undefined) {
|
||||
let instanceHandlers = this.instanceLogHandlers.get(info.instance_id);
|
||||
for (let handler of instanceHandlers || []) {
|
||||
handler(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onInstanceLog(id, handler) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("Invalid instance id");
|
||||
}
|
||||
|
||||
let handlers = this.instanceLogHandlers.get(id);
|
||||
if (!handlers) {
|
||||
handlers = [];
|
||||
this.instanceLogHandlers.set(id, handlers);
|
||||
}
|
||||
|
||||
handlers.push(handler);
|
||||
|
||||
if (handlers.length === 1) {
|
||||
await this.updateLogSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
async offInstanceLog(id, handler) {
|
||||
let handlers = this.instanceLogHandlers.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.updateLogSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
async updateLogSubscriptions() {
|
||||
await libLink.messages.setLogSubscriptions.send(this, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [...this.instanceLogHandlers.keys()],
|
||||
max_level: null,
|
||||
});
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.connector.setTimeout(30);
|
||||
|
||||
try {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await this.connector.close(1000, "Control Quit");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ module.exports = (env = {}) => ({
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(),
|
||||
|
||||
// required for winston
|
||||
new webpack.ProvidePlugin({
|
||||
process: require.resolve("process/browser.js"),
|
||||
Buffer: [require.resolve("buffer/"), "Buffer"],
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
"process.env.APP_ENV": JSON.stringify("browser"),
|
||||
}),
|
||||
@@ -29,6 +36,19 @@ module.exports = (env = {}) => ({
|
||||
test: /node_modules.fs-extra/,
|
||||
use: "null-loader",
|
||||
},
|
||||
{
|
||||
test: /node_modules.jsonwebtoken/,
|
||||
use: "null-loader",
|
||||
},
|
||||
{
|
||||
test: /node_modules.winston.dist.winston.transports.(http|file)/,
|
||||
use: "null-loader",
|
||||
},
|
||||
// Colour library used by winston which doesn't work in browser.
|
||||
{
|
||||
test: /node_modules.colors/,
|
||||
use: "null-loader",
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
@@ -59,11 +79,19 @@ module.exports = (env = {}) => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.png$/,
|
||||
use: "file-loader",
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".js", ".json", ".jsx"],
|
||||
fallback: {
|
||||
// Required for winston
|
||||
"util": require.resolve("util/"),
|
||||
"os": require.resolve("os-browserify/browser"),
|
||||
|
||||
"events": require.resolve("events/"),
|
||||
"path": require.resolve("path-browserify"),
|
||||
},
|
||||
@@ -33,7 +33,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
async chatEventHandler(message) {
|
||||
// TODO check if cross server chat is enabled
|
||||
let content = `[${message.data.instance_name}] ${removeTags(message.data.content)}`;
|
||||
await this.instance.server.sendRcon(`/sc game.print('${libLuaTools.escapeString(content)}')`, true);
|
||||
await this.sendRcon(`/sc game.print('${libLuaTools.escapeString(content)}')`, true);
|
||||
}
|
||||
|
||||
sendChat(message) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -17,14 +17,10 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@clusterio/master": "^2.0.0-alpha.1",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"events": "^3.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^5.2.0"
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("global_chat plugin", function() {
|
||||
instancePlugin.instance.connector.sentMessages = [];
|
||||
await instancePlugin.onOutput(output);
|
||||
assert(instancePlugin.instance.connector.sentMessages.length, "message was not sent");
|
||||
count++;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert(count > 0, "no lines were tested");
|
||||
@@ -58,7 +58,7 @@ describe("global_chat plugin", function() {
|
||||
instancePlugin.instance.connector.sentMessages = [];
|
||||
await instancePlugin.onOutput(output);
|
||||
assert(!instancePlugin.instance.connector.sentMessages.length, "message was sent");
|
||||
count++;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert(count > 0, "no lines were tested");
|
||||
|
||||
@@ -3,9 +3,10 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/master/webpack.common");
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"ajv": { import: false },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -3,23 +3,21 @@ const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
|
||||
function unexpectedError(err) {
|
||||
console.log("Unexpected error in research_sync");
|
||||
console.log("---------------------------------");
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
unexpectedError(err) {
|
||||
this.logger.error(`Unexpected error:\n${err.stack}`);
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("research_sync plugin requires save patching.");
|
||||
}
|
||||
|
||||
this.instance.server.on("ipc-research_sync:contribution", (tech) => {
|
||||
this.researchContribution(tech).catch(unexpectedError);
|
||||
this.researchContribution(tech).catch(err => this.unexpectedError(err));
|
||||
});
|
||||
this.instance.server.on("ipc-research_sync:finished", (tech) => {
|
||||
this.researchFinished(tech).catch(unexpectedError);
|
||||
this.researchFinished(tech).catch(err => this.unexpectedError(err));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +27,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
|
||||
async progressEventHandler(message) {
|
||||
let techsJson = libLuaTools.escapeString(JSON.stringify(message.data.technologies));
|
||||
await this.instance.server.sendRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
|
||||
await this.sendRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
|
||||
}
|
||||
|
||||
async researchFinished(tech) {
|
||||
@@ -38,13 +36,13 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
|
||||
async finishedEventHandler(message) {
|
||||
let { name, level, researched } = message.data;
|
||||
await this.instance.server.sendRcon(
|
||||
await this.sendRcon(
|
||||
`/sc research_sync.research_technology("${libLuaTools.escapeString(name)}", ${level})`, true
|
||||
);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
let dumpJson = await this.instance.server.sendRcon("/sc research_sync.dump_technologies()");
|
||||
let dumpJson = await this.sendRcon("/sc research_sync.dump_technologies()");
|
||||
let techsToSend = [];
|
||||
let instanceTechs = new Map();
|
||||
for (let tech of JSON.parse(dumpJson)) {
|
||||
@@ -74,7 +72,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
|
||||
if (techsToSync.length) {
|
||||
let syncJson = libLuaTools.escapeString(JSON.stringify(techsToSync));
|
||||
await this.instance.server.sendRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
|
||||
await this.sendRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const libPlugin = require("@clusterio/lib/plugin");
|
||||
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
this.technologies = await loadTechnologies(this.master.config);
|
||||
this.technologies = await loadTechnologies(this.master.config, this.logger);
|
||||
this.lastProgressBroadcast = Date.now();
|
||||
this.progressBroadcastId = null;
|
||||
this.progressToBroadcast = new Set();
|
||||
@@ -17,7 +17,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
if (this.progressBroadcastId) {
|
||||
clearTimeout(this.progressBroadcastId);
|
||||
}
|
||||
await saveTechnologies(this.master.config, this.technologies);
|
||||
await saveTechnologies(this.master.config, this.technologies, this.logger);
|
||||
}
|
||||
|
||||
registerProgress() {
|
||||
@@ -159,15 +159,15 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTechnologies(masterConfig) {
|
||||
async function loadTechnologies(masterConfig, logger) {
|
||||
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
|
||||
console.log(`Loading ${filePath}`);
|
||||
logger.verbose(`Loading ${filePath}`);
|
||||
try {
|
||||
return new Map(JSON.parse(await fs.readFile(filePath)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Creating new technologies database");
|
||||
logger.verbose("Creating new technologies database");
|
||||
return new Map();
|
||||
|
||||
} else {
|
||||
@@ -176,9 +176,9 @@ async function loadTechnologies(masterConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTechnologies(masterConfig, technologies) {
|
||||
async function saveTechnologies(masterConfig, technologies, logger) {
|
||||
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
|
||||
console.log(`writing ${filePath}`);
|
||||
logger.verbose(`writing ${filePath}`);
|
||||
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -20,14 +20,10 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@clusterio/master": "^2.0.0-alpha.1",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"events": "^3.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^5.2.0"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,10 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/master/webpack.common");
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"ajv": { import: false },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ Clusterio Statistics Exporter Plugin
|
||||
====================================
|
||||
|
||||
Plugin exporting item production, fluid production, kill count, entity
|
||||
build and poluttion statistics to the Prometheus integration to
|
||||
build and pollution statistics to the Prometheus integration to
|
||||
Clusterio.
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const { Gauge } = require("@clusterio/lib/prometheus");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
|
||||
|
||||
const instancePlayerCount = new Gauge(
|
||||
@@ -20,6 +21,17 @@ const instanceGameFlowStatistics = new Gauge(
|
||||
{ labels: ["instance_id", "statistic", "direction", "name"] },
|
||||
);
|
||||
|
||||
function setForceFlowStatistic(instanceId, forceName, statisticName, direction, item, value) {
|
||||
instanceForceFlowStatistics.labels(String(instanceId), forceName, statisticName, direction, item).set(value);
|
||||
|
||||
// For item and fluid statistics it's useful to compare the input flow with the
|
||||
// output flow, to simplify the comparison ensure both directions have a value.
|
||||
if (["item_production_statistics", "fluid_production_statistic"].includes(statisticName)) {
|
||||
let reverseDirection = direction === "input" ? "output" : "input";
|
||||
instanceForceFlowStatistics.labels(String(instanceId), forceName, statisticName, reverseDirection, item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
async init() {
|
||||
@@ -28,8 +40,8 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async onMetrics() {
|
||||
let string = await this.instance.server.sendRcon("/sc statistics_exporter.export()");
|
||||
async gatherMetrics() {
|
||||
let string = await this.sendRcon("/sc statistics_exporter.export()");
|
||||
let stats;
|
||||
try {
|
||||
stats = JSON.parse(string);
|
||||
@@ -45,9 +57,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
for (let [statisticName, statistic] of Object.entries(flowStatistics)) {
|
||||
for (let [direction, counts] of Object.entries(statistic)) {
|
||||
for (let [item, value] of Object.entries(counts)) {
|
||||
instanceForceFlowStatistics.labels(
|
||||
String(instanceId), forceName, statisticName, direction, item
|
||||
).set(value);
|
||||
setForceFlowStatistic(instanceId, forceName, statisticName, direction, item, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +71,14 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMetrics() {
|
||||
// Wait up to 5 seconds for the metrics to be collected. It may
|
||||
// take a long time for the command to go through if the command
|
||||
// stream is overloaded. Should this take more than 5 seconds the
|
||||
// previous values for the metrics will end up being sent to master.
|
||||
await libHelpers.timeout(this.gatherMetrics(), 5e3);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -17,14 +17,10 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@clusterio/master": "^2.0.0-alpha.1",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"events": "^3.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^5.2.0"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,10 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/master/webpack.common");
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"ajv": { import: false },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -103,6 +103,7 @@ function doleDivider({
|
||||
object,
|
||||
items,
|
||||
logItemTransfers,
|
||||
logger,
|
||||
}) {
|
||||
let itemCount = items.getItemCount(object.name);
|
||||
// lower rates will equal more dramatic swings
|
||||
@@ -115,7 +116,7 @@ function doleDivider({
|
||||
object.count /= ((_doleDivisionFactor[object.name] || 0) + doleDivisionRetardation) / doleDivisionRetardation;
|
||||
object.count = Math.round(object.count);
|
||||
if (logItemTransfers) {
|
||||
console.info(
|
||||
logger.verbose(
|
||||
`Serving ${object.count}/${originalCount} ${object.name} from ${itemCount} ${object.name} `+
|
||||
`with dole division factor ${(_doleDivisionFactor[object.name]||0)} `+
|
||||
`(real=${((_doleDivisionFactor[object.name] || 0) + doleDivisionRetardation) / doleDivisionRetardation}), `+
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,33 +4,31 @@ const fs = require("fs-extra");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
function unexpectedError(err) {
|
||||
console.log("Unexpected error in subspace_storage");
|
||||
console.log("------------------------------------");
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
unexpectedError(err) {
|
||||
this.logger.error(`Unexpected error:\n${err.stack}`);
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.instance.server.on("ipc-subspace_storage:output", (output) => {
|
||||
this.provideItems(output).catch(unexpectedError);
|
||||
this.provideItems(output).catch(err => this.unexpectedError(err));
|
||||
});
|
||||
this.instance.server.on("ipc-subspace_storage:orders", (orders) => {
|
||||
this.requestItems(orders).catch(unexpectedError);
|
||||
this.requestItems(orders).catch(err => this.unexpectedError(err));
|
||||
});
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.pingId = setInterval(() => {
|
||||
this.instance.server.sendRcon(
|
||||
this.sendRcon(
|
||||
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0", true
|
||||
).catch(unexpectedError);
|
||||
).catch(err => this.unexpectedError(err));
|
||||
}, 5000);
|
||||
|
||||
let response = await this.info.messages.getStorage.send(this.instance);
|
||||
// TODO Diff with dump of invdata produce minimal command to sync
|
||||
let itemsJson = libLuaTools.escapeString(JSON.stringify(response.items));
|
||||
await this.instance.server.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}", true)`, true);
|
||||
await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}", true)`, true);
|
||||
}
|
||||
|
||||
onExit() {
|
||||
@@ -45,8 +43,8 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
});
|
||||
|
||||
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
|
||||
console.log("Exported the following to master:");
|
||||
console.log(items);
|
||||
this.logger.verbose("Exported the following to master:");
|
||||
this.logger.verbose(JSON.stringify(items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,31 +61,23 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
}
|
||||
|
||||
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
|
||||
console.log("Imported following from master:");
|
||||
console.log(response.items);
|
||||
this.logger.verbose("Imported following from master:");
|
||||
this.logger.verbose(JSON.stringify(response.items));
|
||||
}
|
||||
|
||||
let itemsJson = libLuaTools.escapeString(JSON.stringify(response.items));
|
||||
await this.instance.server.sendRcon(`/sc __subspace_storage__ Import("${itemsJson}")`, true);
|
||||
await this.sendRcon(`/sc __subspace_storage__ Import("${itemsJson}")`, true);
|
||||
}
|
||||
|
||||
// combinator signals ---------------------------------------------------------
|
||||
async updateStorageEventHandler(message, event) {
|
||||
let items = message.data.items;
|
||||
|
||||
// XXX this should be done on the lua side
|
||||
// Ensure counts don't overflow
|
||||
for (let item of items) {
|
||||
if (item[1] > 0x7fffffff) {
|
||||
item[1] = 0x7fffffff;
|
||||
}
|
||||
}
|
||||
|
||||
// XXX this should be moved to instance/clusterio api
|
||||
items.push(["signal-unixtime", Math.floor(Date.now()/1000)]);
|
||||
|
||||
let itemsJson = libLuaTools.escapeString(JSON.stringify(items));
|
||||
await this.instance.server.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
|
||||
await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,11 @@ const masterInventoryGauge = new Gauge(
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
|
||||
this.items = await loadDatabase(this.master.config);
|
||||
this.items = await loadDatabase(this.master.config, this.logger);
|
||||
this.itemsLastUpdate = new Map(this.items._items.entries());
|
||||
this.autosaveId = setInterval(() => {
|
||||
saveDatabase(this.master.config, this.items).catch(err => {
|
||||
console.log("Unexpected error autosaving items in subspace_storage");
|
||||
console.log("-----------------------------------------------------");
|
||||
console.log(err);
|
||||
saveDatabase(this.master.config, this.items, this.logger).catch(err => {
|
||||
this.logger.error(`Unexpected error autosaving items:\n${err.stack}`);
|
||||
});
|
||||
}, this.master.config.get("subspace_storage.autosave_interval") * 1000);
|
||||
|
||||
@@ -83,8 +81,9 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
this.updateStorage();
|
||||
|
||||
if (this.master.config.get("subspace_storage.log_item_transfers")) {
|
||||
console.log(`Imported the following from ${message.data.instance_id}:`);
|
||||
console.log(message.data.items);
|
||||
this.logger.verbose(
|
||||
`Imported the following from ${message.data.instance_id}:\n${JSON.stringify(message.data.items)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +124,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
object: { name: item[0], count: item[1], instanceId, instanceName },
|
||||
items: this.items,
|
||||
logItemTransfers: this.master.config.get("subspace_storage.log_item_transfers"),
|
||||
logger: this.logger,
|
||||
});
|
||||
if (count > 0) {
|
||||
itemsRemoved.push([item[0], count]);
|
||||
@@ -145,8 +145,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
this.updateStorage();
|
||||
|
||||
if (itemsRemoved.length && this.master.config.get("subspace_storage.log_item_transfers")) {
|
||||
console.log(`Exported the following to ${instanceId}`);
|
||||
console.log(itemsRemoved);
|
||||
this.logger.verbose(`Exported the following to ${instanceId}:\n${JSON.stringify(itemsRemoved)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,20 +165,20 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
async onShutdown() {
|
||||
clearInterval(this.autosaveId);
|
||||
clearInterval(this.doleMagicId);
|
||||
await saveDatabase(this.master.config, this.items);
|
||||
await saveDatabase(this.master.config, this.items, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabase(config) {
|
||||
async function loadDatabase(config, logger) {
|
||||
let itemsPath = path.resolve(config.get("master.database_directory"), "items.json");
|
||||
console.log(`Loading ${itemsPath}`);
|
||||
logger.verbose(`Loading ${itemsPath}`);
|
||||
try {
|
||||
let content = await fs.readFile(itemsPath);
|
||||
return new libDatabase.ItemDatabase(JSON.parse(content));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Creating new item database");
|
||||
logger.verbose("Creating new item database");
|
||||
return new libDatabase.ItemDatabase();
|
||||
|
||||
} else {
|
||||
@@ -188,14 +187,14 @@ async function loadDatabase(config) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDatabase(masterConfig, items) {
|
||||
async function saveDatabase(masterConfig, items, logger) {
|
||||
if (items && items.size < 50000) {
|
||||
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
|
||||
console.log(`writing ${file}`);
|
||||
logger.verbose(`writing ${file}`);
|
||||
let content = JSON.stringify(items.serialize());
|
||||
await fs.outputFile(file, content);
|
||||
} else if (items) {
|
||||
console.error(`Item database too large, not saving (${items.size})`);
|
||||
logger.error(`Item database too large, not saving (${items.size})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-subspace_storage",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"version": "2.0.0-alpha.3",
|
||||
"description": "Clusterio plugin for sharing storage between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,15 +20,11 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@clusterio/master": "^2.0.0-alpha.1",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.3",
|
||||
"antd": "^4.6.5",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"events": "^3.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
|
||||
@@ -3,9 +3,10 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/master/webpack.common");
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
@@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"ajv": { import: false },
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
|
||||
-11
@@ -1,19 +1,8 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
|
||||
const { testLines } = require("./lib/factorio/lines");
|
||||
const clusterioctl = require("@clusterio/ctl/ctl.js");
|
||||
|
||||
|
||||
describe("clusterioctl", function() {
|
||||
describe("formatOutputColored()", function() {
|
||||
it("should pass the test lines", function() {
|
||||
for (let [reference, output] of testLines) {
|
||||
let line = clusterioctl._formatOutputColored(output);
|
||||
// Strip colours
|
||||
line = line.replace(/\x1B\[\d+m/g, "");
|
||||
assert.deepEqual(line, reference);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
@@ -166,6 +185,10 @@ describe("Integration of Clusterio", function() {
|
||||
assert.equal(await sendRcon(44, `/config get ${configName}`), `${expectedResult}\n`);
|
||||
}
|
||||
});
|
||||
it("should not change the instance status", async function() {
|
||||
slowTest(this);
|
||||
await checkInstanceStatus(44, "running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user set-admin/whitelisted/banned", function() {
|
||||
@@ -293,7 +316,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("user set-roles", function() {
|
||||
it("should set the roles on the user", async function() {
|
||||
await execCtl("user set-roles temp Admin");
|
||||
await execCtl('user set-roles temp "Cluster Admin"');
|
||||
let result = await libLink.messages.listUsers.send(getControl());
|
||||
let tempUser = result.list.find(user => user.name === "temp");
|
||||
assert.deepEqual(tempUser.roles, [0]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user