Compare commits

..
Author SHA1 Message Date
Hornwitser 6da7b84757 v2.0.0-alpha.10.c hotfix for installer 2021-12-09 00:14:19 +01:00
Arvids Godjuks 072e95046a Merge pull request #436 from jake-87/patch-1
Wrong capitalisation on `process.env`
2021-12-09 01:04:43 +02:00
Hornwitser 5aa4d99ea3 Fix installer ignoring --plugins argument (#440)
Fix issue passing --plugins to installer which was discovered in #438.
2021-12-09 00:03:45 +01:00
Hornwitser f1ac22881c Use strict parsing in installer (#439)
Bail out if arguments that aren't understood are passed to the
installer script instead of silently ignoring them.
2021-12-08 23:59:40 +01:00
J87 fb85869d09 Fix spelling mistake 2021-12-07 22:13:28 +11:00
J87 c69672ec60 Variable misspelling 2021-12-07 21:42:58 +11:00
J87 3e1a41393a Fix incorrrent spelling of variable
Honestly not sure how this one slipped through
2021-12-07 21:42:10 +11:00
J87 e9225a4b2c Wrong capitalisation on process.env 2021-12-07 21:38:46 +11:00
Arvids Godjuks df80effbdf Merge pull request #434 from Danielv123/small-changes-for-plugins
Small changes for plugins
2021-11-25 01:37:28 +02:00
Arvids Godjuks 35a60e798e Merge pull request #431 from jake-87/user-editable-config
Implementing user editable config for CLI editors
2021-11-25 01:35:14 +02:00
J87 dfd0cb2d36 Fix linter issue 2021-11-25 07:45:56 +11:00
danielv da941a299c Start all and stop all instance buttons 2021-11-24 21:33:55 +01:00
J87 81e3c9e19b Removed unnecessary double negation 2021-11-24 14:31:42 +11:00
J87 b9e8ffa3d1 Fix linting error 2021-11-24 09:33:08 +11:00
J87 62efc63830 Fixed variable definition 2021-11-23 22:03:36 +11:00
J87 e6e5805a49 Refactored serializedConfigToString into function
I'm unable to test right now, so this is currently not yet tested. Will test asap.
2021-11-23 13:12:49 +11:00
Danielv123 a3d98595e2 Fix linter errors 2021-11-22 13:28:29 +01:00
Danielv123 6af34dc3a6 Update BaseConfigTree when props.id changes 2021-11-22 13:17:39 +01:00
Danielv123 1cf3e39d72 Avoid savesProvider throwing errors on missing instanceId 2021-11-22 12:44:48 +01:00
Danielv123 b192c82dcc Export induvidual components from the web interface for plugin use 2021-11-22 10:20:44 +01:00
Danielv123 9138efca6f Clear log console when changing props 2021-11-22 10:20:06 +01:00
Danielv123 52238de9b3 Expose react-router-dom to allow plugins to use useHistory()
See https://githubmemory.com/repo/module-federation/module-federation-examples/issues/716
2021-11-22 10:19:43 +01:00
J87 cc064c2fd1 Fix mistake with Instance descriptions
Also added warning for description not found in master.
2021-11-22 09:36:20 +11:00
J87 baf7dbfe0d Fixed linter issue 2021-11-21 20:26:17 +11:00
J87 0bc6cf1178 Minified getEditor function. 2021-11-21 20:25:33 +11:00
Daniel 8638632600 Merge pull request #425 from Danielv123/fix-invokeHook
Add timeout to invokeHook on plugins
2021-11-21 10:03:30 +01:00
jake87 6d5d9ced47 Refactored editor selection to function 2021-11-21 13:39:23 +11:00
jake87 e825f643e2 Refactored string processing to function
Previously string processing was done the exact same way for both master
and instance; this has now been refactored into a function.
2021-11-21 13:31:12 +11:00
jake87 c4f656996e Fixed linting issues 2021-11-21 12:24:00 +11:00
J87 103c0e3820 Fix missing brace from conflict merge 2021-11-21 12:15:46 +11:00
J87 6da349c888 Fix commented out catch bracket 2021-11-21 12:14:49 +11:00
J87 15c3bb81a6 Fix "emitter" spelling & rename to be more descriptive 2021-11-21 12:12:54 +11:00
J87 4a628df108 Remove unneeded backslash 2021-11-21 12:11:18 +11:00
J87 25ae9b9a0d Add example to "no editor" error 2021-11-21 12:10:24 +11:00
J87 6b10f2ab7d Resolve function style 2021-11-21 12:07:30 +11:00
danielv 770e6a41d4 Add timeout to invokeHook on plugins 2021-11-20 12:44:46 +01:00
J87 375f591fe5 Remove outdated comments 2021-11-20 22:28:21 +11:00
Daniel fcdbf59f7a Merge pull request #433 from Danielv123/fix-node12-support
Support node12 by removing optional chaining
2021-11-20 12:17:34 +01:00
Danielv123 6c80227103 Support node12 by removing optional chaining 2021-11-20 12:03:46 +01:00
Daniel 0016c6271a Merge pull request #427 from Danielv123/gh-actions
Gh actions
2021-11-20 11:50:24 +01:00
Daniel c557b7d6b2 Merge pull request #428 from Hornwitser/fix-localhost-resolve
Fix error when localhost resolves to ::1
2021-11-20 11:49:56 +01:00
J87 64c328c53d Merge branch 'master' into user-editable-config 2021-11-20 16:37:49 +11:00
J87 e5994287fb Remove outdated comments 2021-11-20 16:33:47 +11:00
jake87 7126014619 Added editing for instances
Instances require a number of modifications leading to it not being
able to be converted into a function. The main one is that there is a
list of config elements that cannot be set via the edit command and
either have to be set via different means or cannot be set. To stop
these fields constantly sending error messages, we disregard adding them
to the file in the first place.
2021-11-20 16:23:21 +11:00
Arvids Godjuks 3b8e89ea00 Merge pull request #424 from bbassie/slave-instance-ui-updates
Slave instance UI updates
2021-11-19 21:48:52 +02:00
Danielv123 e72ee3ecaf Added linter to CI action 2021-11-19 17:58:54 +01:00
jake87 31cfc45e58 Fix null processing ( again ) 2021-11-17 21:16:22 +11:00
jake87 834fc8cf98 Fixed null value parsing 2021-11-17 18:16:24 +11:00
jake87 eb3b7df390 Fix to be more consistant with the rest of clustorio
Also made sure "null" is entered as empty as to be consistant with the
rest of clustorio.
2021-11-17 17:45:54 +11:00
jake87 76882ad1c4 Fix styling to be more consistant with the rest of clustorio 2021-11-17 17:42:39 +11:00
bbassie 15381ac260 Final running status color update 2021-11-16 23:50:13 +01:00
bbassie b7e6acb24e Added instance deleted alert and updated not found 2021-11-16 23:44:07 +01:00
bbassie b86305db8c Updated colors of the instance tag 2021-11-16 23:42:02 +01:00
bbassie fe803a99ca Instance status tag color changes 2021-11-16 21:50:21 +01:00
jake87 4612826664 spelling mistake 2021-11-16 21:43:59 +11:00
Hornwitser a5a99e2ab1 Fix error when localhost resolves to ::1
Some systems may resolve localhost to ::1 while Factorio doesn't respond
to RCON connection over IPv6 loopback, this causes instances startup to
fail.  Fix by using 127.0.0.1 instead of localhost.
2021-11-15 21:29:51 +01:00
Danielv123 56584a25c8 Remove old pipeline 2021-11-15 10:26:32 +01:00
Danielv123 03ba7cb68c Added new testing pipeline 2021-11-15 10:20:18 +01:00
jake87 cc605250db removed final debugging helpers 2021-11-14 20:55:16 +11:00
jake87 ae34a86cbf Removed debugging prints, and added removing of tmpFile 2021-11-14 20:54:27 +11:00
jake87 86de5ae151 Fixed config editing, fixed child spawning, fixed config setting
Using an event emmiter, the fields can now be set correctly in the
master config. Null values are now handled correctly, and numbers are also set
correctly.
2021-11-14 20:51:06 +11:00
jake87 bcc25047f2 Readded config file editing, working on config file value setting 2021-11-13 22:35:15 +11:00
bbassie f5ce0a8a8c Missing import 2021-11-07 22:00:27 +01:00
bbassie 55bbc10986 Added slave status indicator tag to slave details page
Co-authored-by: Danielv123 <danielv@live.no>
2021-11-07 21:54:10 +01:00
bbassie 4fef2d8143 Fix message null issues 2021-11-07 21:35:48 +01:00
bbassie 4432a689bb Fix instance list 2021-11-07 21:31:56 +01:00
jake87 6013fcd528 Started rework of file loading system 2021-11-06 17:04:34 +11:00
jake87 71fb8541d5 Added more comments, added quotes around strings 2021-11-06 14:32:13 +11:00
jake87 4398d33fea Fixed definitions to be consistant with bullet points. 2021-11-05 23:32:18 +11:00
jake87 92814aa9b3 Reworked to use config list's grabber system
Also now using the system described by Hornwitser, in using
libConfig.MasterConfig.groups to get definitions. Now all that's needed
is to re-read the updated data back into clustorio.
2021-11-05 23:24:13 +11:00
bbassie 69d4a883ad Add copy public address button 2021-11-04 22:25:53 +01:00
bbassie 2e5617c4b5 Fix instance port and status update
Master was not recieving instance status after restart. Instead only a status from the slave but the slave doesn't store the full instance status.
2021-11-04 22:25:36 +01:00
bbassie f930ed36a0 Changed to using the real factorio port 2021-11-04 21:29:53 +01:00
bbassie 83b720eade Added public_address field to lists
- InstanceList
- SlavesPage
Added public_address field to messages
- slaveProperties
- instanceProperties
2021-11-04 01:39:28 +01:00
bbassie c3a1b420ea Added InstanceStatusTag react component
- Modified InstanceList & InstanceViewPage to use it
2021-11-04 01:36:56 +01:00
Hornwitser c889d8f325 Ignore SessionLost from updateInstances (#422)
Don't shutdown the slave if the connection session is lost during
updateInstances.  Resolves #418.
2021-11-03 16:27:21 +01:00
Hornwitser e23d07e965 Use safe file writing pattern (#421)
Write new file contents to a temporary file and then rename over the
target file to avoid data loss in the unlikely event there's an error
writing the new file contents or the process is interrupted.

Resolves #416.
2021-11-03 16:14:18 +01:00
jake87 08a8182173 Reworked to use throw error, added system for getting config groups
Now using `throw error` to exit out, instead of exit(1). Reworked config
get system to use the map of configs in libConfig.MasterConfig.groups .
2021-11-01 20:40:44 +11:00
Hornwitser 9e7a7a602f Merge pull request #420 from Hornwitser/slave-page
Slave page
2021-10-31 16:53:34 +01:00
Hornwitser 6e5e5f0aeb Show log console for master and slaves in web UI
Rename InstanceConsole to LogConsole and use it to show logs for the
master server and slaves as well.
2021-10-31 10:41:25 +01:00
Hornwitser 7769c1f89e Add SlaveViewPage
Add slave overview page displaying instances assigned and some basic
properties.
2021-10-31 10:41:25 +01:00
Hornwitser a2a5cf6d4c Warn on instances with duplicated ids
It's possible that someone copies an instance folder and don't change
the instance id in it.  Emit a warning in case this happens.
2021-10-31 07:26:56 +01:00
jake87 77e9b929dc Testing why config not being returned correctly 2021-10-30 12:31:20 +11:00
jake87 b6306031dc Reworked some of editor logic to work with async functions
Now async functions can be used after the editor returns, as before they
couldnt be used due to .on being non-async.
2021-10-29 22:21:46 +11:00
jake87 0564eee400 Experementing with the master config
no important work, commit for my own sake
2021-10-29 22:09:01 +11:00
jake87 f548ac2ecc Added more logic into ctl.js
Now prints out a basic approximation of the final look of the config
file; with comments on the function of each part.
2021-10-29 20:42:32 +11:00
jake87 c9fd99589b Added more selection options for the edit
ctl master config edit [editor]
is the new structure; it can take an optional editor, and will prio this
editor over any other editor, such as one set by env vars. Generally,
the prio goes like this: argv supplied editor > EDITOR env var > VISUAL
env var. Things needing to be done are marked with a TODO.
2021-10-27 20:16:37 +11:00
jake87 23d8a819af Added basic framework for edit command
Implimented framework for edit command, including ability to get a temp
file, open an editor on the file, then resume code execution once the
editor exits.
2021-10-26 21:23:23 +11:00
jake87 14b3331c8e Added basic logic for config edit command
Added groundwork for the ctl master config edit command,
including temporary file grabbing and JSON config fetching.
2021-10-26 20:46:24 +11:00
jake87 f266c441db Fixed async logic within lib/file_ops.js 2021-10-26 20:46:24 +11:00
jake87 ed73e3ad1e Fixed variable naming issue with file_ops.getTempFile 2021-10-26 20:46:05 +11:00
jake87 4f9a8fb45c Rewrote temp function to be safer, using findUnusedName 2021-10-26 17:50:39 +11:00
jake87 6910737374 Added function in lib/file_ops.js to return a temp filename with salt 2021-10-25 21:58:56 +11:00
Hornwitser bdfe8e630e v2.0.0-alpha.10.b hotfix for installer 2021-10-05 20:19:58 +02:00
Hornwitser 66719375af Fix installer not setting address and ctl config (#417)
Somehow the step of automatically obtaining the right address to apply
to slave.public_address, and asking for the master url and token for ctl
installations was implemented but the code to actually set it in the
relevant config was forgotten.
2021-10-05 20:05:44 +02:00
Hornwitser ef3d5209eb v2.0.0-alpha.10 2021-10-01 17:59:16 +02:00
Hornwitser e4eac3f9ad Merge pull request #415 from Hornwitser/small-things-6
Small things 6
2021-10-01 17:55:07 +02:00
Hornwitser 107284e796 Fix &gt; and &lt; being present in docs
< and > must apparently not be escaped inside backticks.
2021-10-01 01:52:18 +02:00
Hornwitser bad86a5ff2 Fix inventory_sync uploading editor mode
The serialization and deserialization of editor mode is not supported by
the current code, and while the download correctly checked for the
player being in editor mode, the upload did not check for the player
being in editor mode causing players in editor mode to break. Fix by
adding check in upload_inventory.
2021-10-01 01:52:18 +02:00
Hornwitser 034ca4c250 Fix research_sync sending commands before instance start
Fix commands syncing researces being sent before the instance is started
up, and potential desync of research progress as a result of out of
order execution of RCON commands sent.
2021-10-01 01:52:18 +02:00
Hornwitser 2b101d594a Fix statistics_exporter sending commands before start
Fix statistics_exporter potentially sending commands before an instance
is started when metrics are collected.
2021-10-01 01:52:18 +02:00
Hornwitser c40362fa7f Add sendOrderedRcon for plugins
Add BaseInstancePlugin method for sending RCON commands in order to
simplify logic in plugins that depend on commands being delivered in
order.
2021-10-01 01:52:18 +02:00
Hornwitser e8b515c733 Update missing docs from connector refactor
Fix documentation of max_reconnect_delay, heartbeat_interval and
session_timeout which where changed with the connector refactor.
2021-09-28 23:04:30 +02:00
Hornwitser e5582b5558 Skip gatherng metrics from dropped slaves
Avoid stacking up huge amounts of metrics requests while a slave
connection is in the dropped state.
2021-09-28 21:36:28 +02:00
Hornwitser 3b6ebeb653 Handle errors gathering metrics from slaves
If the session to a slave is lost while gathering metrics the error
would propagate out to the metrics route handler and break collection of
statistics.  Fix by handling errors thrown from sending the getMetrics
request to slaves.
2021-09-28 21:32:29 +02:00
Hornwitser bfec83996a Expect close in WsServer connection disconnect
Don't bother doing the prepareDisconnect dance if the connection is
already broken. This avoids having to wait until the connection times
out when a slave gives up on a current connection and starts a new one.
2021-09-28 21:28:38 +02:00
Hornwitser 4a72c14b74 Terminate connection on timeout
If a connection has timed out the most likely cause is the connection
going stale, in which case calling close could take up to 30 seconds
before the ws lib gives up on waiting for the close message.  Terminate
the connection instead on the Node.js side.  Note that Firefox does not
permit applications from closing connections with a code other than
1000, or in the range 3000-4999.
2021-09-28 21:24:42 +02:00
Hornwitser f529457f96 Replace ini packages with homebrew module
The INI format Factorio use differs from what the ini package parse and
produce.  Most notably no escape mechanism exists in Factorio's parser
which causes instance paths containing among other thing the # character
to completely break.  Fix by replacing the ini package with a Factorio
compatible implementation of INI parsing and serialization.

Resolves the problem in #414.
2021-09-27 03:45:52 +02:00
Hornwitser 642504874b Fix error on slave reconnect of stale connection
Remove call to removed function setTimeout on connector that was left
over from the connector refactor.
2021-09-27 03:45:52 +02:00
Hornwitser d99e160a27 Remove unused node-factorio-api dependency 2021-09-27 03:45:52 +02:00
Hornwitser 714e590496 Add sorting to web interface tables
Add sort options to the slave, instance, user, role and plugins tables.
2021-09-27 03:45:52 +02:00
Hornwitser bfcb57002d Merge pull request #413 from Hornwitser/hide-unauthorized-controls
Hide unauthorized controls
2021-09-22 22:14:15 +02:00
Hornwitser 8b59c89322 Link account menu name to user account
Take the user to the user view page for their own account when clicking
on the name in the top right account menu.
2021-09-20 00:57:38 +02:00
Hornwitser 31e9f59d8d Move save list subscription permission
Move the save list subscription permission to be after the save list
permission as these two permissions are logically related to each other
and both need to be granted to view the save list in the web interface.
2021-09-20 00:56:47 +02:00
Hornwitser 0c74aef68e Hide unauthorized web interface controls
Hide or disable controls in the web interface that the user for the
control connection it's connected as don't have the requisite
permissions to use.
2021-09-20 00:54:43 +02:00
Hornwitser 18e9fc8e7c Provide roles and permission for the control user
Add the roles and their permissions for the user the control connection
is connected as along with updates when these change.  This enables the
web interface to know which permissions it has available to it.
2021-09-20 00:52:59 +02:00
Hornwitser 1035389c9e Hotfix new player sync logic
Fix has_data not being checked by sync_player, dialog being stuck open if
no data is received by the master and master server refusing to server
empty data.
2021-09-12 00:27:46 +02:00
81 changed files with 1811 additions and 866 deletions

No files matched your search

-159
View File
@@ -1,159 +0,0 @@
name: Docker
on:
push:
# Publish `master` as Docker `latest` image.
branches:
- '**'
# Publish `v1.2.3` tags as releases.
tags:
- v*
# Run tests for any PRs.
pull_request:
release:
types: [released, prereleased]
env:
# TODO: Change variable to your image's name.
IMAGE_NAME: clusterio
jobs:
# Build dependencies and clusterio image
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_final
- name: Export docker image
run: docker save -o clusterio_docker_image $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image
path: clusterio_docker_image
# Build dependencies and clusterio image with test tooling
build_testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_testing
- name: Export docker image
run: docker save -o clusterio_docker_image_testing $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image_testing
path: clusterio_docker_image_testing
# Upload clusterio_lib and subspace_storage versions
upload_mods:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download docker image
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Create container
run: docker create --name clusterio_container $IMAGE_NAME
- name: Move mods out of container
run: docker cp clusterio_container:/clusterio/sharedMods ./mods
- name: Remove container
run: docker rm clusterio_container
- name: List extracted mods
run: ls mods
- name: Upload mods
uses: actions/upload-artifact@master
with:
name: mods
path: mods
# Run tests.
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
test:
needs: build_testing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Download docker image
uses: actions/download-artifact@master
with:
name: clusterio_docker_image_testing
path: .
- name: ls
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/clusterio && ls /home/runner/work/clusterio/clusterio
- name: Load docker image
run: docker load -i clusterio_docker_image_testing
- name: Run tests
run: |
if [ -f docker-compose.test.yml ]; then
docker-compose --file docker-compose.test.yml run unit_test
else
docker build . --file Dockerfile
fi
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
# Ensure test job passes before pushing image.
needs: [test, build]
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v2
- name: Download artifact
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Log into registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: |
IMAGE_ID=ghcr.io/${{ github.actor }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "master" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION
+39
View File
@@ -0,0 +1,39 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: npm i --no-optional
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- run: npx lerna bootstrap --hoist
- run: npx lerna run build
- run: npm test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: npm i --no-optional
- run: npx lerna bootstrap --hoist
- run: npm run lint
+16 -14
View File
@@ -86,19 +86,20 @@ Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Defaults to 45.
Defaults to 15.
### master.connector_shutdown_timeout
### master.session_timeout
Timeout in seconds to use while shutting down the master server for link sessions that currently don't have an active connection and haven't been closed down properly.
This happens when the network between a slave and the master server goes down unexpectedly, or the slave goes down without closing the connection properly either do to crashing or the machine it's hosted on going down.
If the timeout expires the session is terminated and data loss may occur, if the slave went down this is unavoidable.
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
In the case of the master or client crashing the data loss is unavoidable.
Defaults to 30.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `master.heartbeat_interval`.
Defaults to 60.
### master.metrics_timeout
@@ -197,12 +198,12 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
### slave.reconnect_delay
### slave.max_reconnect_delay
Delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all slaves trying to reconnect at the same time.
Maximum delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 5.
Defaults to 60.
## Instance Configuration
@@ -399,8 +400,9 @@ If you have a self signed certificate on the master server you will need to copy
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.reconnect_delay
### control.max_reconnect_delay
Duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 2.
Defaults to 60.
+2 -2
View File
@@ -99,7 +99,7 @@ The following messages are the only valid messages that can be sent during the h
Sent by the master server when the WebSocket connection has been opened.
- `version: string` - The version of the master, e.g. "2.0.0".
- `plugins: Object&lt;string, string&gt;` -
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
#### `register_slave`
@@ -111,7 +111,7 @@ Slave handshake for establishing a new connection session.
- `version: string` - The protocol version of the slave, e.g. "2.0.0".
- `name: string` - Name of the slave.
- `id: integer` - ID of the slave.
- `plugins: Object&lt;string, string&gt;` -
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the slave.
#### `register_control`
+1 -1
View File
@@ -4,5 +4,5 @@
"plugins/*",
"test"
],
"version": "2.0.0-alpha.9"
"version": "2.0.0-alpha.10"
}
+37 -11
View File
@@ -85,6 +85,12 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const asyncExec = util.promisify(child_process.execFile);
@@ -119,6 +125,13 @@ async function execSlave(args) {
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join("packages", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
function validateSlaveToken(token) {
let parts = token.split(".");
if (parts.length !== 3) {
@@ -158,7 +171,7 @@ async function validateInstallDir() {
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
await safeOutputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -206,7 +219,7 @@ async function installClusterio(mode, plugins) {
}
}
try {
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
@@ -216,17 +229,17 @@ async function installClusterio(mode, plugins) {
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
await safeOutputFile(
"run-master.cmd",
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
);
} else {
await fs.writeFile(
await safeOutputFile(
"run-master.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
{ mode: 0o755 },
);
await fs.outputFile(
await safeOutputFile(
"systemd/clusteriomaster.service",
`[Unit]
Description=Clusterio Master
@@ -247,17 +260,17 @@ WantedBy=multi-user.target
if (["standalone", "slave"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
await safeOutputFile(
"run-slave.cmd",
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
);
} else {
await fs.writeFile(
await safeOutputFile(
"run-slave.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
{ mode: 0o755 },
);
await fs.outputFile(
await safeOutputFile(
"systemd/clusterioslave.service",
`[Unit]
Description=Clusterio Slave
@@ -350,7 +363,7 @@ async function inquirerMissingArgs(args) {
}
if (["standalone", "slave"].includes(answers.mode)) {
let myIp;
let myIp = "localhost";
if (args.publicAddress) {
answers.publicAddress = args.publicAddress;
} else {
@@ -420,7 +433,11 @@ async function inquirerMissingArgs(args) {
}
}
if (dev) { answers.plugins = []; }
if (dev) {
answers.plugins = [];
} else if (args.plugins) {
answers.plugins = args.plugins;
}
answers = await inquirer.prompt([
{
type: "checkbox",
@@ -514,7 +531,9 @@ async function main() {
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
});
})
.strict()
;
if (process.platform === "linux") {
args = args
@@ -566,6 +585,7 @@ async function main() {
let slaveToken = result.stdout.split("\n").slice(-2)[0];
await execSlave(["config", "set", "slave.master_token", slaveToken]);
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
@@ -576,6 +596,7 @@ async function main() {
await execSlave(["config", "set", "slave.name", answers.slaveName]);
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
@@ -584,6 +605,11 @@ async function main() {
await writeScripts(answers.mode);
}
if (answers.mode === "ctl") {
await execCtl(["control-config", "set", "control.master_url", answers.masterUrl]);
await execCtl(["control-config", "set", "control.master_token", answers.masterToken]);
}
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (adminToken) {
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10.c",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+191
View File
@@ -14,6 +14,8 @@ const path = require("path");
const winston = require("winston");
const setBlocking = require("set-blocking");
const phin = require("phin");
const os = require("os");
const child_process = require("child_process");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
@@ -34,6 +36,60 @@ function print(...content) {
console.log(...content);
}
async function getEditor(argsEditor) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || -1
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
async function configToKeyVal(data) {
let final = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
filtered[index] = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = filtered[index][1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[finalIndex] = part;
}
}
return final;
}
async function serializedConfigToString(serializedConfig, configGroup, disallowedList) {
let allConfigElements = "";
for (let group of serializedConfig.groups) {
for (let [name, value] of Object.entries(group.fields)) {
if (`${group.name}.${name}` in disallowedList) {
continue;
}
let desc = "";
try {
desc += configGroup.groups.get(group.name)._definitions.get(name).description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${group.name}.${name} = ${value}\n\n`;
}
}
return allConfigElements;
}
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
const masterConfigCommands = new libCommand.CommandTree({
@@ -104,6 +160,66 @@ masterConfigCommands.add(new libCommand.Command({
await libLink.messages.setMasterConfigProp.send(control, request);
},
}));
masterConfigCommands.add(new libCommand.Command({
definition: ["edit [editor]", "Edit master configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args, control) {
let response = await libLink.messages.getMasterConfig.send(control);
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === -1) {
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl master config edit <editor of choice>"`);
}
let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {});
await fs.writeFile(tmpFile, allConfigElements, (err) => {
if (err) {
throw err;
}
});
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await libLink.messages.setMasterConfigField.send(control, {
field: index,
value: final[index],
});
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
await fs.unlink(tmpFile, (err) => {
if (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
});
},
}));
masterCommands.add(masterConfigCommands);
@@ -300,6 +416,79 @@ instanceConfigCommands.add(new libCommand.Command({
await libLink.messages.setInstanceConfigProp.send(control, request);
},
}));
instanceConfigCommands.add(new libCommand.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === -1) {
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl master config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0};
let allConfigElements = await serializedConfigToString(
response.serialized_config,
libConfig.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements, (err) => {
if (err) {
throw err;
}
});
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: instanceId,
field: index,
value: final[index],
});
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
await fs.unlink(tmpFile, (err) => {
if (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
});
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new libCommand.Command({
@@ -927,6 +1116,8 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"as-table": "^1.0.31",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
+5 -5
View File
@@ -15,7 +15,7 @@ MasterGroup.groupName = "master";
MasterGroup.define({
name: "name",
title: "Name",
description: "Name of the cluster",
description: "Name of the cluster.",
type: "string",
initial_value: "Your Cluster",
});
@@ -83,7 +83,7 @@ MasterGroup.define({
MasterGroup.define({
name: "heartbeat_interval",
title: "Heartbeat Interval",
description: "Interval heartbeats are sent out on WebSocket connections",
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initial_value: 15,
});
@@ -97,21 +97,21 @@ MasterGroup.define({
MasterGroup.define({
name: "metrics_timeout",
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from slaves",
description: "Timeout in seconds for metrics gathering from slaves.",
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
description: "ID of role assigned by default to new users",
description: "ID of role assigned by default to new users.",
type: "number",
optional: true,
initial_value: 1,
+3 -2
View File
@@ -8,6 +8,7 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("./file_ops");
const { basicType } = require("./helpers");
@@ -78,7 +79,7 @@ async function loadJsonAsMap(filePath) {
*/
async function saveMapAsJson(filePath, map) {
let obj = mapToObject(map);
await fs.outputFile(filePath, JSON.stringify(obj, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, 4));
}
/**
@@ -138,7 +139,7 @@ async function loadJsonArrayAsMap(filePath) {
* @throws {Error} if an error occured writing to the file.
*/
async function saveMapAsJsonArray(filePath, map) {
await fs.outputFile(filePath, JSON.stringify([...map.values()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, 4));
}
+3 -3
View File
@@ -2,10 +2,10 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const ini = require("ini");
const Jimp = require("jimp");
const JSZip = require("jszip");
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
/**
@@ -312,7 +312,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
for (let mod of modOrder) {
if (["core", "base", "export"].includes(mod)) {
@@ -330,7 +330,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(ini.parse(content.toString("utf8")));
mergeLocale(libIni.parse(content.toString("utf8")));
}
}
+3 -1
View File
@@ -284,9 +284,11 @@ async function patch(savePath, modules) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
// Write back the save
let tempSavePath = `${savePath}.tmp`;
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let pipe = stream.pipe(fs.createWriteStream(savePath));
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
await events.once(pipe, "finish");
await fs.rename(tempSavePath, savePath);
}
module.exports = {
+8 -7
View File
@@ -6,10 +6,11 @@ const path = require("path");
const events = require("events");
const util = require("util");
const crypto = require("crypto");
const ini = require("ini");
const rconClient = require("rcon-client");
const libErrors = require("../errors");
const libFileOps = require("../file_ops");
const libIni = require("../ini");
const { logger } = require("../logging");
const { escapeRegExp } = require("../helpers");
@@ -689,7 +690,7 @@ class FactorioServer extends events.EventEmitter {
}
let config = {
host: "localhost",
host: "127.0.0.1", // localhost may resolve to ::1 while Factorio listen to only IPv4 by default.
port: this.rconPort,
password: this.rconPassword,
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
@@ -1167,23 +1168,23 @@ class FactorioServer extends events.EventEmitter {
}
async _writeConfigIni() {
let content = ini.encode({
let content = libIni.stringify({
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
},
});
await fs.outputFile(this.writePath("config.ini"), content);
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings, mapSettings) {
if (mapGenSettings) {
await fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
);
}
if (mapSettings) {
await fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
);
}
+42 -1
View File
@@ -4,7 +4,7 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const crypto = require("crypto"); // needed for getTempFile
/**
* Returns the newest file in a directory
*
@@ -68,8 +68,49 @@ async function findUnusedName(directory, name, extension = "") {
}
}
}
/**
* Warning: this function should not be relied upon to be accurate for
* security sensitive applications. The selection process is inherently
* racy and a file or folder may have been created in the folder by the time
* this function returns.
*
* @param {string} prefix - Prefix for file
* @param {string} suffix - Suffix for file
* @param {string} tmpdir - Directory for temp file
*/
async function getTempFile(prefix, suffix, tmpdir) {
prefix = (typeof prefix !== "undefined") ? prefix : "tmp.";
suffix = (typeof suffix !== "undefined") ? suffix : "";
tmpdir = (typeof tmpdir !== "undefined") ? tmpdir : "./";
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
let freeFile = await findUnusedName(tmpdir, fileName);
let fullPath = path.join(tmpdir, freeFile);
return fullPath;
}
/**
* Safely write data to a file
*
* Same as fs-extra.outputFile except the data is written to a temporary
* file that's renamed over the target file. The name of the temporary file
* is the same as the target file with the suffix `.tmp` added.
*
* If the operation fails it may leave behind the temporary file. This
* should not be too much of an issue as the next time the same file is
* written the temporary will be overwritten and renamed to the target file.
*
* @param {string} file - Path to file to write.
* @param {string|Buffer} data - Content to write.
* @param {object|string} options - see fs.writeFile, `flag` must not be set.
*/
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
module.exports = {
getNewestFile,
findUnusedName,
getTempFile,
safeOutputFile,
};
+94
View File
@@ -0,0 +1,94 @@
/**
* Factorio Compatible INI parser
*
* @module lib/ini
* @author Hornwitser
*/
"use strict";
/**
* Parse INI file into object of sections
* @param {string} input - content of INI file to parse.
* @returns {object<string, object<string, string>>}
* sections parsed from the ini file.
*/
function parse(input) {
const sections = {};
let currentSection = sections;
const lines = input.split(/\r\n|\n/);
for (let number = 1; number <= lines.length; number++) {
let line = lines[number - 1].replace(/^[\t ]+/, "");
if (!line || line[0] === "#" || line[0] === ";") {
continue;
}
// Read section header
if (line[0] === "[") {
line = line.replace(/[\t ]+$/, "");
if (line.slice(-1) !== "]") {
throw new Error(`Unterminated section header on line ${number}`);
}
const name = line.slice(1, -1);
if (Object.prototype.hasOwnProperty.call(sections, name)) {
throw new Error(`Duplicated section [${name}] on line ${number}`);
}
currentSection = {};
sections[name] = currentSection;
continue;
}
// Read key value
const equalIndex = line.indexOf("=");
if (equalIndex === -1) {
throw new Error(`Missing value for key ${line} on line ${number}`);
}
const key = line.slice(0, equalIndex);
if (Object.prototype.hasOwnProperty.call(currentSection, key)) {
throw new Error(`Duplicated key ${key} on line ${number}`);
}
const value = line.slice(equalIndex + 1);
currentSection[key] = value;
}
return sections;
}
/**
* Serialize an object of section definitions into an INI file
* @param {object<string, object<string, string>>} sections -
* Sections to encode into ini format
* @returns {string} sections serialized into INI format
*/
function stringify(sections) {
let serialized = [];
let first = true;
for (let [key, value] of Object.entries(sections)) {
if (typeof value === "string") {
serialized.push(`${key}=${value}`);
first = false;
}
}
for (let [section, content] of Object.entries(sections)) {
if (typeof content === "object") {
if (!first) {
serialized.push("");
}
first = false;
serialized.push(`[${section}]`);
for (let [key, value] of Object.entries(content)) {
serialized.push(`${key}=${value}`);
}
}
}
if (serialized.length) { serialized.push(""); }
return serialized.join("\n");
}
module.exports = {
parse,
stringify,
};
+6 -1
View File
@@ -73,7 +73,12 @@ class WebSocketBaseConnector extends events.EventEmitter {
this._check("connected");
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
logger.verbose("Connector | closing after heartbeat timed out");
this._socket.close(1008, "Heartbeat timeout");
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket.close(4008, "Heartbeat timeout");
} else {
this._socket.terminate();
}
} else {
this._socket.send(JSON.stringify({
+28
View File
@@ -337,6 +337,7 @@ let slaveProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
"public_address": { type: ["null", "string"] },
};
messages.listSlaves = new Request({
@@ -372,6 +373,7 @@ let instanceProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"game_port": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
@@ -1085,6 +1087,30 @@ messages.debugWsMessage = new Event({
},
});
messages.accountUpdate = new Event({
type: "account_update",
links: ["master-control"],
eventRequired: [],
eventProperties: {
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
});
messages.logMessage = new Event({
type: "log_message",
links: ["slave-master", "master-control"],
@@ -1121,11 +1147,13 @@ messages.instanceInitialized = new Event({
messages.instanceStatusChanged = new Event({
type: "instance_status_changed",
links: ["instance-slave", "slave-master"],
eventRequired: ["instance_id", "status"],
eventProperties: {
"instance_id": { type: "integer" },
"status": { enum: [
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
"game_port": { type: ["null", "integer"] },
},
});
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"browser": "browser.js",
@@ -15,7 +15,6 @@
"ajv": "^6.12.6",
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"isomorphic-ws": "^4.0.1",
"jimp": "^0.12.1",
"jsonwebtoken": "^8.2.1",
+78 -3
View File
@@ -4,6 +4,7 @@
*/
"use strict";
const helpers = require("./helpers");
/**
* Conceptual base for master and instance plugins.
@@ -60,6 +61,9 @@ class BaseInstancePlugin {
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = instance.logger.child({ plugin: this.info.name });
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
@@ -207,9 +211,72 @@ class BaseInstancePlugin {
*/
async onPlayerEvent(event) { }
async sendRcon(message, expectEmpty) {
/**
* Send RCON message to instance
*
* Send a message or command to the server which can potentially be
* executed out of order in relation to other RCON commands.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendRcon(message, expectEmpty=false) {
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
}
/**
* Send serially ordered RCON message to instance
*
* Send a message or command to the server which will not be executed
* out of order in relation to other RCON commands sent with this
* method. The ordering applies per plugin, and two plugins sending
* commands with this method may execute out of order in relation to
* each other.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendOrderedRcon(message, expectEmpty=false) {
let promise = new Promise((resolve, reject) => {
this._pendingRconMessages.push({resolve, reject, message, expectEmpty});
});
if (!this._sendingRconMessages) {
this._sendPendingRconMessages();
}
return await promise;
}
async _sendPendingRconMessages() {
this._sendingRconMessages = true;
while (this._pendingRconMessages.length) {
let task = this._pendingRconMessages.shift();
try {
let result = await this.sendRcon(task.message, task.expectEmpty);
task.resolve(result);
} catch (err) {
task.reject(err);
}
}
this._sendingRconMessages = false;
}
}
/**
@@ -667,8 +734,16 @@ async function invokeHook(plugins, hook, ...args) {
let results = [];
for (let [name, plugin] of plugins) {
try {
let result = await plugin[hook](...args);
if (result !== undefined) {
// Use an object to detect if the hook failed on timeout or tried to return a value looking like an error
const timeout = {};
let result = await helpers.timeout(
plugin[hook](...args),
15000,
timeout
);
if (result === timeout) {
throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`);
} else if (result !== undefined) {
results.push(result);
}
} catch (err) {
+19 -1
View File
@@ -104,10 +104,27 @@ const serverHandshake = ajv.compile({
"session_timeout": { type: "number" },
"heartbeat_interval": { type: "number" },
"account": {
type: "object",
additionalProperties: false,
required: ["name"],
required: ["name", "roles"],
properties: {
"name": { type: "string" },
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
},
},
@@ -161,6 +178,7 @@ const clientHandshake = ajv.compile({
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"public_address": { type: "string" },
"plugins": {
type: "object",
additionalProperties: { type: "string" },
+4 -3
View File
@@ -7,6 +7,7 @@ const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
const libFileOps = require("./file_ops");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
@@ -70,7 +71,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
}
pluginList.set(pluginInfo.name, pluginPath);
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
@@ -80,7 +81,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
return;
}
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Removed ${args.name}`);
} else if (command === "list") {
@@ -155,7 +156,7 @@ async function handleConfigCommand(args, instance, configPath) {
try {
instance.set(args.field, args.value);
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
logger.error(err.message);
+5 -5
View File
@@ -382,6 +382,11 @@ definePermission({
title: "List saves on instance",
description: "List the saves currently on the instance.",
});
definePermission({
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
name: "core.instance.save.create",
title: "Create new instance save",
@@ -402,11 +407,6 @@ definePermission({
title: "Download save from instance",
description: "Download savegames from instances.",
});
definePermission({
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
name: "core.instance.export_data",
title: "Export locale and icons",
+5 -2
View File
@@ -26,6 +26,7 @@ const jwt = require("jsonwebtoken");
// homebrew modules
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const libConfig = require("@clusterio/lib/config");
@@ -162,7 +163,7 @@ async function handleBootstrapCommand(args, masterConfig) {
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await fs.outputFile(args.output, content);
await libFileOps.safeOutputFile(args.output, content);
}
}
}
@@ -294,7 +295,9 @@ async function initialize() {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
parameters.masterConfig.set("master.auth_secret", bytes.toString("base64"));
await fs.outputFile(parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4));
await libFileOps.safeOutputFile(
parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4)
);
}
if (command === "config") {
+3 -4
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env production",
@@ -19,21 +19,20 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"express": "^4.16.2",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"fast-deep-equal": "^3.1.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+1
View File
@@ -64,6 +64,7 @@ class BaseConnection extends libLink.Link {
}
async disconnect(code, reason) {
this.connector.setClosing();
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
+8
View File
@@ -106,6 +106,7 @@ class ControlConnection extends BaseConnection {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this._master.wsServer.slaveConnections.has(slave.id),
});
}
@@ -169,6 +170,7 @@ class ControlConnection extends BaseConnection {
id,
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
};
}
@@ -180,6 +182,7 @@ class ControlConnection extends BaseConnection {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
@@ -199,6 +202,7 @@ class ControlConnection extends BaseConnection {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
@@ -513,6 +517,7 @@ class ControlConnection extends BaseConnection {
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._master.rolePermissionsUpdated(role);
}
async grantDefaultRolePermissionsRequestHandler(message) {
@@ -522,6 +527,7 @@ class ControlConnection extends BaseConnection {
}
role.grantDefaultPermissions();
this._master.rolePermissionsUpdated(role);
}
async deleteRoleRequestHandler(message) {
@@ -534,6 +540,7 @@ class ControlConnection extends BaseConnection {
this._master.userManager.roles.delete(id);
for (let user of this._master.userManager.users.values()) {
user.roles.delete(role);
this._master.userPermissionsUpdated(user);
}
}
@@ -573,6 +580,7 @@ class ControlConnection extends BaseConnection {
}
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(user);
}
async setUserAdminRequestHandler(message) {
+44 -3
View File
@@ -10,6 +10,8 @@ const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
@@ -240,7 +242,7 @@ class Master {
logger.info("Stopping master");
logger.info("Saving config");
await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
@@ -296,7 +298,7 @@ class Master {
}
static async saveSlaves(filePath, slaves) {
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
}
static async loadInstances(filePath) {
@@ -328,7 +330,7 @@ class Master {
serialized.push(instance.config.serialize());
}
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static addAppRoutes(app, pluginInfos) {
@@ -366,6 +368,7 @@ class Master {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this.wsServer.slaveConnections.has(slave.id),
};
@@ -410,6 +413,44 @@ class Master {
}
}
/**
* Notify connected control clients under the given user that the
* permissions for this user may have changed.
* @param {module:lib/users.User} user - User permisions updated for.
*/
userPermissionsUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user === user) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
/**
* Notify connected control clients with the given role that the
* permissions may have changed.
* @param {module:lib/users.Role} role - Role permisions updated for.
*/
rolePermissionsUpdated(role) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user.roles.has(role)) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
+2
View File
@@ -28,6 +28,7 @@ class SlaveConnection extends BaseConnection {
id: this._id,
name: this._name,
version: this._version,
public_address: registerData.public_address,
plugins: registerData.plugins,
});
@@ -87,6 +88,7 @@ class SlaveConnection extends BaseConnection {
let prev = instance.status;
instance.status = message.data.status;
instance.game_port = message.data.game_port;
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
+2 -1
View File
@@ -1,6 +1,7 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("@clusterio/lib/file_ops");
const libUsers = require("@clusterio/lib/users");
/**
@@ -62,7 +63,7 @@ class UserManager {
users: serializedUsers,
roles: serializedRoles,
};
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
/**
+6 -2
View File
@@ -136,7 +136,7 @@ class WsServer {
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
socket.terminate();
this.pendingSockets.delete(socket);
}, 30*1000);
@@ -266,7 +266,6 @@ ${err.stack}`
let connection = this.slaveConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
connection.connector.setTimeout(15); // Slave connection is likely stalled
await connection.disconnect(1008, "Registered from another connection");
}
@@ -298,6 +297,11 @@ ${err.stack}`
this.controlConnections.push(connection);
additionalReadyData["account"] = {
name: user.name,
roles: [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
};
}
+3 -12
View File
@@ -73,19 +73,10 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
continue(socket, lastSeq) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. If this is the case the connector will have to
// wait for the previous socket to close down before it can continue
// from a new socket, or there will be overlapping events from both.
// stale connection. Terminate it if so.
if (this._state === "connected") {
this._socket.close(4003, "Session Hijacked");
// Correctly waiting for the connector to close and then
// handling all the edge cases like multiple continues happening
// in parallel or connections going stale is far too difficult.
// Kill the connection here and let the client reconnect later
// when the connector is not held up by an active connection.
socket.close(1013, "Session Busy");
socket.terminate();
this._socket.terminate();
this._socket.once("close", () => this.continue(socket, lastSeq));
return;
}
+15 -3
View File
@@ -2,12 +2,13 @@
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
@@ -47,7 +48,18 @@ async function getMetrics(req, res, next) {
let requests = [];
let timeout = req.app.locals.master.config.get("master.metrics_timeout") * 1000;
for (let slaveConnection of req.app.locals.master.wsServer.slaveConnections.values()) {
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
if (!slaveConnection.connected) {
continue;
}
requests.push(libHelpers.timeout(
libLink.messages.getMetrics.send(slaveConnection).catch(err => {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`Unexpected error gathering metrics from slave:\n${err.stack}`);
}
return null;
}),
timeout, null
));
}
for await (let result of await libPrometheus.defaultRegistry.collect()) {
@@ -175,7 +187,7 @@ async function uploadExport(req, res) {
}
let name = path.posix.basename(filePath);
await fs.outputFile(path.join("static", "export", name), await file.async("nodebuffer"));
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
}
res.sendStatus(200);
+2
View File
@@ -25,6 +25,8 @@ module.exports = (env = {}) => merge(common(env), {
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
"react-router": { singleton: true },
"react-router-dom": { singleton: true },
},
}),
],
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"pidusage": "^2.0.18",
+18 -6
View File
@@ -260,7 +260,9 @@ class Instance extends libLink.Link {
notifyStatus(status) {
this._status = status;
libLink.messages.instanceStatusChanged.send(this, {
instance_id: this.id, status,
instance_id: this.id,
status,
game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null,
});
}
@@ -376,7 +378,7 @@ class Instance extends libLink.Link {
*/
async writeServerSettings() {
let serverSettings = await this.resolveServerSettings(this.config.get("factorio.settings"));
await fs.writeFile(
await libFileOps.safeOutputFile(
this.server.writePath("server-settings.json"),
JSON.stringify(serverSettings, null, 4)
);
@@ -410,7 +412,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_adminlist")) {
this.logger.verbose("Writing server-adminlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._slave.adminlist], null, 4)
);
@@ -418,7 +420,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_banlist")) {
this.logger.verbose("Writing server-banlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._slave.banlist].map(
([username, reason]) => ({ username, reason })
@@ -428,7 +430,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_whitelist")) {
this.logger.verbose("Writing server-whitelist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._slave.whitelist], null, 4)
);
@@ -886,6 +888,11 @@ async function discoverInstances(instancesDir) {
continue;
}
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
let instancePath = path.join(instancesDir, entry.name);
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instanceInfos.set(instanceConfig.get("instance.id"), {
@@ -999,6 +1006,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
version,
id: this.slaveConfig.get("slave.id"),
name: this.slaveConfig.get("slave.name"),
public_address: this.slaveConfig.get("slave.public_address"),
plugins,
});
}
@@ -1092,6 +1100,10 @@ class Slave extends libLink.Link {
}
this.updateInstances().catch((err) => {
if (err instanceof libErrors.SessionLost) {
return undefined;
}
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
});
@@ -1294,7 +1306,7 @@ class Slave extends libLink.Link {
_warning: "Changes to this file will be overwritten by the master server's copy.",
...instanceInfo.config.serialize(),
};
await fs.outputFile(
await libFileOps.safeOutputFile(
path.join(instanceInfo.path, "instance.json"),
JSON.stringify(warnedOutput, null, 4)
);
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.13.8",
"@babel/preset-react": "^7.12.13",
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"assert": "^2.0.0",
"babel-loader": "^8.2.2",
@@ -3,6 +3,30 @@ import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Ty
const { Title } = Typography;
function getInitialValues(config, props) {
let initialValues = {};
for (let [name, GroupClass] of props.ConfigClass.groups) {
let group = config.group(name);
for (let def of GroupClass.definitions.values()) {
if (!group.canAccess(def.name)) {
continue;
}
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
if (def.type === "object") {
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
initialValues[propPath] = JSON.stringify(value[prop]);
}
} else {
initialValues[fieldName] = value;
}
}
}
return initialValues;
}
export default function BaseConfigTree(props) {
let [config, setConfig] = useState(null);
@@ -16,8 +40,13 @@ export default function BaseConfigTree(props) {
let newConfig = new props.ConfigClass("control");
await newConfig.load(serializedConfig);
setConfig(newConfig);
return newConfig;
}
useEffect(() => { updateConfig(); }, [props.id]);
useEffect(() => {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}, [props.id]);
function renderInput(def) {
if (def.type === "boolean") {
@@ -0,0 +1,91 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { message, Button, Table } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import { useSlaveList } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function InstanceList(props) {
let history = useHistory();
let [slaveList] = useSlaveList();
function slaveName(slaveId) {
let slave = slaveList.find(s => s.id === slaveId);
if (slave) {
return slave.name;
}
return String(slaveId);
}
function instancePublicAddress(instance) {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave && slave.public_address) {
if (instance.game_port) {
return `${slave.public_address}:${instance.game_port}`;
}
return slave.public_address;
}
return null;
}
let columns = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
},
{
title: "Public address",
key: "public_address",
render: instance => {
let public_address = instancePublicAddress(instance);
return public_address ? <>
{public_address}
<Button
type="text"
icon={<CopyOutlined/>}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(public_address);
message.success("Copied public address!");
}}
/>
</> : "";
},
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
},
{
title: "Status",
dataIndex: "status",
render: status => <InstanceStatusTag status={status} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
];
if (props.hideAssignedSlave) {
columns.splice(1, 1);
}
return <Table
size={props.size || "large"}
columns={columns}
dataSource={props.instances}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>;
}
@@ -0,0 +1,21 @@
import { Tag } from "antd";
import React from "react";
export const statusColors = {
unknown: "#8c8c8c",
unassigned: "#eb2f96",
stopped: "#cf1322",
starting: "#ad8b00",
running: "#389e0d",
stopping: "#d48806",
creating_save: "#096dd9",
exporting_data: "#08979c",
deleted: "#262626",
};
export default function InstanceStatusTag(props) {
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
}
@@ -1,14 +1,15 @@
import React, { useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import InstanceConfigTree from "./InstanceConfigTree";
import InstanceConsole from "./InstanceConsole";
import LogConsole from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
@@ -17,6 +18,7 @@ import SavesList from "./SavesList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const { Title, Paragraph } = Typography;
@@ -27,6 +29,7 @@ export default function InstanceViewPage(props) {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [instance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
@@ -38,17 +41,32 @@ export default function InstanceViewPage(props) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing) {
if (instance.missing || instance["status"] === "deleted") {
return <PageLayout nav={nav}>
<h2>Instance not found</h2>
<p>Instance with id {instanceId} was not found on the master server.</p>
<Alert
message={instance["status"] === "deleted" ? "Instance has been deleted" : "Instance not found" }
showIcon
description={<>Instance with id {instanceId} was not found on the master server.</>}
type="warning"
action={
<Button
type="text"
onClick={() => { history.push("/instances"); }}
>
Go back to instances list
</Button>
}
/>
</PageLayout>;
}
let instanceButtons = <Space>
<StartStopInstanceButton instance={instance} />
<LoadScenarioModal instance={instance} />
<Button
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasPermission("core.instance.export_data") && <Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
@@ -63,8 +81,8 @@ export default function InstanceViewPage(props) {
}}
>
Export data
</Button>
<Popconfirm
</Button>}
{account.hasPermission("core.instance.delete") && <Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
@@ -83,7 +101,7 @@ export default function InstanceViewPage(props) {
>
<DeleteOutlined />
</Button>
</Popconfirm>
</Popconfirm>}
</Space>;
let assigned = instance["assigned_slave"] !== null;
@@ -99,7 +117,7 @@ export default function InstanceViewPage(props) {
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
<AssignInstanceModal
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
@@ -109,16 +127,25 @@ export default function InstanceViewPage(props) {
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>
/>}
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
&& <SavesList instance={instance} />
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
}
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
}
<InstanceConfigTree id={instanceId} />
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
</PageLayout>;
}
@@ -1,15 +1,15 @@
import React, { useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { Button, Form, Input, Modal, PageHeader } from "antd";
import { libConfig, libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useInstanceList } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
function CreateInstanceButton(props) {
let control = useContext(ControlContext);
@@ -50,7 +50,7 @@ function CreateInstanceButton(props) {
>
<Form form={form}>
<Form.Item name="instanceName" label="Name">
<Input/>
<Input />
</Form.Item>
</Form>
</Modal>
@@ -58,47 +58,41 @@ function CreateInstanceButton(props) {
}
export default function InstancesPage() {
let history = useHistory();
let [slaveList] = useSlaveList();
let control = useContext(ControlContext);
let account = useAccount();
let [instanceList] = useInstanceList();
return <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
className="site-page-header"
title="Instances"
extra=<CreateInstanceButton />
extra={<>
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instanceList.forEach(instance => {
if (instance.status === "stopped") {
libLink.messages.startInstance.send(
control, { instance_id: instance.id, save: null }
).catch(notifyErrorHandler("Error starting instance"));
}
})
}>
Start all
</Button>}
{account.hasPermission("core.instance.stop")
&& <Button onClick={e => instanceList.forEach(instance => {
if (["starting", "running"].includes(instance.status)) {
libLink.messages.stopInstance.send(
control, { instance_id: instance.id }
).catch(notifyErrorHandler("Error stopping instance"));
}
})
}>
Stop all
</Button>}
</>}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave) {
return slave.name;
}
return instance.assigned_slave;
},
},
{
title: "Status",
dataIndex: "status",
},
]}
dataSource={instanceList}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>
<InstanceList instances={instanceList} />
</PageLayout>;
}
@@ -3,6 +3,7 @@ import { Typography } from "antd";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
@@ -48,30 +49,40 @@ function formatLog(info, key) {
return <span key={key}>[{level}] {info.message}<br/></span>;
}
export default function InstanceConsole(props) {
export default function LogConsole(props) {
let account = useAccount();
let control = useContext(ControlContext);
let anchor = useRef();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState([]);
useEffect(() => {
let logFilter = {
all: props.all || false,
master: props.master || false,
slave_ids: props.slaves || [],
instance_ids: props.instances || [],
};
// 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,
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
if (account.hasPermission("core.log.query")) {
setPastLines([<span key={0}>{"Loading past entries..."}<br/></span>]);
libLink.messages.queryLog.send(control, {
...logFilter,
max_level: null,
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
} else {
setPastLines([]);
}
function logHandler(info) {
setLines(currentLines => currentLines.concat(
@@ -79,11 +90,11 @@ export default function InstanceConsole(props) {
));
}
control.onInstanceLog(props.id, logHandler);
control.onLog(logFilter, logHandler);
return () => {
control.offInstanceLog(props.id, logHandler);
control.offLog(logFilter, logHandler);
};
}, [props.id]);
}, [props.all, props.master, props.slaves, props.instances]);
return <>
<Paragraph code className="instance-console">
+12 -1
View File
@@ -1,12 +1,23 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import MasterConfigTree from "./MasterConfigTree";
import PageLayout from "./PageLayout";
const { Title } = Typography;
export default function SlavesPage() {
let account = useAccount();
return <PageLayout nav={[{ name: "Master" }]}>
<h2>Master</h2>
<MasterConfigTree />
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole master={true} />
</>}
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
</PageLayout>;
};
@@ -6,6 +6,8 @@ import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function PluginsPage() {
let plugins = useContext(ControlContext).plugins;
@@ -47,6 +49,8 @@ export default function PluginsPage() {
title: "Name",
key: "name",
render: plugin => (plugin.info ? plugin.info.title : plugin.meta.name),
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
},
{
title: "Version",
@@ -60,11 +64,13 @@ export default function PluginsPage() {
}
return plugin.package.version;
},
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
},
{
title: "Loaded",
dataIndex: ["meta", "loaded"],
render: loaded => (loaded ? "Yes" : null),
sorter: (a, b) => a.loaded - b.loaded,
responsive: ["sm"],
},
]}
+38 -34
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
import { Button, Checkbox, Form, Input, PageHeader, Popconfirm, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink, libUsers } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
@@ -42,6 +43,7 @@ export default function RoleViewPage() {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [role, updateRole] = useRole(roleId);
let [edited, setEdited] = useState(false);
@@ -50,14 +52,20 @@ export default function RoleViewPage() {
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
if (role.loading) {
return <PageLayout nav={nav}>
<h2>{roleId}</h2>
<PageHeader
className="site-page-header"
title={roleId}
/>
<Spin size="large" />
</PageLayout>;
}
if (role.missing) {
return <PageLayout nav={nav}>
<h2>Role not found</h2>
<PageHeader
className="site-page-header"
title="Role not found"
/>
<p>Role with id {roleId} was not found on the master server.</p>
</PageLayout>;
}
@@ -72,6 +80,7 @@ export default function RoleViewPage() {
},
};
let canUpdate = account.hasPermission("core.role.update");
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
@@ -93,40 +102,35 @@ export default function RoleViewPage() {
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
<Row>
<Col flex="auto">
<h2>{role["name"]}</h2>
</Col>
<Col flex="0 0 auto">
<Space>
<Button type={edited ? "primary" : "default"} htmlType="submit">
Apply
<PageHeader
className="site-page-header"
title={role["name"]}
extra={<>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteRole.send(
control, { id: roleId }
).then(() => {
history.push("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
<Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteRole.send(
control, { id: roleId }
).then(() => {
history.push("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
</Popconfirm>}
</>}
/>
<Form.Item name="name" label="Name">
<Input/>
<Input disabled={!canUpdate}/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
<Input disabled={!canUpdate}/>
</Form.Item>
<h3>Permissions</h3>
{[...libUsers.permissions.values()].map(({name, title, description}) => (
@@ -141,7 +145,7 @@ export default function RoleViewPage() {
labelAlign="left"
colon={false}
>
<Checkbox/>
<Checkbox disabled={!canUpdate} />
</Form.Item>
))}
</Form>
+86 -33
View File
@@ -1,50 +1,103 @@
import React, { useContext } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { libLink } from "@clusterio/lib";
import DataTable from "./data-table";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function UsersPage() {
function CreateRoleButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
async function createRole() {
let values = form.getFieldsValue();
if (!values.roleName) {
form.setFields([{ name: "roleName", errors: ["Name is required"] }]);
return;
}
let result = await libLink.messages.createRole.send(control, {
name: values.roleName,
description: values.description || "",
permissions: [],
});
setVisible(false);
history.push(`/roles/${result.id}/view`);
}
return <>
<Button
type="primary"
onClick={() => { setVisible(true); }}
>Create</Button>
<Modal
title="Create Role"
okText="Create"
visible={visible}
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
onCancel={() => { setVisible(false); }}
destroyOnClose
>
<Form form={form}>
<Form.Item name="roleName" label="Name">
<Input/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function RolesPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
async function listRoles() {
let result = await libLink.messages.listRoles.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Description": item["description"],
}));
}
let [roles, setRoles] = useState([]);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(result["list"]);
}).catch(notifyErrorHandler("Error fetching role list"));
}, []);
return <PageLayout nav={[{ name: "Roles" }]}>
<h2>Roles</h2>
<DataTable
DataFunction={listRoles}
AddRecord={{
fields: [
{ dataIndex: "name", title: "Name" },
{ dataIndex: "description", title: "Description" },
],
insert: async args => {
await libLink.messages.createRole.send(control, {
name: args.name,
description: args.description || "",
permissions: [],
});
<PageHeader
className="site-page-header"
title="Roles"
extra={account.hasPermission("core.role.create") && <CreateRoleButton />}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
history.push(`/roles/${record.key}/view`);
},
}),
}}
{
title: "Description",
dataIndex: "description",
},
]}
dataSource={roles}
pagination={false}
rowKey={role => role["id"]}
onRow={(role, rowIndex) => ({
onClick: event => {
history.push(`/roles/${role["id"]}/view`);
},
})}
/>
</PageLayout>;
}
+20 -14
View File
@@ -5,6 +5,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
@@ -24,6 +25,7 @@ function formatBytes(bytes) {
}
export default function SavesList(props) {
let account = useAccount();
let control = useContext(ControlContext);
let saves = useSaves(props.instance.id);
let [starting, setStarting] = useState(false);
@@ -65,7 +67,7 @@ export default function SavesList(props) {
columnWidth: 33,
expandRowByClick: true,
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
<Button
{account.hasPermission("core.instance.start") && <Button
loading={starting}
disabled={props.instance.status !== "stopped"}
onClick={() => {
@@ -78,8 +80,8 @@ export default function SavesList(props) {
() => { setStarting(false); }
);
}}
>Load save</Button>
<Button
>Load save</Button>}
{account.hasPermission("core.instance.save.download") && <Button
disabled={slaveOffline}
onClick={() => {
libLink.messages.downloadSave.send(
@@ -92,8 +94,8 @@ export default function SavesList(props) {
notifyErrorHandler("Error downloading save")
);
}}
>Download</Button>
<Popconfirm
>Download</Button>}
{account.hasPermission("core.instance.save.delete") && <Popconfirm
title="Permanently delete save?"
okText="Delete"
placement="top"
@@ -105,7 +107,7 @@ export default function SavesList(props) {
}}
>
<Button danger disabled={slaveOffline}>Delete</Button>
</Popconfirm>
</Popconfirm>}
</Space>,
}}
/>;
@@ -158,15 +160,19 @@ export default function SavesList(props) {
};
return <div>
<SectionHeader title="Saves" extra=<Space>
<Upload {...uploadProps} >
<SectionHeader title="Saves" extra={<Space>
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
<Button disabled={slaveOffline}>Upload save</Button>
</Upload>
<CreateSaveModal instance={props.instance} />
</Space> />
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
</Upload>}
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
</Space>} />
{
account.hasPermission("core.instance.save.upload")
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
: saveTable
}
{uploadingFiles.length ? <List>
{uploadingFiles.map(file => <List.Item key={file.uid}>
{file.name}
@@ -27,9 +27,9 @@ export default function SiteLayout(props) {
}
let accountMenu = <Menu>
<Menu.ItemGroup title={account.name}/>
<Menu.Item key="1" onClick={() => { history.push(`/users/${account.name}/view`); }}>{account.name}</Menu.Item>
<Menu.Divider/>
<Menu.Item danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
<Menu.Item key="2" danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
</Menu>;
let combinedPages = [...pages];
@@ -69,9 +69,12 @@ export default function SiteLayout(props) {
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{combinedPages.map(({sidebarName, path}) => (
sidebarName ? <Menu.Item key={path}>{sidebarName}</Menu.Item> : null)
)}
{combinedPages.map(({sidebarName, permission, path}) => {
if (!sidebarName || permission && !account.hasPermission(permission)) {
return null;
}
return <Menu.Item key={path}>{sidebarName}</Menu.Item>;
})}
</Menu>
</Sider>
<Layout className="site-layout-content-container">
@@ -0,0 +1,64 @@
import React, { useContext, useState } from "react";
import { Descriptions, PageHeader, Spin, Tag, Typography } from "antd";
import { useParams } from "react-router-dom";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import { useInstanceList } from "../model/instance";
import { useSlave } from "../model/slave";
import PageLayout from "./PageLayout";
const { Title } = Typography;
export default function SlaveViewPage(props) {
let params = useParams();
let slaveId = Number(params.id);
let account = useAccount();
let [instanceList] = useInstanceList();
let [slave] = useSlave(slaveId);
instanceList = instanceList.filter(instance => instance["assigned_slave"] === slaveId);
let nav = [{ name: "Slaves", path: "/slaves" }, { name: slave.name || slaveId }];
if (slave.loading) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (slave.missing) {
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={slaveId}
/>
<p>Slave with id {slaveId} was not found on the master server.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={slave.name || slaveId}
/>
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
<Descriptions.Item label="Name">{slave["name"]}</Descriptions.Item>
<Descriptions.Item label="Connected">
<Tag color={slave["connected"] ? "#389e0d" : "#cf1322"}>
{slave["connected"] ? "Connected" : "Disconnected"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Agent">{slave["agent"]}</Descriptions.Item>
<Descriptions.Item label="Version">{slave["version"]}</Descriptions.Item>
</Descriptions>
{account.hasPermission("core.instance.list") && <>
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
<InstanceList instances={instanceList} size="small" hideAssignedSlave />
</>}
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole slaves={[slaveId]} />
</>}
</PageLayout>;
}
+29 -4
View File
@@ -1,13 +1,17 @@
import React, { useContext, useRef, useState } from "react";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useSlaveList } from "../model/slave";
import { notifyErrorHandler } from "../util/notify";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function GenerateSlaveTokenButton(props) {
let control = useContext(ControlContext);
@@ -71,38 +75,59 @@ function GenerateSlaveTokenButton(props) {
export default function SlavesPage() {
let control = useContext(ControlContext);
let account = useAccount();
let history = useHistory();
let [slaveList] = useSlaveList();
return <PageLayout nav={[{ name: "Slaves" }]}>
<PageHeader
className="site-page-header"
title="Slaves"
extra=<GenerateSlaveTokenButton />
extra={account.hasPermission("core.slave.generate_token") && <GenerateSlaveTokenButton />}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Agent",
dataIndex: "agent",
sorter: (a, b) => strcmp(a["agent"], b["agent"]),
responsive: ["lg"],
},
{
title: "Version",
dataIndex: "version",
sorter: (a, b) => strcmp(a["version"], b["version"]),
},
{
title: "Public address",
dataIndex: "public_address",
sorter: (a, b) => strcmp(a["public_address"], b["public_address"]),
},
{
title: "Connected",
key: "connected",
render: slave => slave["connected"] && "Yes",
render: slave => <Tag
color={slave["connected"] ? "#389e0d" : "#cf1322"}
>
{slave["connected"] ? "Connected" : "Disconnected"}
</Tag>,
sorter: (a, b) => a["connected"] - b["connected"],
},
]}
dataSource={slaveList}
rowKey={slave => slave["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/slaves/${record.id}/view`);
},
})}
/>
</PageLayout>;
};
+57 -41
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
import { Button, Col, Form, Input, PageHeader, Popconfirm, Popover, Row, Tag, Select, Space, Spin, Switch } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
@@ -39,6 +40,7 @@ export default function UserViewPage() {
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [user, updateUser] = useUser(userName);
let [roles, setRoles] = useState(null);
@@ -59,25 +61,55 @@ export default function UserViewPage() {
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (user.loading) {
return <PageLayout nav={nav}>
<h2>{userName}</h2>
<PageHeader
className="site-page-header"
title={userName}
/>
<Spin size="large" />
</PageLayout>;
}
if (user.missing) {
return <PageLayout nav={nav}>
<h2>User not found</h2>
<PageHeader
className="site-page-header"
title="User not found"
/>
<p>User with name {userName} was not found on the master server.</p>
</PageLayout>;
}
let roleSelector = <Form.Item
noStyle
name="roles"
initialValue={user["roles"]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
let role = roles.get(option.value);
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
}}
>
{roles && [...roles.values()].map(r => <Select.Option
key={r["id"]}
value={r["id"]}
>{r["name"]}</Select.Option>)}
</Select>
</Form.Item>;
return <PageLayout nav={nav}>
<Row>
<Col flex="auto">
<h2>{userName}</h2>
</Col>
<Col flex="0 0 auto">
<Popconfirm
<PageHeader
className="site-page-header"
title={userName}
extra={
account.hasPermission("core.user.delete") && <Popconfirm
title={<>
Delete user account and all data associated with it?
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
@@ -97,40 +129,22 @@ export default function UserViewPage() {
<DeleteOutlined />
</Button>
</Popconfirm>
</Col>
</Row>
}
/>
<Form form={form}>
<Form.Item
label="Roles"
>
<Row gutter={8}>
<Col flex="auto">
<Form.Item
noStyle
name="roles"
initialValue={user["roles"]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
let role = roles.get(option.value);
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
}}
>
{roles && [...roles.values()].map(r => <Select.Option
key={r["id"]}
value={r["id"]}
>{r["name"]}</Select.Option>)}
</Select>
</Form.Item>
{account.hasPermission("core.user.update_roles")
? roleSelector
: [...user["roles"]].map(id => <Tag key={id}>{
roles ? (roles.get(id) || { name: id })["name"] : id
}</Tag>)
}
</Col>
<Col flex="0 0 auto">
{account.hasPermission("core.user.update_roles") && <Col flex="0 0 auto">
<Button
type={"primary"}
disabled={!rolesDirty}
@@ -152,12 +166,13 @@ export default function UserViewPage() {
});
}}
>Apply</Button>
</Col>
</Col>}
</Row>
</Form.Item>
<Form.Item label="In-game Admin">
<Switch
checked={user["is_admin"]}
disabled={!account.hasPermission("core.user.set_admin")}
onClick={() => {
libLink.messages.setUserAdmin.send(control, {
name: userName,
@@ -172,6 +187,7 @@ export default function UserViewPage() {
<Form.Item label="Whitelisted">
<Switch
checked={user["is_whitelisted"]}
disabled={!account.hasPermission("core.user.set_whitelisted")}
onClick={() => {
libLink.messages.setUserWhitelisted.send(control, {
name: userName,
@@ -186,7 +202,7 @@ export default function UserViewPage() {
<Form.Item label="Banned">
<Space>
{user["is_banned"] ? "Yes" : "No"}
<Popover
{account.hasPermission("core.user.set_banned") && <Popover
title="Ban user"
visible={banUserPopover}
trigger="click"
@@ -195,7 +211,7 @@ export default function UserViewPage() {
setBanUserPopover(!banUserPopover);
}
}}
content=<Form.Item label="Reason">
content={<Form.Item label="Reason">
<Row gutter={8}>
<Col flex="auto">
<Form.Item noStyle name="reason">
@@ -220,7 +236,7 @@ export default function UserViewPage() {
>Ban</Button>
</Col>
</Row>
</Form.Item>
</Form.Item>}
>
<Button
type="primary"
@@ -238,7 +254,7 @@ export default function UserViewPage() {
}
}}
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
</Popover>
</Popover>}
</Space>
</Form.Item>
</Form>
+98 -41
View File
@@ -1,68 +1,125 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Spin, Tag } from "antd";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { libLink } from "@clusterio/lib";
import DataTable from "./data-table";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function CreateUserButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
async function createUser() {
let values = form.getFieldsValue();
if (!values.userName) {
form.setFields([{ name: "userName", errors: ["Name is required"] }]);
return;
}
await libLink.messages.createUser.send(control, { name: values.userName });
setVisible(false);
history.push(`/users/${values.userName}/view`);
}
return <>
<Button
type="primary"
onClick={() => { setVisible(true); }}
>Create</Button>
<Modal
title="Create User"
okText="Create"
visible={visible}
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
onCancel={() => { setVisible(false); }}
destroyOnClose
>
<Form form={form}>
<Form.Item name="userName" label="Name">
<Input/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
let [roles, setRoles] = useState(null);
let [users, setUsers] = useState([]);
let [roles, setRoles] = useState(new Map());
useEffect(() => {
libLink.messages.listUsers.send(control).then(result => {
setUsers(result["list"]);
}).catch(notifyErrorHandler("Error fetching user list"));
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
// ignore
});
}, []);
async function listUsers() {
let result = await libLink.messages.listUsers.send(control);
return result["list"].map(item => ({
key: item["name"],
"Name": item["name"],
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
"Admin": item["is_admin"] && "Yes",
"Whitelisted": item["is_whitelisted"] && "Yes",
"Banned": item["is_banned"] && "Yes",
}));
}
// DataTable does not make it possible to update the rendering function
if (roles === null) {
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<Spin/>
</PageLayout>;
}
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<DataTable
DataFunction={listUsers}
AddRecord={{
fields: [{
dataIndex: "name",
<PageHeader
className="site-page-header"
title="Users"
extra={account.hasPermission("core.user.create") && <CreateUserButton />}
/>
<Table
columns={[
{
title: "Name",
}],
insert: async args => {
await libLink.messages.createUser.send(control, { name: args.name });
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
history.push(`/users/${record.key}/view`);
},
}),
}}
{
title: "Roles",
key: "roles",
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
},
{
title: "Admin",
key: "admin",
render: user => user["is_admin"] && "yes",
sorter: (a, b) => a["is_admin"] - b["is_admin"],
responsive: ["lg"],
},
{
title: "Whitelisted",
key: "whitelisted",
render: user => user["is_whitelisted"] && "yes",
sorter: (a, b) => a["is_whitelisted"] - b["is_whitelisted"],
responsive: ["lg"],
},
{
title: "Banned",
key: "banned",
render: user => user["is_banned"] && "yes",
sorter: (a, b) => a["is_banned"] - b["is_banned"],
responsive: ["lg"],
},
]}
dataSource={users}
pagination={false}
rowKey={user => user["name"]}
onRow={(user, rowIndex) => ({
onClick: event => {
history.push(`/users/${user["name"]}/view`);
},
})}
/>
</PageLayout>;
}
@@ -1,281 +0,0 @@
import React, { Component } from "react";
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import notify from "../util/notify";
const { Panel } = Collapse;
/**
* Display data in table. Allows for editing data by clicking rows in the table.
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
*
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
* Editable - Boolean - Allow editing
* AddRecord - Boolean - Allow adding new records
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
* RecordIdField - String -
* By default we assume the column "Id" is a unique identifier for a record. If
* that isn't the case, supply the column name here
*/
class DataTable extends Component {
constructor(props) {
super(props);
if (!this.props.DataFunction) {
throw new Error("DataFunction not defined");
}
this.state = {
pagination: {
pageSize: 100,
current: 1,
},
filters: this.props.Filters,
sorter: {},
tableData: [],
showEditModal: false,
record: {},
};
}
componentDidMount() {
this.getData();
}
async getData(pagination, filters, sorter) {
// pagination = {
// pageSize: 10, // items per page
// current: 1, // page number
// total: 123, // Total amount of items
// }
// filters = {
// ReportId: 123
// }
// sorter = {}
pagination = pagination || this.state.pagination;
filters = filters || this.state.filters;
sorter = sorter || this.state.sorter;
// eslint-disable-next-line new-cap
let data = await this.props.DataFunction();
data = data || {};
this.setState({
// eslint-disable-next-line new-cap
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
pagination: { ...pagination, ...data.pagination },
sorter,
filters,
});
}
showEditModal(record, index) {
if (!this.state.showEditModal) {
this.setState({
record: record,
showEditModal: true,
});
}
}
hideEditModal() {
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
}
async onEditFinish(values) {
if (this.state.inserting) { // Create new record
await this.props.AddRecord.insert(values);
} else { // Edit existing record, capable of doing sparse updates
await this.props.Editable.insert(values);
}
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
// Force refresh of data
this.getData();
}
async deleteRecord(record) {
delete record.key;
// Delete "null" values as they seem to cause deletes to fail silently when
// the value is null and field type is float
for (let key in record) {
if (record[key] === null) { delete record[key]; }
}
let status = {};
// TODO: Delete data here
if (status.ok) { notify("success", status.msg, "success"); }
if (!status.ok) { notify("error", status.msg, "error"); }
// Force refresh of data
this.getData();
}
render() {
let tableProps = {
...this.props.TableProps || {},
onRow: (record, rowIndex) => {
// To allow for extending functionality, we take a TableProps argument
// from props and pass it through to the Table component. Since we
// are also using onRow internaly here, we handle the externally
// provided events ourselves by passing along the event object. This
// way the end user isn't affected by our event handlers.
let externalOnRow = {
...(
this.props.TableProps
&& this.props.TableProps.onRow
&& this.props.TableProps.onRow(record, rowIndex)
),
};
return {
onClick: event => {
if (event.target.type !== "button") {
if (this.props.Editable) { this.showEditModal(record, rowIndex); }
if (externalOnRow.onClick) { externalOnRow.onClick(event); }
}
},
onDoubleClick: event => {
if (externalOnRow.onDoubleClick) { externalOnRow.onDoubleClick(event); }
},
onContextMenu: event => {
if (externalOnRow.onContextMenu) { externalOnRow.onContextMenu(event); }
},
onMouseEnter: event => {
if (externalOnRow.onMouseEnter) { externalOnRow.onMouseEnter(event); }
},
onMouseLeave: event => {
if (externalOnRow.onMouseLeave) { externalOnRow.onMouseLeave(event); }
},
};
},
};
let columns = this.props.columns || this.inferColumns(this.state.tableData);
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) {
columns.push({
// Add delete button on the right
dataIndex: "notInUse",
title: "",
render: (text, record) => <Popover
content={
<Button
type="primary"
danger
onClick={() => {
this.deleteRecord(record);
}}
>
Slett permanent
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>,
});
}
return <>
<Row>
<Col span={24}>
<style>
{`
.editableTable .ant-table-tbody > tr {
cursor:pointer
}
`}
</style>
<Table
className={this.props.Editable && "editableTable"}
{...tableProps}
columns={columns.map(x => ({
...x, // Add a 4th parameter to the stock render function with all of our table data
render: x.render
? (...originalParams) => x.render(...originalParams, this.state.tableData)
: undefined,
}))}
dataSource={this.state.tableData}
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
onChange={this.getData.bind(this)}
/>
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
showEditModal: true,
inserting: true,
})}>
<PlusOutlined />Add
</Button>}
</Col>
<Modal
visible={this.state.showEditModal}
onCancel={this.hideEditModal.bind(this)}
footer={null}
title={
<h2>{this.state.inserting ? "Add" : "Edit"}</h2>
}
>
{this.state.showEditModal && <Form
labelCol={{
span: 6,
}}
wrapperCol={{
span: 12,
}}
layout="horizontal"
onFinish={this.onEditFinish.bind(this)}
initialValues={this.state.record}
>
{(
// Use externally supplied form input fields
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
// Generate input fields from external columns
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
// Generate input fields from autogenerated columns (only works with existing data)
|| this.inferColumns(this.state.tableData)
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit && col.renderEdit(
this.state.record[col.dataIndex],
this.state.record,
undefined /* Non spec compliant, is supposed to be index of record */,
this.state.tableData
) || <Input placeholder={this.state.record[col.dataIndex]} />}
</Form.Item>)}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Save" : "Save"}
</Button>
</Form.Item>
</Form>}
</Modal>
</Row>
</>;
}
inferColumns(data) {
// Look at the data and find columns and how to display them. Can be
// overridden by props.Columns if more customization is needed
let columns = [];
data.forEach(row => {
Object.keys(row).filter(x => x !== "key").forEach(key => {
let existing = columns.find(col => col.key === key);
if (!existing) {
columns.push({
key: key,
title: key,
dataIndex: key,
});
}
});
});
return columns;
}
}
export default DataTable;
+7 -1
View File
@@ -5,6 +5,7 @@ export * from "./model/instance.jsx";
export * from "./model/slave.jsx";
export * from "./model/locale.jsx";
export * from "./model/item_metadata.jsx";
export * from "./model/account.jsx";
export { default as ControlContext } from "./components/ControlContext";
@@ -13,9 +14,14 @@ export { default as BaseConfigTree } from "./components/BaseConfigTree";
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
export { default as MasterConfigTree } from "./components/MasterConfigTree";
export { default as LogConsole } from "./components/LogConsole";
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceList } from "./components/InstanceList";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as InstanceStatusTag } from "./components/InstanceStatusTag";
export { default as StartStopInstanceButton } from "./components/StartStopInstanceButton";
export { statusColors } from "./components/InstanceStatusTag";
export { default as PageLayout } from "./components/PageLayout";
export { default as SavesList } from "./components/SavesList";
+47 -9
View File
@@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext";
/**
* @typedef {Object} Account
* @property {?string} name - Name of the currently logged in account.
* @property {?Array<object>} roles - Roles of the corrently logged in account.
* @property {function(string)} hasPermission -
* Check if the currently logged in account has the given permission.
* @property {function()} logout - Logs out of the web interface.
*/
export function useAccount() {
let control = useContext(ControlContext);
let [name, setName] = useState(control.accountName);
let [roles, setRoles] = useState(control.accountRoles);
useEffect(() => {
function onConnect(data) {
setName(data.account.name);
function onAccountUpdate(account) {
setName(account.name);
setRoles(account.roles);
}
function onClose() {
setName(null);
}
control.connector.on("connect", onConnect);
control.connector.on("close", onClose);
control.onAccountUpdate(onAccountUpdate);
return () => {
control.connector.off("connect", onConnect);
control.connector.off("close", onClose);
control.offAccountUpdate(onAccountUpdate);
};
}, [control]);
function hasPermission(permission) {
for (let role of roles) {
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
return true;
}
}
return false;
}
return {
name,
roles,
hasPermission(permission) {
if (!roles) {
return null;
}
return hasPermission(permission);
},
hasAnyPermission(...permissions) {
if (!roles) {
return null;
}
for (let permission of permissions) {
if (hasPermission(permission)) {
return true;
}
}
return false;
},
hasAllPermission(...permissions) {
if (!roles) {
return null;
}
for (let permission of permissions) {
if (!hasPermission(permission)) {
return false;
}
}
return true;
},
logOut() {
control.loggingOut = true;
control.shutdown(notifyErrorHandler("Error logging out"));
+5
View File
@@ -14,10 +14,15 @@ export function useSaves(instanceId) {
setSaves(result.list);
}).catch(err => {
logger.error(`Failed to list instance saves: ${err}`);
setSaves([]);
});
}
useEffect(() => {
if (!Number.isInteger(instanceId)) {
setSaves([]);
return undefined;
}
updateSaves();
function updateHandler(data) {
+60 -10
View File
@@ -2,6 +2,7 @@ import React from "react";
import MasterPage from "./components/MasterPage";
import SlavesPage from "./components/SlavesPage";
import SlaveViewPage from "./components/SlaveViewPage";
import InstancesPage from "./components/InstancesPage";
import InstanceViewPage from "./components/InstanceViewPage";
import UsersPage from "./components/UsersPage";
@@ -13,16 +14,65 @@ import PluginViewPage from "./components/PluginViewPage";
export const pages = [
{ path: "/master", sidebarName: "Master", content: <MasterPage /> },
{ path: "/slaves", sidebarName: "Slaves", content: <SlavesPage /> },
{ path: "/instances", sidebarName: "Instances", content: <InstancesPage />},
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
{ path: "/users", sidebarName: "Users", content: <UsersPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles", sidebarName: "Roles", content: <RolesPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
{ path: "/plugins", sidebarName: "Plugins", content: <PluginsPage /> },
{ path: "/plugins/:name/view", sidebarPath: "/plugins", content: <PluginViewPage /> },
{
path: "/master",
sidebarName: "Master",
content: <MasterPage />,
},
{
path: "/slaves",
sidebarName: "Slaves",
permission: "core.slave.list",
content: <SlavesPage />,
},
{
path: "/slaves/:id/view",
sidebarPath: "/slaves",
content: <SlaveViewPage />,
},
{
path: "/instances",
sidebarName: "Instances",
permission: "core.instance.list",
content: <InstancesPage />,
},
{
path: "/instances/:id/view",
sidebarPath: "/instances",
content: <InstanceViewPage />,
},
{
path: "/users",
sidebarName: "Users",
permission: "core.user.list",
content: <UsersPage />,
},
{
path: "/users/:name/view",
sidebarPath: "/users",
content: <UserViewPage />,
},
{
path: "/roles",
sidebarName: "Roles",
permission: "core.user.list",
content: <RolesPage />,
},
{
path: "/roles/:id/view",
sidebarPath: "/roles",
content: <RoleViewPage />,
},
{
path: "/plugins",
sidebarName: "Plugins",
content: <PluginsPage />,
},
{
path: "/plugins/:name/view",
sidebarPath: "/plugins",
content: <PluginViewPage />,
},
];
export const sidebarPages = [
+86 -20
View File
@@ -27,6 +27,22 @@ export class ControlConnector extends libLink.WebSocketClientConnector {
}
}
function logFilter({ all, master, slave_ids, instance_ids }, info) {
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;
}
/**
* Handles running the control
*
@@ -60,13 +76,22 @@ export class Control extends libLink.Link {
*/
this.accountName = null;
/**
* Roles of the account this control link is connected as.
* @type {?Array<object>}
*/
this.accountRoles = null;
this.accountUpdateHandlers = [];
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.instanceLogHandlers = new Map();
this.logHandlers = new Map();
this.connector.on("connect", data => {
this.accountName = data.account.name;
this.accountRoles = data.account.roles;
this.emitAccountUpdate();
this.updateSlaveSubscriptions().catch(err => logger.error(
`Unexpected error updating slave subscriptions:\n${err.stack}`
));
@@ -83,6 +108,8 @@ export class Control extends libLink.Link {
this.connector.on("close", () => {
this.accountName = null;
this.accountRoles = null;
this.emitAccountUpdate();
});
for (let event of ["connect", "drop", "resume", "close"]) {
@@ -94,6 +121,32 @@ export class Control extends libLink.Link {
}
}
async accountUpdateEventHandler(message) {
this.accountRoles = message.data.roles;
this.emitAccountUpdate();
}
emitAccountUpdate() {
for (let handler of this.accountUpdateHandlers) {
handler({
name: this.accountName,
roles: this.accountRoles,
});
}
}
onAccountUpdate(handler) {
this.accountUpdateHandlers.push(handler);
}
offAccountUpdate(handler) {
let index = this.accountUpdateHandlers.lastIndexOf(handler);
if (index === -1) {
throw new Error("Given handler is not registered for account update");
}
this.accountUpdateHandlers.splice(index, 1);
}
async slaveUpdateEventHandler(message) {
let handlers = [].concat(
this.slaveUpdateHandlers.get(null) || [],
@@ -265,23 +318,20 @@ export class Control extends libLink.Link {
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);
for (let [filter, handlers] of this.logHandlers) {
if (logFilter(filter, info)) {
for (let handler of handlers) {
handler(info);
}
}
}
}
async onInstanceLog(id, handler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.instanceLogHandlers.get(id);
async onLog(filter, handler) {
let handlers = this.logHandlers.get(filter);
if (!handlers) {
handlers = [];
this.instanceLogHandlers.set(id, handlers);
this.logHandlers.set(filter, handlers);
}
handlers.push(handler);
@@ -291,15 +341,15 @@ export class Control extends libLink.Link {
}
}
async offInstanceLog(id, handler) {
let handlers = this.instanceLogHandlers.get(id);
async offLog(filter, handler) {
let handlers = this.logHandlers.get(filter);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for instance ${id} exist`);
throw new Error("No handlers for the given filter exists");
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
throw new Error("Given handler is not registered for the filter");
}
handlers.splice(index, 1);
@@ -313,13 +363,29 @@ export class Control extends libLink.Link {
return;
}
await libLink.messages.setLogSubscriptions.send(this, {
let combinedFilter = {
all: false,
master: false,
slave_ids: [],
instance_ids: [...this.instanceLogHandlers.keys()],
max_level: null,
});
instance_ids: [],
};
for (let filter of this.logHandlers.keys()) {
if (filter.all) { combinedFilter.all = true; }
if (filter.master) { combinedFilter.master = true; }
for (let slaveId of filter.slave_ids || []) {
if (!combinedFilter.slave_ids.includes(slaveId)) {
combinedFilter.slave_ids.push(slaveId);
}
}
for (let instanceId of filter.instance_ids || []) {
if (!combinedFilter.instance_ids.includes(instanceId)) {
combinedFilter.instance_ids.push(instanceId);
}
}
}
await libLink.messages.setLogSubscriptions.send(this, { ...combinedFilter, max_level: null });
}
async debugWsMessageEventHandler(message) {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,8 +20,8 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
+3 -7
View File
@@ -2,6 +2,7 @@
const fs = require("fs-extra");
const path = require("path");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
@@ -26,7 +27,7 @@ async function saveDatabase(masterConfig, playerDatastore, logger) {
let file = path.resolve(masterConfig.get("master.database_directory"), "inventories.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(Array.from(playerDatastore));
await fs.outputFile(file, content);
await libFileOps.safeOutputFile(file, content);
}
}
@@ -160,14 +161,9 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
this.logger.warn(`${instanceName} downloaded ${player_name} while another instance has acquired it`);
}
let playerData = this.playerDatastore.get(player_name);
if (!playerData) {
throw new libErrors.RequestError(`No player data exists for ${player_name}`);
}
this.logger.verbose(`Sending player data for ${player_name} to ${instanceName}`);
return {
player_data: playerData || null,
player_data: this.playerDatastore.get(player_name) || null,
};
}
@@ -28,6 +28,8 @@ local function download_inventory(player_name, data, number, total)
log("ERROR: Inventory sync failed, got empty player data for " .. player.name .. " from the master")
player.print("ERROR: Inventory sync failed, got empty player data from the master")
progress_dialog.remove(player)
-- Give the player a character and pretend that's the synced player data
ensure_character(player)
@@ -194,7 +194,10 @@ function inventory_sync.sync_player(acquire_response)
return
end
if not player_record.sync or acquire_response.generation > player_record.generation then
if
acquire_response.has_data
and (not player_record.sync or acquire_response.generation > player_record.generation)
then
inventory_sync.initiate_inventory_download(player, player_record, acquire_response.generation)
else
@@ -3,6 +3,18 @@ local serialize = require("modules/inventory_sync/serialize")
local save_crafts = require("modules/inventory_sync/save_crafts")
local function upload_inventory(player, player_record)
-- Editor mode is not supported.
if player.controller_type == defines.controllers.editor then
player.toggle_map_editor()
end
if player.controller_type == defines.controllers.editor then
log("ERROR: Inventory sync failed to upload, unable to switch " .. player.name .. " out of editor mode")
player.print("ERROR: Inventory sync failed to upload, unable to switch out of editor mode")
global.inventory_sync.active_uploads[player.name] = nil
return
end
local serialized_player = serialize.serialize_player(player)
serialized_player.generation = player_record.generation
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+6 -1
View File
@@ -31,7 +31,12 @@ function InventoryPage() {
export class WebPlugin extends libPlugin.BaseWebPlugin {
async init() {
this.pages = [
{ path: "/inventory", sidebarName: "Inventory sync", content: <InventoryPage /> },
{
path: "/inventory",
sidebarName: "Inventory sync",
permission: "inventory_sync.inventory.view",
content: <InventoryPage />,
},
];
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -24,8 +24,8 @@
"jsonwebtoken": "^8.2.1"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"phin": "^3.5.1",
"react": "^16.13.1",
+13 -4
View File
@@ -19,6 +19,8 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
this.instance.server.on("ipc-research_sync:finished", (tech) => {
this.researchFinished(tech).catch(err => this.unexpectedError(err));
});
this.syncStarted = false;
}
async researchContribution(tech) {
@@ -26,8 +28,11 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async progressEventHandler(message) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
let techsJson = libLuaTools.escapeString(JSON.stringify(message.data.technologies));
await this.sendRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
await this.sendOrderedRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
}
async researchFinished(tech) {
@@ -35,14 +40,17 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async finishedEventHandler(message) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
let { name, level, researched } = message.data;
await this.sendRcon(
await this.sendOrderedRcon(
`/sc research_sync.research_technology("${libLuaTools.escapeString(name)}", ${level})`, true
);
}
async onStart() {
let dumpJson = await this.sendRcon("/sc research_sync.dump_technologies()");
let dumpJson = await this.sendOrderedRcon("/sc research_sync.dump_technologies()");
let techsToSend = [];
let instanceTechs = new Map();
for (let tech of JSON.parse(dumpJson)) {
@@ -56,6 +64,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
let response = await this.info.messages.syncTechnologies.send(this.instance, { technologies: techsToSend });
this.syncStarted = true;
let techsToSync = [];
for (let masterTech of response.technologies) {
let [name, level, progress, researched] = masterTech;
@@ -72,7 +81,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
if (techsToSync.length) {
let syncJson = libLuaTools.escapeString(JSON.stringify(techsToSync));
await this.sendRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
await this.sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
const fs = require("fs-extra");
const path = require("path");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const RateLimiter = require("@clusterio/lib/RateLimiter");
@@ -25,7 +26,7 @@ async function loadTechnologies(masterConfig, logger) {
async function saveTechnologies(masterConfig, technologies, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
}
class MasterPlugin extends libPlugin.BaseMasterPlugin {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
+4
View File
@@ -77,6 +77,10 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async onMetrics() {
if (this.instance.status !== "running") {
return;
}
// Wait configured timeout for the metrics to be collected. It may
// take a long time for the command to go through if the command
// stream is overloaded. Should the timeout be exceeded the
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,8 +20,8 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
@@ -17,7 +17,14 @@ describe("statistics_exporter plugin", function() {
});
describe(".onMetrics()", function() {
it("should not send commands before running", async function() {
instancePlugin.instance.server.rconCommands.length = 0;
instancePlugin.instance.status = "starting";
await instancePlugin.onMetrics();
assert(instancePlugin.instance.server.rconCommands.length === 0, "commands were run");
});
it("should record statistics", async function() {
instancePlugin.instance.status = "running";
instancePlugin.instance.server.rconCommandResults.set(
"/sc statistics_exporter.export()",
JSON.stringify({
+2 -1
View File
@@ -3,6 +3,7 @@ const fs = require("fs-extra");
const path = require("path");
const libDatabase = require("@clusterio/lib/database");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const { Counter, Gauge } = require("@clusterio/lib/prometheus");
const RateLimiter = require("@clusterio/lib/RateLimiter");
@@ -49,7 +50,7 @@ async function saveDatabase(masterConfig, items, logger) {
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(items.serialize());
await fs.outputFile(file, content);
await libFileOps.safeOutputFile(file, content);
} else if (items) {
logger.error(`Item database too large, not saving (${items.size})`);
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.9",
"version": "2.0.0-alpha.10",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.9",
"@clusterio/web_ui": "^2.0.0-alpha.9",
"@clusterio/lib": "^2.0.0-alpha.10",
"@clusterio/web_ui": "^2.0.0-alpha.10",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+6 -1
View File
@@ -117,7 +117,12 @@ function StoragePage() {
export class WebPlugin extends libPlugin.BaseWebPlugin {
async init() {
this.pages = [
{ path: "/storage", sidebarName: "Storage", content: <StoragePage/> },
{
path: "/storage",
sidebarName: "Storage",
permission: "subspace_storage.storage.view",
content: <StoragePage/>,
},
];
this.storage = new Map();
+2
View File
@@ -49,6 +49,8 @@ class TestControl extends libLink.Link {
async debugWsMessageEventHandler() { }
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler(message) {
this.slaveUpdates.push(message.data);
}
+18
View File
@@ -10,6 +10,8 @@ describe("lib/file_ops", function() {
async function setupTestingEnv() {
await fs.ensureDir(path.join(baseDir, "test", "folder"));
await fs.ensureDir(path.join(baseDir, "test", "another folder"));
await fs.remove(path.join(baseDir, "safe"));
await fs.ensureDir(path.join(baseDir, "safe"));
await fs.outputFile(path.join(baseDir, "test", "file.txt"), "contents");
await fs.outputFile(path.join(baseDir, "test", "another file.txt"), "more contents");
@@ -66,4 +68,20 @@ describe("lib/file_ops", function() {
}
});
});
describe("safeOutputFile()", function() {
it("should write new target file", async function() {
let target = path.join(baseDir, "safe", "simple.txt");
await libFileOps.safeOutputFile(target, "a text file", "utf8");
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "a text file");
});
it("should overwrite existing target file", async function() {
let target = path.join(baseDir, "safe", "exists.txt");
await fs.outputFile(target, "previous", "utf8");
await libFileOps.safeOutputFile(target, "current", "utf8");
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "current");
});
});
});
+93
View File
@@ -0,0 +1,93 @@
"use strict";
const assert = require("assert").strict;
const libIni = require("@clusterio/lib/ini");
describe("lib/ini", function() {
describe("parse()", function() {
it("should ignore comments", function() {
assert.deepEqual(
libIni.parse("# A comment\n; Another comment\nfoo=bar\n"),
{ foo: "bar" },
);
});
it("should ignore certain whitespace", function() {
assert.deepEqual(
libIni.parse(" # A comment \n\n [spam] \t \n \tfoo=bar\n"),
{ spam: { foo: "bar" }}
);
});
it("should preserve certain whitespace", function() {
assert.deepEqual(
libIni.parse("[\t spam\t ]\nfoo =\t bar \n"),
{ "\t spam\t ": { "foo ": "\t bar " }}
);
});
it("should not treat characters specially", function() {
assert.deepEqual(
libIni.parse("[#;spam]\nfoo#;=#;bar=#;'\""),
{ "#;spam": { "foo#;": "#;bar=#;'\"" }}
);
});
it("should handle windows line endings", function() {
assert.deepEqual(
libIni.parse("[spam]\r\nfoo=bar\r\n"),
{ "spam": { "foo": "bar" }}
);
});
it("should throw on incomplete section", function() {
assert.throws(
() => libIni.parse("[spam"),
new Error("Unterminated section header on line 1")
);
});
it("should throw on missing value", function() {
assert.throws(
() => libIni.parse("key"),
new Error("Missing value for key key on line 1")
);
});
it("should throw on duplicated section", function() {
assert.throws(
() => libIni.parse("[spam]\n[spam]\n"),
new Error("Duplicated section [spam] on line 2")
);
});
it("should throw on duplicated keys", function() {
assert.throws(
() => libIni.parse("key=value\nkey=value\n"),
new Error("Duplicated key key on line 2")
);
});
});
describe("stringify()", function() {
it("should serialize to reference output", function() {
let cases = [
[{}, ""],
[{ foo: "bar" }, "foo=bar\n"],
[{ foo: { spam: "one", bar: "two" }}, "[foo]\nspam=one\nbar=two\n"],
[
{ start: "alpha", foo: { spam: "one", bar: "two" }, end: "beta" },
"start=alpha\nend=beta\n\n[foo]\nspam=one\nbar=two\n",
],
[{ "trail ": "\t tabs \t" }, "trail =\t tabs \t\n"],
[{ "#;spam": { "foo#;": "#;bar=#;'\"" }}, "[#;spam]\nfoo#;=#;bar=#;'\"\n"],
];
for (let [input, expected] of cases) {
assert.equal(libIni.stringify(input), expected);
}
});
});
it("should round trip object", function() {
for (let expected of [
{},
{ simple: "value" },
{ section: { foo: "false", bar: "text" }},
{ mixed: "root", section: { foo: "1", bar: "2" }, end: "value" },
{ first: { foo: "1", bar: "2" }, second: { spam: "true" }},
{ '"#;[] ': ' "#;[] ', ' #;"[ ': { '"#;[] ': ' "#;[ ' }},
]) {
let actual = libIni.parse(libIni.stringify(expected));
assert.deepEqual(actual, expected);
}
});
});
+31 -1
View File
@@ -10,7 +10,7 @@ describe("lib/plugin", function() {
describe("class BaseInstancePlugin", function() {
let instancePlugin;
it("should be constructible", async function() {
instancePlugin = new libPlugin.BaseInstancePlugin({}, { logger: new mock.MockLogger() }, {});
instancePlugin = new libPlugin.BaseInstancePlugin({}, new mock.MockInstance(), {});
await instancePlugin.init();
});
it("should define defaults for hooks", async function() {
@@ -22,6 +22,36 @@ describe("lib/plugin", function() {
instancePlugin.onMasterConnectionEvent("connect");
await instancePlugin.onPrepareMasterDisconnect();
});
describe("sendRcon", function() {
it("should send commands out of order", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" });
let a = instancePlugin.sendRcon("a");
let b = instancePlugin.sendRcon("b");
let result = await Promise.race([a, b]);
assert.equal(result, "b");
});
it("should propagate errors", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref"));
await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref"));
});
});
describe("sendOrderedRcon", function() {
it("should send commands in order", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" });
let a = instancePlugin.sendOrderedRcon("a");
let b = instancePlugin.sendOrderedRcon("b");
let result = await Promise.race([a, b]);
assert.equal(result, "a");
});
it("should propagate errors", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref"));
await assert.rejects(instancePlugin.sendOrderedRcon("a"), new Error("ref"));
});
});
});
describe("class BaseMasterPlugin", function() {
+16 -1
View File
@@ -3,6 +3,7 @@ const events = require("events");
const http = require("http");
const express = require("express");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libUsers = require("@clusterio/lib/users");
@@ -105,7 +106,20 @@ class MockServer extends events.EventEmitter {
async sendRcon(command) {
this.rconCommands.push(command);
return this.rconCommandResults.get(command) || "";
let result = this.rconCommandResults.get(command);
if (!result) {
result = { response: "" };
}
if (result instanceof Error || typeof result === "string") {
result = { response: result };
}
if (result.time) {
await libHelpers.wait(result.time);
}
if (result.response instanceof Error) {
throw result.response;
}
return result.response || "";
}
}
@@ -116,6 +130,7 @@ class MockInstance extends libLink.Link {
this.server = new MockServer();
this.name = "test";
this.id = 7357;
this.status = "running";
this.mockConfigEntries = new Map([
["instance.id", 7357],
["factorio.enable_save_patching", true],