Compare commits

..
Author SHA1 Message Date
Hornwitser bcb2365c9a 2.0.0-alpha.13 2022-07-20 13:30:35 +02:00
Hornwitser 259d18eb30 Fix Web UI not displaying date for last seen 2022-07-20 13:30:35 +02:00
Hornwitser 04852196ad Rename play time to online time
Be a bit more accurate in the online time stat name and call it for what
it is.  It counts time spend online on the server, not necessarily
time spent playing.
2022-07-20 13:18:47 +02:00
Hornwitser fc1daa7e71 Bump path traversal test timeout (#472)
Double timeout on these two tests as they sometimes time out on GitHub
Actions.
2022-07-19 21:24:44 +02:00
Hornwitser e234d3db36 Fix disconnect_reason missing before Factorio 1.1.3 (#471)
Provide "quit" as the reason for disconnecting in versions of Factorio
before 1.1.3 which added reason to the on_player_left_game event.
2022-07-19 21:24:26 +02:00
Hornwitser 7b8127fef8 Refactor inventory_sync serialisation (#470)
Fix broken support for retrying uploads and background downloads by
making crafting queue serialisation non-destructive and only performing
the serialisation and deserialisation while the player is online.

This removes the /save-crafts and /restore-crafts commands because the
existing implementation would voids the non-saved crafting queue items
if /save-crafts is performed and then new items are added to the
crafting queue before switching server.

Fixes #419.
2022-07-19 21:24:12 +02:00
Hornwitser db84b5a6ce Shut up Dependabot (#469)
If only the bot was clever enough to see that node-forge is only used
for a test that has no user input it might have had the foresight to
realize that crying wolf about completely irrelevant security issues
will only lead to legitimate issues being ignored in the future. But
alas, the AI overlords are not taking over any time soon.
2022-07-19 21:23:33 +02:00
Hornwitser c79ced28dd Merge pull request #468 from Hornwitser/installer-fixes
Installer fixes
2022-07-19 21:23:09 +02:00
Hornwitser 2408dc8985 Remove obsolete Factorio download instructions
On Linux the installer offers to correctly download and install the
latest version of Factorio.  Remove no longer needed instructions for
how to do it incorrectly.
2022-07-18 20:10:08 +02:00
Hornwitser 2953eb17d6 Refuse installing Clusterio as root
I have seen multiple people have installed Clusterio as the root account
and then run it as root when tasked with setting up Clusterio on Linux.
Make this mistake harder to do by refusing to install as root by
default.
2022-07-18 20:05:37 +02:00
Hornwitser c9619397b6 Use current user's group for systemd files
Generate systemd files using the current user's main group name instead
of nogroup as the latter is not present in all systems.
2022-07-18 19:39:01 +02:00
Daniel c8a6b8094e Merge pull request #452 from Danielv123/slave-guided-setup
Added setup guide for slaves from web interface
2022-07-16 16:51:41 +02:00
danielv 7ee9c60e38 Add syntax highlighting to codeblock 2022-07-16 15:59:57 +02:00
danielv 4b0a33174d Added setup guide for slaves from web interface 2022-07-16 15:59:27 +02:00
Hornwitser db27650b60 Merge pull request #467 from Hornwitser/user-ui-update
User UI update
2022-07-16 15:11:21 +02:00
Hornwitser aadf24db99 Add test for sending user ban_reason 2022-07-16 14:30:06 +02:00
Hornwitser 2496e756f1 Fix display of stats in Web UI when empty 2022-07-15 16:14:04 +02:00
Hornwitser 5ba9f94b7e Only show admin/whitelist switches if usable
Since the admin and whitelist status is shown with a tag next to the
name it's no longer necessary to show the admin and whitelisted switches
when the user does not have permission to use them.
2022-07-15 16:14:04 +02:00
Hornwitser 9fa41e37f3 Show ban reason in Web UI
Show and update live the reason given for someone being banned in the
Web UI.
2022-07-15 15:57:45 +02:00
Hornwitser d184b9db81 Merge pull request #466 from Hornwitser/player-tracking
Per player statistics
2022-07-13 22:49:33 +02:00
Hornwitser 08e7434d24 Add command to extract play time from save
Add command that extracts play time (and players) from the currently
running save to the recorded instance statistics.  This is useful for
getting correct play time statistics when an existing save game is
imported.
2022-07-13 00:31:02 +02:00
Hornwitser 0245ccd16c Share player stats with master server
Send the per player stats with the player event updates to the master
server and expose it to the Web UI and ctl interfaces.
2022-07-12 23:03:50 +02:00
Hornwitser deb6842a4d Record all players as leaving on server exit
In the case of shutdown the Factorio server will not notify the
currently online players as leaving the game.  Notify the master server
that all remaining online players quit the game in this case.
2022-07-12 23:03:50 +02:00
Hornwitser ac62f66aa1 Move PlayerStats to separate data class 2022-07-12 23:03:50 +02:00
Hornwitser 097827b515 Update linter for ES2022 syntax
Allow modern JavaScript syntax including class property declarations
since Node.js v12 supports this.
2022-07-12 17:29:22 +02:00
Hornwitser 9a3edc8c11 Fix web build broken by assert and buffer imports
Not sure what made it break, but it seems like it shouldn't have worked
to begin with.
2022-07-08 01:00:54 +02:00
Hornwitser 096f338277 Add live updating of users
Provied a subscription for user updates and send updates whenever the
details of a user account is changed.
2022-07-08 01:00:54 +02:00
Hornwitser db562ce6a0 Provide reason for player leaving
Expose the reason for why a player is leaving a server in playerEvent.
2022-07-08 01:00:54 +02:00
Hornwitser 8017497bdd Merge pull request #464 from Hornwitser/player-time-autosave
Player online autosave pool
2022-06-30 14:33:19 +02:00
Hornwitser 3cb9843f23 Add player online autosave pool
Add separate autosave pool for autosaves that have had players online
since the previous autosave.
2022-06-30 01:03:29 +02:00
Hornwitser a063cf2db5 Track player stats on instances
Track which players are online and record basic stats for each player on
each instance including when save patching is disabled.
2022-06-29 21:35:56 +02:00
Hornwitser 72357dfd23 Fix wrong action in load scenario error message 2022-06-29 21:25:25 +02:00
Hornwitser 0df6701326 Use consistent path for saves in Instance 2022-06-29 21:25:25 +02:00
Hornwitser f5e558cb2f Sneaky tab removal 2022-06-29 21:25:25 +02:00
Hornwitser 995f0934ed Merge pull request #463 from Hornwitser/player-time-autosave-bugfixes
Player time autosave bugfixes
2022-06-24 13:08:35 +02:00
Hornwitser ef74436cde Fix jsdoc for #462
Fix references and missing files from the jsdoc output after the
refactor in #462.
2022-06-23 16:57:52 +02:00
Hornwitser 51c025c79b Fix Log out button not logging out 2022-06-23 16:57:52 +02:00
Hornwitser 12767fd58c Merge pull request #462 from Hornwitser/minor-refactor
Minor refactor
2022-06-23 16:40:14 +02:00
Hornwitser 826d89c7dc Fix race condition on creating instance directory 2022-06-23 00:09:28 +02:00
Hornwitser 393652c1d9 Allow instance names to be any string
Replace bad characters with underscore instead of rejecting bad instance
names.
2022-06-23 00:08:23 +02:00
Hornwitser 246ce73d94 Break commands out of ctl.js 2022-06-22 22:33:54 +02:00
Hornwitser 951bad4134 Refactor slave code
Move classes from slave.js monolit into their own source files.
2022-06-22 22:16:58 +02:00
Hornwitser ae17b00b6f Hide start button based on permissions
Hide the start/stop buttin in instance view if the current used does not
have permission to use it.
2022-06-22 21:26:41 +02:00
Hornwitser 36808b34de Merge pull request #461 from Hornwitser/small-things-8
Small things 8
2022-06-20 13:26:14 +02:00
Hornwitser 105f99ced3 Preserve entered vehicle on inventory sync
Save vehicle the player is driving before downloading the inventory and
restore the player position to this vehicle if possible.
2022-06-19 19:51:22 +02:00
Hornwitser 430a64722e Use empty slots as end for logistics requests
Count up to 100 empty slots past the last seen slot with data in it
before stopping to look for personal logistics slots in inventory_sync.
Allows unlimeted amount of logistics request slots to be used for
heavily modded games.
2022-06-19 17:03:08 +02:00
Hornwitser 7e9604a254 Fix LogConsole scroll anchor trasmutation
Fix react mutating the scroll anchor element in LogConsole into a log
line and breaking scroll anchoring to the bottom as a result.
2022-06-19 16:38:02 +02:00
Hornwitser 5369f283d7 Merge pull request #460 from Hornwitser/cache-web
Cache static web files
2022-06-19 15:34:19 +02:00
Hornwitser 654465e4eb Fix LogConsole using duplicated keys
Fix past lines loaded from the log containing keys that clash with new
items logged.
2022-06-09 00:53:12 +02:00
Hornwitser c5a0c0a873 Migrate to new <Menu> syntax
The use of <Menu.Item> children to <Menu> is deprecated, convert
existing code to use the new items={...} syntax.
2022-06-09 00:52:27 +02:00
Hornwitser 16a466aed0 Merge pull request #458 from Hornwitser/replace-npm
Replace npm and lerna with pnm
2022-06-09 00:11:24 +02:00
Hornwitser 3e836c1dd3 Fix web interface breaking when disabling plugins
Fix incorrect logic to set up plugins in the web interface causing an
emptry plugin to be atempted to be loaded when a plugin was disabled.
2022-06-08 23:26:33 +02:00
Hornwitser ddf15f9b92 Add option to turn on authentication server bans
Factorio 1.1.40 added a feature to use a centralized banlist maintained
by Wube.  For the dedicated server this needs to be turned on with the
command line flag --use-authserver-bans.
2022-06-08 22:43:58 +02:00
Hornwitser 54897c1447 Fix deserializing to a smaller inventory
Void excess slots when deserializing an inventory to a target that has
fewer slots than what was serialized.  This fixs inventory sync failing
when a stored inventory has trash slots in use but the player does not
have the slots unlocked.
2022-06-08 22:26:08 +02:00
Hornwitser 2a221456d9 Fix exported data loading in web interface
Store exported data files with a hash in the filename to allow the new
caching stategy for static files to work with it and provide a manifest
for the web interface to find the exported files with.
2022-06-08 21:01:36 +02:00
Hornwitser 48a57bf16c Use name of the cluster as web title
Set the title attribute of the web interface page to the name of the
cluster.
2022-06-08 21:01:36 +02:00
Hornwitser 8742b4127c Improve pre-loaded feedback of website
Render the web interface as early as possible with a black background
and show a message to indicate whether the interface is actually loading
or if an error occured loading the main bundle.
2022-06-08 21:01:36 +02:00
Hornwitser e32637503d Move all web assets to shared cached space
Put all web assets including plugin assets under a shared static/ route
in the web interface that's declared as immutable and cacheable for 1
year.  This allows all JavaScript bundles to be loaded from disk instead
of having to check with the web server each time and significantly
speeds up the web interface load time.  It also fixes the public path
issue when using the --dev-plugin option behind a proxy.

To prevent collisions and stale data all assets now include a hash in
the filename.  This means the entrypoint is now dynamic and specified in
a manifest.json file created from the web build.  For plugins this means
the filename attribute needs to be removed from webpack.config.js.
2022-06-08 21:01:36 +02:00
Hornwitser 2401fe25fa Remove test package
Mocha's recursive search feature finds all files and then removes paths
that match the exclution filter, except that the globstar filter doesn't
match directories with a dot in them which is created by pnpm.  Avoid
this madness by removing the test package.
2022-06-08 16:48:40 +02:00
Hornwitser f0b60119cb Add pnpm to GitHub Action
Based on https://pnpm.io/continuous-integration#github-actions
2022-06-05 17:00:21 +02:00
Hornwitser 4d5597a1da Use prepare instead of build as script
The prepare script runs before publishing a package, after installation
of a package from source, and when running npm install without
arguments in the repository.  These are the times where a build of the
web interface files are required to make the web interface work and
removes a manual step from using the package(s). The "build" script on
the other hand is never automatically invoked by npm.
2022-06-05 16:13:53 +02:00
Hornwitser 2973485321 Replace npm and lerna with pnpm
Save disk space, time and sanity by replacing npm and lerna with pnpm.
This is more performant and doesn't have developer experience ruining
bugs like npm link removing all previous links before creating the
requested one.
2022-06-05 16:13:53 +02:00
Hornwitser 1e4292876c v2.0.0-alpha.12 2022-04-22 12:36:00 +02:00
Hornwitser ca6464baeb Merge pull request #456 from Hornwitser/save-transfer
Save transfer
2022-03-30 22:51:04 +02:00
Hornwitser c2cb58d088 Comment out command logging in tests
Don't log commands executed while running tests as these spam the output
more than they are useful right now.
2022-03-29 22:55:13 +02:00
Hornwitser 38c249ec70 Implement save transfer
Add ability to move or copy saves between instances.  For instances on
the same slave the saves are transferred with filesystem operations, for
instances on two different slaves the proxy stream interface is used to
send the save via the master server.
2022-03-28 20:42:08 +02:00
Hornwitser 2e1c1fd3c8 Remove duplicated instance check
Deduplicate instance existence check for requests in master.
2022-03-28 20:42:08 +02:00
Hornwitser 4edc7a2949 Implement rename and copy save
Add ability to rename and copy existing saves on an instance.
2022-03-27 19:59:29 +02:00
Hornwitser 9b5c6f8554 Remove duplicated request checks
Move duplicated checking of save filenames and instance existing from
requests into separate functions.
2022-03-27 18:50:15 +02:00
Hornwitser 0de964ec02 Reorganise instance save commands
Move create, list, upload and dalete save management commands to its own
'instance save' namespace in the command line interface.
2022-03-27 15:50:42 +02:00
Hornwitser 7db091d427 Simplify wording of save permissions
Use shorter and more consise titles for the save management related
permissions.
2022-03-27 15:39:40 +02:00
Hornwitser 9cf7dea579 Add mods for subspace_storage in GitHub Actions (#455)
The GitHub Actions workflow was missing the mods needed to do the tests
for subspace_storage, causing coverage to be lower than it should be.
2022-03-27 13:16:12 +02:00
Hornwitser 9465a488cc Migrate coverage reporting to GitHub Actions (#454)
Travis seems to be going away from supporting Open Source Software for
free and their proceedure to gaining free tokens is getting harder and
hander to find for each year that passes.  Remove Travis CI integration
and migrate the coverage reporting previously provided by the Travis
build over to the GitHub Actions.
2022-03-26 21:23:19 +01:00
Hornwitser 5e94198216 Merge pull request #453 from Hornwitser/small-things-7
Small things 7
2022-03-26 21:22:35 +01:00
Hornwitser 854d2b3c79 Suggested grammar fix 2022-03-26 20:27:59 +01:00
Hornwitser 946847c95d Send save list update after manual save
Trigger a save list update in the event an admin manually saves the game
with the /server-save command.
2022-03-25 23:15:07 +01:00
Hornwitser ce95f868fd Fix curently typo 2022-03-25 23:15:07 +01:00
Hornwitser e5a63a3508 Add start-stop button to instance list 2022-03-25 23:15:07 +01:00
Hornwitser 1e4d709a06 Implement kill instance
Add ability to kill running instances in case it is desirable to stop
the server without saving over the current save file.
2022-03-25 20:52:31 +01:00
Hornwitser a93f048814 Add support for sidebar groups
Allow sidebar entries to be grouped together by specifiying a name under
which they will be grouped.  To avoid confusion groups are rendered at
the bottom of the list.
2022-02-10 14:51:51 +01:00
Hornwitser 5319ff3907 Add hook to add extra content to Web UI pages
Allow plugins to extend Web UI pages provided by Clusterio by
adding components in the componentExtra property of the plugin.
2022-02-10 14:51:51 +01:00
Hornwitser 68b8ac6b8c Add onMasterConfigFieldChanged hook
Add hook for master plugin code to react to the master config changing.
2022-02-10 10:29:47 +01:00
Arvids Godjuks a979c0cfac Merge pull request #446 from jake-87/ctl-docs
Added more documentation for managing-a-cluster.md
2022-01-19 13:39:45 +02:00
J87 3da8c30a64 Remove unnecessary information 2022-01-11 19:55:18 +11:00
J87 f8c72a1e0a Fixed wording issue 2022-01-10 12:37:02 +11:00
Hornwitser 4a059efe18 Merge pull request #447 from Hornwitser/lgtm-alerts
Fix issues detected by lgtm.com
2022-01-09 19:06:38 +01:00
Hornwitser dec5467538 Use anonymous master metrics
Convert the master metrics to void instead of storing them in a variable
as they are completely self contained, and this makes it more clear that
there are no use of references to the metrics.
2022-01-05 19:45:24 +01:00
Hornwitser 61cd5bd76c Fix issues detected by lgtm.com 2022-01-03 19:45:46 +01:00
jake-87 1b1524e787 Fix poor wording 2022-01-02 19:23:54 +11:00
jake-87 488d81f424 Updated wording in docs
Also added more information on the banned-bulk-import option.
2022-01-02 19:16:23 +11:00
jake-87 8ecc6d1c83 Added instance and user documentation 2021-12-10 13:01:03 +11:00
125 changed files with 6246 additions and 3640 deletions

No files matched your search

+1 -4
View File
@@ -6,10 +6,7 @@ module.exports = {
"env": {
"node": true,
"commonjs": true,
"es6": true,
},
"parserOptions": {
"ecmaVersion": 11,
"es2022": true,
},
"overrides": [
+22 -7
View File
@@ -17,23 +17,38 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
with:
version: 6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: npm i --no-optional
- run: pnpm i --no-optional --no-frozen-lockfile
- 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
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- run: wget -q -O temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- name: Run tests
if: ${{ matrix.node-version != '12.x' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '12.x' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '12.x' }}
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
with:
version: 6
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: npm i --no-optional
- run: npx lerna bootstrap --hoist
- run: npm run lint
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm run lint
+2 -2
View File
@@ -38,9 +38,9 @@ Factorio*
/config-master.json
/config-control.json
/config-slave.json
package-lock.json
pnpm-lock.yaml
mod_cache.json
static/export
/static/
saves
/temp/
-15
View File
@@ -1,15 +0,0 @@
language: node_js
node_js:
- 12
before_install:
- curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
- tar -xf factorio.tar.gz
- npm install -g codecov
script:
- npx lerna bootstrap --hoist --no-ci
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- npx lerna run build
- npm run-script ci-cover
- codecov -f coverage/lcov.info
- npm run-script lint
+1 -2
View File
@@ -19,7 +19,7 @@
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
- Added centralized logging for the cluster.
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
- Added remote save management with the ability to list, create, upload, download, delete and load saves for instances.
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
### Features
@@ -86,7 +86,6 @@
Breaks playerManager.
- The masterIP and masterPort config entries has been merged into masterURL.
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
- Instance names can no longer be invalid Windows file names.
- Removed config management from the command line and the server manager.
- Moved ejs templates into views folder and changed their extension to .ejs.
Breaks playerManager.
+5 -10
View File
@@ -15,18 +15,15 @@ RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/head
# Copy project files into the container
COPY . .
RUN npm install \
&& npx lerna bootstrap --hoist
RUN pnpm install
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
# If you want a different set of plugins, consider using this as the base image for your own.
#RUN npm install @clusterio/plugin-subspace_storage
#RUN pnpm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
# Build submodules (web UI, libraries, plugins etc)
RUN npx lerna run build
# Build Lua library mod
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
&& mv dist/* sharedMods/ \
@@ -46,8 +43,7 @@ COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN npm install --production
RUN npx lerna bootstrap -- --production --no-optional
RUN pnpm install --production
LABEL maintainer "danielv@danielv.no"
FROM frolvlad/alpine-glibc AS clusterio_testing
@@ -58,6 +54,5 @@ COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN npm install
RUN npx lerna bootstrap
RUN npm install chalk semver
RUN pnpm install
RUN pnpm install chalk semver
+3 -10
View File
@@ -105,7 +105,7 @@ Otherwise see below for OS specific instructions.
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt install nodejs
2. Create a new directory and run the Clusterio installer:
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
mkdir clusterio
cd clusterio
@@ -113,19 +113,12 @@ Otherwise see below for OS specific instructions.
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation then download the headless build of Factorio and unpack it:
wget -O factorio.tar.xz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.xz
To specify a version of Factorio to download replace "latest" in the URL with a version number like "1.0.0".
4. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
3. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
**Ubuntu with Docker**
The Docker support for Clusterio is curently broken.
The Docker support for Clusterio is currently broken.
If you need it open an issue about it.
<!--
+13
View File
@@ -271,6 +271,19 @@ If null a random secure password is generated and used each time the instance st
Defaults to null.
### player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
Defaults to 5.
### factorio.enable_save_patching
Whether to use save patching or not.
+12 -11
View File
@@ -49,12 +49,10 @@ Do not run `git pull upstream` it will mess thing up!
### Setting up the Development Environment
The project is organized into several packages that are all managed through Lerna.
In order to get the development environment up and running you will need to run the following commands:
The project is organized into several packages that are all tied together using pnpm's workspace feature.
In order to get the development environment up and running you will need to run the following command:
npm install
npx lerna bootstrap --hoist
npx lerna run build
pnpm install
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
@@ -80,14 +78,15 @@ Note that to use `clusterioctl` you will have to create a control config first:
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
For web development on the master there are also the following flags.
For web development on the master there are also the following flags:
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
These options can be combined and multiple plugins can be actived for dev mode.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `npx lerna run build` before you start the master without these flags again.
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the master without these flags again.
### Starting a Feature Branch
@@ -113,8 +112,8 @@ But it's nice to have its name be somewhat descriptive of what it implements.
There's automated tests and linting setup for the project.
Please do add tests for any code you add and run both:
npm run test
npm run lint
pnpm run test
pnpm run lint
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
@@ -183,11 +182,11 @@ The format of this log should be self explanatory, please follow it carefully.
## Supported Node.js and Factorio Versions
Clusterio run on Node.js v12 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The Travis CI tests are runned on whatever version of Node.js v12 is the default.
The GitHub Actions tests are run on whatever version of Node.js v12, v14, and v16 is the default.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The Travis CI tests are runned against latest experimental release.
The GitHub Actions tests are run against the latest experimental release.
## Code Style
@@ -272,6 +271,8 @@ Headings use the `# heading` format.
## Pushing releases to npm
***Warning: This workflow is outdated due to lerna having been removed from the project.****
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
+2
View File
@@ -13,7 +13,9 @@
"include": [
"packages/master/src/",
"packages/master/master.js",
"packages/slave/src/",
"packages/slave/slave.js",
"packages/ctl/src/",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
+101 -1
View File
@@ -10,13 +10,86 @@ The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-c
## Slaves
To be written.
### List slaves
ctl> slave list
Lists all slaves.
### Generate token
ctl> slave generate-token [--id <slave id>]
Generate a token for a slave. `--id` is the slave id.
### Create config
ctl> slave create-config [--id <slave id>] [--name <slave name>] [--generate-token <bool>] [--output <path to output>]
Create a slave config. `--id` id the slave id. `--name` is the slave name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-slave.json", "-" for stdout.
## Instances
### List Instances
ctl> instance list
List instances.
### Create instance
ctl> instance create <name> [--id <instance id>]
Create a new instance. `name` is the instance name. `--id` is the optional id.
### Kill instance
ctl> instance kill <name>
Terminates the running Factorio server without giving it a chance to save or cleanup after itself.
Note: This may cause loss of data.
### Extract players from instance
ctl> instance extract-players <name>
Creates a user account in the cluster for each player that has been online on the currently running save of the instance and sets the online time of that account on this instance to the online time recorded in the save.
Useful when importing a save to a cluster.
Note that the online time in a save is recorded differntly from how Clusterio records online time.
Most notably Clusterio records wall clock time while the save time is recorded in ticks and this extraction of player time assumes the game runs at 60 ticks a second.
### Config management
#### List Configuration
ctl> instance config list <instance>
List config values for an instance. `instance` is the instance whose config values to list.
#### Set config value
ctl> instance config set <instance> <field> [value] [--stdin]
Set field in instance config. `instance` is instance to set. `field` is field to set. `value` is value to set in field. `--stdin` signals to read value from stdin.
#### Set config property of field
To be written.
#### Edit instance config
ctl> instance config edit <instance> [editor]
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
### Assign instance to slave
ctl> instace assign <instance> [slave]
Assigns instance to slave. `instance` is instance to assign, `slave` is slave to assign to. If `slave` is not provided, instance will be unassigned.
## Users
@@ -74,6 +147,33 @@ Since ban status is a part of the account data the account must exist for this t
Note: This bans the user from logging in to Factorio servers in the cluster, it does not revoke access to any cluster management they might have, see next section on setting roles for revoking tha.
### Bulk importing admin, whitelist and ban lists
Whitelists and admin lists can be bulk imported with the following commands:
ctl> user whitelist-bulk-import <file> [--create]
ctl> user admin-bulk-import <file> [--create]
The file should simply be newline seperated names:
joe
bob
allan
The `--create` option will create the user if they do not exist, and then set the relevent value.
Banlists can be bulk imported with the following command:
ctl> user banned-bulk-import <file> [--create]
The file should be names, followed by a ban reason:
joe greifed copper mines
bob stole materials
allan rude language
The first string on the line is used as the username, the others are used as the ban reason.
The `--create` option will create the user if they do not exist, then ban them.
### Set Cluster Roles
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!README.md
+4
View File
@@ -0,0 +1,4 @@
# External plugins directory
Plugins placed here will have their npm dependencies incorporated in the shared pnpm storage for this repository when `pnpm install` is invoked.
Use this to enable `--dev-plugin` to work with plugins developed outside of the Clusterio repository.
-8
View File
@@ -1,8 +0,0 @@
{
"packages": [
"packages/*",
"plugins/*",
"test"
],
"version": "2.0.0-alpha.11"
}
+19 -9
View File
@@ -2,11 +2,11 @@
"name": "root",
"private": true,
"scripts": {
"test": "mocha --check-leaks -R spec --exclude \"**/node_modules/**\" --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
"ci-cover": "nyc --reporter=lcovonly npm run-script test",
"test": "mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint \"*.js\" packages plugins test",
"docs": "jsdoc --configure docs/jsdoc.json",
"bundle-dependencies": "bundle-dependencies"
@@ -21,11 +21,21 @@
"node": ">=12"
},
"devDependencies": {
"eslint": "^7.25.0",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/master": "workspace:*",
"@clusterio/slave": "workspace:*",
"eslint": "^8.18.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.16.2",
"form-data": "^4.0.0",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.6",
"lerna": "^4.0.0",
"mocha": "^8.4.0",
"nyc": "^15.1.0"
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"mocha": "^9.2.2",
"node-forge": "^1.3.1",
"nyc": "^15.1.0",
"phin": "^3.5.1"
}
}
+30 -4
View File
@@ -2,7 +2,6 @@
"use strict";
const child_process = require("child_process");
const events = require("events");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
@@ -94,7 +93,6 @@ async function safeOutputFile(file, data, options={}) {
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const asyncExec = util.promisify(child_process.execFile);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
if (err) {
@@ -170,6 +168,19 @@ async function validateInstallDir() {
}
}
async function validateNotRoot(args) {
if (args.allowInstallAsRoot) {
return;
}
if (os.userInfo().uid === 0) {
throw new InstallError(
"Refusing to install as root. Create a separate user account for clusterio " +
"and then use su/sudo to switch to it before invoking the installer."
);
}
}
async function installClusterio(mode, plugins) {
try {
await safeOutputFile("package.json", JSON.stringify({
@@ -227,6 +238,17 @@ async function installClusterio(mode, plugins) {
}
}
async function groupIdToName(gid) {
try {
let exec = util.promisify(child_process.exec);
let { stdout } = await exec(`getent group ${gid}`);
return stdout.split(":")[0];
} catch (err) {
logger.warn(`getent group ${gid} failed: ${err.message}`);
return gid;
}
}
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (process.platform === "win32") {
@@ -247,7 +269,7 @@ Description=Clusterio Master
[Service]
User=${os.userInfo().username}
Group=nogroup
Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
@@ -278,7 +300,7 @@ Description=Clusterio Slave
[Service]
User=${os.userInfo().username}
Group=nogroup
Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
@@ -538,6 +560,9 @@ async function main() {
if (process.platform === "linux") {
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
@@ -554,6 +579,7 @@ async function main() {
if (!dev) {
await validateInstallDir();
await validateNotRoot(args);
}
let answers = await inquirerMissingArgs(args);
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+4 -1065
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"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.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"as-table": "^1.0.31",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
"use strict";
/**
* Stastics collected about a player.
* @alias module:lib/PlayerStats
*/
class PlayerStats {
/**
* Count of the number of times this player has been seen joining this server.
* @type {number}
*/
joinCount = 0;
/**
* Time in ms this player has been seen online on this instance.
* @type {number}
*/
onlineTimeMs = 0;
/**
* Timestamp the player was last seen joining.
* @type {Date=}
*/
lastJoinAt;
/**
* Timestamp the player was last seen leaving.
* @type {Date=}
*/
lastLeaveAt;
/**
* Reason the player was last seen leaving with.
* @type {string=}
*/
lastLeaveReason;
static jsonSchema = {
type: "object",
properties: {
"join_count": { type: "integer" },
"online_time_ms": { type: "number" },
"last_join_at": { type: "number" },
"last_leave_at": { type: "number" },
"last_leave_reason": { type: "string" },
},
};
constructor(json = {}) {
if (json["join_count"]) {
this.joinCount = json["join_count"];
}
if (json["online_time_ms"]) {
this.onlineTimeMs = json["online_time_ms"];
}
if (json["last_join_at"]) {
this.lastJoinAt = new Date(json["last_join_at"]);
}
if (json["last_leave_at"]) {
this.lastLeaveAt = new Date(json["last_leave_at"]);
this.lastLeaveReason = json["last_leave_reason"] || "quit";
}
}
toJSON() {
let json = {};
if (this.joinCount) {
json["join_count"] = this.joinCount;
}
if (this.onlineTimeMs) {
json["online_time_ms"] = this.onlineTimeMs;
}
if (this.lastJoinAt) {
json["last_join_at"] = this.lastJoinAt.getTime();
}
if (this.lastLeaveAt) {
json["last_leave_at"] = this.lastLeaveAt.getTime();
json["last_leave_reason"] = this.lastLeaveReason;
}
return json;
}
}
module.exports = PlayerStats;
+2 -2
View File
@@ -83,7 +83,7 @@ async function build(args) {
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let [, name, version] = match;
let name = match[1];
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
@@ -94,7 +94,7 @@ async function build(args) {
}
if (info.variants) {
for (let [variant, variantOverrides] of Object.entries(info.variants)) {
for (let variantOverrides of info.variants) {
let variantInfo = {
...info,
...variantOverrides,
+16 -2
View File
@@ -265,6 +265,14 @@ FactorioGroup.define({
type: "string",
optional: true,
});
FactorioGroup.define({
name: "player_online_autosave_slots",
description:
"Rename autosaves where players have been online since the previous autosave into a separate autosave " +
"pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initial_value: 5,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
@@ -277,6 +285,12 @@ FactorioGroup.define({
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "enable_authserver_bans",
description: "Turn on Factorio.com based multiplayer bans.",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
@@ -376,9 +390,9 @@ ControlConfig.registerGroup(ControlGroup);
function validateGroup(pluginInfo, groupName) {
if (!pluginInfo[groupName] instanceof classes.PluginConfigGroup) {
if (!(pluginInfo[groupName].prototype instanceof classes.PluginConfigGroup)) {
throw new Error(
`Expected ${groupName} for ${pluginInfo.name} to be an instance of PluginConfigGroup`
`Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup`
);
}
+36 -17
View File
@@ -409,6 +409,7 @@ const outputHeuristics = [
* - game-ready - invoked when the server is finished starting up
* - autosave-start - invoked when the server starts an autosave
* - autosave-fnished - invoked when the autosave finished
* - save-finished - invoked when the server has finished a manual save
* - exit - invoked when the sterver has exited
* @extends events.EventEmitter
* @memberof module:lib/factorio
@@ -428,6 +429,8 @@ class FactorioServer extends events.EventEmitter {
* @param {number} options.rconPort - TCP port to use for RCON.
* @param {string} options.rconPassword - Password use for RCON.
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
* @param {boolean} options.enableAuthserverBans -
* Enable Factorio.com based multiplayer bans.
* @param {boolean} options.verboseLogging - Enable verbose logging.
* @param {boolean} options.stripPaths - Strip paths in the console.
* @param {number} options.maxConcurrentCommands -
@@ -456,6 +459,8 @@ class FactorioServer extends events.EventEmitter {
this.rconPassword = options.rconPassword;
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist;
/** Enable Factorio.com based multiplayer bans **/
this.enableAuthserverBans = options.enableAuthserverBans;
/** Enable verbose logging */
this.verboseLogging = options.verboseLogging;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
@@ -487,6 +492,7 @@ class FactorioServer extends events.EventEmitter {
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
this._killed = false;
// Track autosaving
this._runningAutosave = null;
@@ -499,6 +505,8 @@ class FactorioServer extends events.EventEmitter {
if (this._runningAutosave) {
this.emit("autosave-finished", this._runningAutosave);
this._runningAutosave = null;
} else {
this.emit("save-finished");
}
});
}
@@ -694,28 +702,30 @@ class FactorioServer extends events.EventEmitter {
_watchExit() {
this._server.on("exit", (code, signal) => {
if (this._state !== "stopping") {
if (this._unexpected.length === 0) {
if (signal === "SIGKILL") {
if (signal === "SIGKILL") {
if (this._killed) {
this.emit("error", new libErrors.EnvironmentError("Factorio server was killed"));
} else {
this.emit("error", new libErrors.EnvironmentError(
"Factorio server was unexpectedly killed, is the system low on memory?"
));
} else {
let msg;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
msg = `Factorio server was unexpectedly shut by down by signal ${signal}`;
}
this.emit("error", new Error(msg));
}
} else if (this._unexpected.length === 0) {
let msg;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
msg = `Factorio server was unexpectedly shut by down by signal ${signal}`;
}
this.emit("error", new libErrors.EnvironmentError(msg));
} else if (this._unexpected.length === 1) {
this.emit("error", new Error(this._unexpected[0]));
this.emit("error", new libErrors.EnvironmentError(this._unexpected[0]));
} else {
this.emit("error", new Error(
this.emit("error", new libErrors.EnvironmentError(
"Factorio server unexpectedly shut down. Possible causes:"+
`\n- ${this._unexpected.join("\n- ")}`
));
@@ -791,6 +801,7 @@ class FactorioServer extends events.EventEmitter {
try {
let [code, signal] = await events.once(this._server, "exit");
void signal;
if (code !== 0) {
throw new Error(`Factorio exited with status ${code}`);
}
@@ -821,6 +832,7 @@ class FactorioServer extends events.EventEmitter {
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
@@ -865,6 +877,7 @@ class FactorioServer extends events.EventEmitter {
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
@@ -1003,12 +1016,18 @@ class FactorioServer extends events.EventEmitter {
* Kill the server
*
* Terminates the server without any cleanup or saving.
* @param {boolean} [unexpected=false] -
* If true raise an error event as a result of killing the Factorio
* process.
*/
async kill() {
async kill(unexpected = false) {
this._check(["running", "stopping"]);
this._state = "stopping";
this._killed = true;
if (!unexpected) {
this._state = "stopping";
}
this._server.kill("SIGKILL");
await events.once(this._server, "exit");
await new Promise(resolve => this._server.once("exit", resolve));
}
/**
+1 -1
View File
@@ -445,7 +445,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
return;
}
let { seq, type, data } = message;
let { type, data } = message;
if (type === "hello") {
logger.verbose(`Connector | received hello from master version ${data.version}`);
this.emit("hello", data);
+1 -2
View File
@@ -4,7 +4,6 @@
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const messages = require("./messages");
// Some definitions for the terminology used here:
// link: Either side of a master - client connection
@@ -135,7 +134,7 @@ class Link {
* @returns {Boolean} true if a waiter was triggered.
*/
_processWaiters(message) {
let { seq, type, data } = message;
let { type, data } = message;
let waiters = this._waiters.get(type);
if (!waiters || !waiters.length) {
return false;
+110 -10
View File
@@ -3,6 +3,7 @@
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const PlayerStats = require("../PlayerStats");
class MissingLinkHandlerError extends Error {
constructor(type, source, target) {
@@ -550,6 +551,28 @@ messages.createSave = new Request({
},
});
messages.renameSave = new Request({
type: "rename_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.rename",
forwardTo: "instance",
requestProperties: {
"old_name": { type: "string" },
"new_name": { type: "string" },
},
});
messages.copySave = new Request({
type: "copy_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.copy",
forwardTo: "instance",
requestProperties: {
"source": { type: "string" },
"destination": { type: "string" },
},
});
messages.deleteSave = new Request({
type: "delete_save",
links: ["control-master", "master-slave"],
@@ -573,6 +596,22 @@ messages.downloadSave = new Request({
},
});
messages.transferSave = new Request({
type: "transfer_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.transfer",
requestProperties: {
"instance_id": { type: "integer" },
"source_save": { type: "string" },
"target_instance_id": { type: "integer" },
"target_save": { type: "string" },
"copy": { type: "boolean" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.pullSave = new Request({
type: "pull_save",
links: ["master-slave"],
@@ -629,6 +668,13 @@ messages.exportData = new Request({
forwardTo: "instance",
});
messages.extractPlayers = new Request({
type: "extract_players",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.extract_players",
forwardTo: "instance",
});
messages.stopInstance = new Request({
type: "stop_instance",
links: ["control-master", "master-slave", "slave-instance"],
@@ -636,6 +682,13 @@ messages.stopInstance = new Request({
forwardTo: "instance",
});
messages.killInstance = new Request({
type: "kill_instance",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.kill",
forwardTo: "instance",
});
messages.deleteInstance = new Request({
type: "delete_instance",
links: ["control-master", "master-slave"],
@@ -741,6 +794,37 @@ messages.deleteRole = new Request({
},
});
const userRequired = ["name", "roles", "instances"];
const userProperties = {
"name": { type: "string" },
"roles": { type: "array", items: { type: "integer" }},
"is_admin": { type: "boolean" },
"is_banned": { type: "boolean" },
"is_whitelisted": { type: "boolean" },
"ban_reason": { type: "string" },
"instances": { type: "array", items: { type: "integer" }},
"is_deleted": { type: "boolean" },
"player_stats": PlayerStats.jsonSchema,
"instance_stats": {
type: "array",
items: {
type: "array",
items: [{ type: "integer" }, PlayerStats.jsonSchema],
},
},
};
messages.getUser = new Request({
type: "get_user",
links: ["control-master"],
permission: "core.user.get",
requestProperties: {
"name": { type: "string" },
},
responseRequired: userRequired,
responseProperties: userProperties,
});
messages.listUsers = new Request({
type: "list_users",
links: ["control-master"],
@@ -750,20 +834,26 @@ messages.listUsers = new Request({
type: "array",
items: {
additionalProperties: false,
required: ["name", "roles", "instances"],
properties: {
"name": { type: "string" },
"roles": { type: "array", items: { type: "integer" }},
"is_admin": { type: "boolean" },
"is_banned": { type: "boolean" },
"is_whitelisted": { type: "boolean" },
"instances": { type: "array", items: { type: "integer" }},
},
required: userRequired,
properties: userProperties,
},
},
},
});
messages.setUserSubscriptions = new Request({
type: "set_user_subscriptions",
links: ["control-master"],
permission: "core.user.subscribe",
requestProperties: {
"all": { type: "boolean" },
"names": {
type: "array",
items: { type: "string" },
},
},
});
messages.createUser = new Request({
type: "create_user",
links: ["control-master"],
@@ -1173,6 +1263,13 @@ messages.saveListUpdate = new Event({
},
});
messages.userUpdate = new Event({
type: "user_update",
links: ["master-control"],
eventRequired: userRequired,
eventProperties: userProperties,
});
messages.masterConnectionEvent = new Event({
type: "master_connection_event",
links: ["slave-instance"],
@@ -1239,10 +1336,13 @@ messages.playerEvent = new Event({
type: "player_event",
links: ["instance-slave", "slave-master"],
forwardTo: "master",
eventRequired: ["instance_id", "type", "name"],
eventProperties: {
"instance_id": { type: "integer" },
"type": { type: "string", enum: ["join", "leave"] },
"type": { type: "string", enum: ["join", "leave", "import"] },
"name": { type: "string" },
"reason": { type: "string" },
"stats": PlayerStats.jsonSchema,
},
});
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"browser": "browser.js",
@@ -14,6 +14,7 @@
"dependencies": {
"ajv": "^6.12.6",
"chalk": "^4.1.0",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^8.1.0",
"isomorphic-ws": "^4.0.1",
"jimp": "^0.12.1",
+67 -7
View File
@@ -39,7 +39,7 @@ class BaseInstancePlugin {
/**
* Instance the plugin started for
* @type {module:slave/slave~Instance}
* @type {module:slave/src/Instance}
*/
this.instance = instance;
@@ -49,7 +49,7 @@ class BaseInstancePlugin {
* With the exepction of accessing the slave's config you should
* avoid ineracting with the slave object directly.
*
* @type {module:slave/slave~Slave}
* @type {module:slave/src/Slave}
*/
this.slave = slave;
@@ -78,7 +78,7 @@ class BaseInstancePlugin {
* changed.
*
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed on.
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
@@ -206,8 +206,12 @@ class BaseInstancePlugin {
* Invoked when a player either joins or leaves the instance.
*
* @param {Object} event - Information about the event.
* @param {string} event.type - Either "join" or "leave".
* @param {string} event.type - Either "join", "leave" or "import".
* @param {string} event.name - Name of the player that joined/left.
* @param {string=} event.reason -
* Only present for type "leave". Reason for player leaving the
* game, one of the possible reasons in defines.disconnect_reason
* or "server_quit" if the server exits while the player is online.
*/
async onPlayerEvent(event) { }
@@ -355,6 +359,19 @@ class BaseMasterPlugin {
*/
async onInstanceStatusChanged(instance, prev) { }
/**
* Called when the value of a master config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on the master.
*
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
async onMasterConfigFieldChanged(group, field, prev) { }
/**
* Called when the value of an instance config field changed.
*
@@ -363,7 +380,7 @@ class BaseMasterPlugin {
*
* @param {Object} instance - The instance the config changed on.
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed on.
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
@@ -485,8 +502,12 @@ class BaseMasterPlugin {
*
* @param {Object} instance - the instance it occured on.
* @param {Object} event - Information about the event.
* @param {string} event.type - Either "join" or "leave".
* @param {string} event.type - Either "join", "leave" or "import".
* @param {string} event.name - Name of the player that joined/left.
* @param {string=} event.reason -
* Only present for type "leave". Reason for player leaving the
* game, one of the possible reasons in defines.disconnect_reason
* or "server_quit" if the server exits while the player is online.
*/
async onPlayerEvent(instance, event) { }
@@ -575,11 +596,14 @@ class BaseControlPlugin {
/**
* Plugin supplied pages
* @typedef {Object} module:Lib/plugin~Page
* @typedef {Object} module:lib/plugin~Page
* @property {string} path - URL path to this page.
* @property {string=} sidebarPath -
* If present and this path matches one of the pages in the sidebar it
* will cause that sidebar entry to be highlighted as active.
* @property {string=} sidebarGroup -
* If present group this entry under a group of the given name in the
* sidebar.
* @property {string=} sidebarName -
* If present creates an entry in the sidebar for this page with the
* given text.
@@ -640,6 +664,42 @@ class BaseWebPlugin {
* @type {Array<module:lib/plugin~Page>}
*/
this.pages = [];
/**
* Extra react component to add to core components
*
* Interface to augment core components of the web UI. Setting a
* component as one of the supported properties of this object will
* cause the web UI to render it when displaying that component,
* usually at the end. Each component will receive a `plugin` param
* which is the instance of the web plugin that contained the
* component extra.
*
* @type {object}
* @property {React.ComponentType} MasterPage -
* Placed at the end of the master page.
* @property {React.ComponentType} SlavesPage -
* Placed at the end of the slaves list page.
* @property {React.ComponentType} SlaveViewPage -
* Placed at the end of each slave page. Takes a `slave` param
* which is the slave the page is displayed for.
* @property {React.ComponentType} InstancesPage -
* Placed at the end of the instance list page.
* @property {React.ComponentType} InstanceViewPage -
* Placed at the end of each instance page. Takes an `instance`
* param which is the instance the page is displayed for.
* @property {React.ComponentType} UsersPage -
* Placed at the end of the users list page.
* @property {React.ComponentType} UserViewPage -
* Placed at the end of each user page. Takes a `user` param
* which is the user object the page is displayed for.
* @property {React.ComponentType} RolesPage -
* Placed at the end of the roles list page.
* @property {React.ComponentType} RoleViewPage -
* Placed at the end of each role page. Takes a `role` param
* which is the role object the page is displayed for.
*/
this.componentExtra = {};
}
/**
-1
View File
@@ -3,7 +3,6 @@
* @module lib/shared_commands
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
+88 -5
View File
@@ -11,6 +11,8 @@ const jwt = require("jsonwebtoken");
const libErrors = require("./errors");
const PlayerStats = require("./PlayerStats");
/**
* Represents a permission that can be granted
@@ -195,6 +197,28 @@ class User {
}
}
}
/**
* Per instance statistics for the player this user account is tied to.
* @type {Map<number, module:lib/PlayerStats>}
*/
this.instanceStats = new Map(
(serializedUser.instance_stats ? serializedUser.instance_stats : []).map(
([id, stats]) => [id, new PlayerStats(stats)]
)
);
/**
* Combined statistics for the player this user account is tied to.
* @type {module:lib/PlayerStats}
*/
this.playerStats = this._calculatePlayerStats();
/**
* True if this user object has been removed from the cluster.
* @type {boolean}
*/
this.isDeleted = false;
}
serialize() {
@@ -226,6 +250,10 @@ class User {
serialized.ban_reason = this.banReason;
}
if (this.instanceStats.size) {
serialized.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]);
}
return serialized;
}
@@ -285,6 +313,26 @@ class User {
User.onlineUsers.delete(this);
}
}
recalculatePlayerStats() {
this.playerStats = this._calculatePlayerStats();
}
_calculatePlayerStats() {
let playerStats = new PlayerStats();
for (let instanceStats of this.instanceStats.values()) {
if (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt) {
playerStats.lastJoinAt = instanceStats.lastJoinAt;
}
if (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt) {
playerStats.lastLeaveAt = instanceStats.lastLeaveAt;
playerStats.lastLeaveReason = instanceStats.lastLeaveReason;
}
playerStats.joinCount += instanceStats.joinCount;
playerStats.onlineTimeMs += instanceStats.onlineTimeMs;
}
return playerStats;
}
}
/**
* Set of users currently online in the cluster.
@@ -379,7 +427,7 @@ definePermission({
});
definePermission({
name: "core.instance.save.list",
title: "List saves on instance",
title: "List saves",
description: "List the saves currently on the instance.",
});
definePermission({
@@ -389,22 +437,37 @@ definePermission({
});
definePermission({
name: "core.instance.save.create",
title: "Create new instance save",
title: "Create new save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.rename",
title: "Rename save",
description: "Rename savegames on instances.",
});
definePermission({
name: "core.instance.save.copy",
title: "Copy save",
description: "Create copies of savegames on instances.",
});
definePermission({
name: "core.instance.save.delete",
title: "Delete instance save",
title: "Delete save",
description: "Delete savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save to instance",
title: "Upload save",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.transfer",
title: "Transfer save",
description: "Transfer savegames between instances.",
});
definePermission({
name: "core.instance.save.download",
title: "Download save from instance",
title: "Download save",
description: "Download savegames from instances.",
});
definePermission({
@@ -412,6 +475,11 @@ definePermission({
title: "Export locale and icons",
description: "Export the the locale and icons from an instance and upload it to the master.",
});
definePermission({
name: "core.instance.extract_players",
title: "Extract player stats from running save",
description: "Run extraction to create a user for each player in the save and set the online time from the save.",
});
definePermission({
name: "core.instance.start",
title: "Start instance",
@@ -427,6 +495,11 @@ definePermission({
title: "Stop instance",
description: "Stop instances.",
});
definePermission({
name: "core.instance.kill",
title: "Kill instance",
description: "Terminate running instances without saving or cleanup.",
});
definePermission({
name: "core.instance.delete",
title: "Delete instance",
@@ -467,11 +540,21 @@ definePermission({
description: "Permanently delete roles.",
});
definePermission({
name: "core.user.get",
title: "Get user",
description: "Get the details of a user in the cluster.",
});
definePermission({
name: "core.user.list",
title: "List users",
description: "Get the full list of users in the cluster.",
});
definePermission({
name: "core.user.subscribe",
title: "Subscribe to user updates",
description: "Subscribe to be notified on updates on the details and status of users.",
});
definePermission({
name: "core.user.create",
title: "Create users",
+12 -12
View File
@@ -43,18 +43,18 @@ const UserManager = require("./src/UserManager");
let master;
const slaveMappingGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_slave_mapping",
"Mapping of Slave ID to name",
{
labels: ["slave_id", "slave_name"],
callback: function() {
slaveMappingGauge.clear();
callback: function(gauge) {
gauge.clear();
if (!master || !master.slaves) {
return;
}
for (let [id, slave] of master.slaves) {
slaveMappingGauge.labels({
gauge.labels({
slave_id: String(id),
slave_name: slave.name,
}).set(1);
@@ -63,18 +63,18 @@ const slaveMappingGauge = new libPrometheus.Gauge(
}
);
const instanceMappingGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_instance_mapping",
"Mapping of Instance ID to name and slave",
{
labels: ["instance_id", "instance_name", "slave_id"],
callback: function() {
instanceMappingGauge.clear();
callback: function(gauge) {
gauge.clear();
if (!master || !master.instances) {
return;
}
for (let [id, instance] of master.instances) {
instanceMappingGauge.labels({
gauge.labels({
instance_id: String(id),
instance_name: String(instance.config.get("instance.name")),
slave_id: String(instance.config.get("instance.assigned_slave")),
@@ -84,19 +84,19 @@ const instanceMappingGauge = new libPrometheus.Gauge(
}
);
const wsActiveConnectionsGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_websocket_active_connections",
"How many WebSocket connections are currently open to the master server",
{ callback: function(gauge) { gauge.set(master.wsServer.activeConnectors.size); }}
);
const wsActiveSlavesGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_active_slaves",
"How many slaves are currently connected to the master",
{ callback: function(gauge) { gauge.set(master.wsServer.slaveConnections.size); }}
);
const masterConnectedClientsCount = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_connected_clients_count", "How many clients are currently connected to this master server",
{
labels: ["type"], callback: async function(gauge) {
@@ -356,7 +356,7 @@ async function startup() {
// add better stack traces on promise rejection
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
let { args, shouldRun, clusterLogger, pluginInfos, masterConfigPath, masterConfig } = await initialize(master);
let { args, shouldRun, clusterLogger, pluginInfos, masterConfigPath, masterConfig } = await initialize();
if (!shouldRun) {
return;
}
+7 -8
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env production",
"prepare": "webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -19,7 +19,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"express": "^4.16.2",
@@ -27,20 +27,19 @@
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"set-blocking": "^2.0.0",
"webpack-cli": "^4.9.2",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.5",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.11",
"fast-deep-equal": "^3.1.3",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-dev-middleware": "^4.1.0",
"webpack": "^5.73.0",
"webpack-dev-middleware": "^5.3.1",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+147 -31
View File
@@ -1,5 +1,6 @@
"use strict";
const jwt = require("jsonwebtoken");
const events = require("events");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
@@ -52,6 +53,11 @@ class ControlConnection extends BaseConnection {
instance_ids: [],
};
this.userSubscriptions = {
all: false,
names: [],
};
this.logTransport = null;
this.logSubscriptions = {
all: false,
@@ -159,10 +165,7 @@ class ControlConnection extends BaseConnection {
async getInstanceRequestHandler(message) {
let id = message.data.id;
let instance = this._master.instances.get(id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${id} does not exist`);
}
let instance = this._master.getRequestInstance(id);
return {
id,
@@ -249,11 +252,7 @@ class ControlConnection extends BaseConnection {
}
async deleteInstanceRequestHandler(message, request) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
if (instance.config.get("instance.assigned_slave") !== null) {
await this.forwardRequestToInstance(message, request);
}
@@ -266,11 +265,7 @@ class ControlConnection extends BaseConnection {
}
async getInstanceConfigRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
return {
serialized_config: instance.config.serialize("control"),
};
@@ -290,11 +285,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigFieldRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
if (message.data.field === "instance.assigned_slave") {
throw new libErrors.RequestError("instance.assigned_slave must be set through the assign-slave interface");
}
@@ -309,11 +300,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigPropRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
let { field, prop, value } = message.data;
instance.config.setProp(field, prop, value, "control");
await this.updateInstanceConfig(instance);
@@ -321,10 +308,7 @@ class ControlConnection extends BaseConnection {
async assignInstanceCommandRequestHandler(message, request) {
let { slave_id, instance_id } = message.data;
let instance = this._master.instances.get(instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(instance_id);
// Check if target slave is connected
let newSlaveConnection;
@@ -385,7 +369,7 @@ class ControlConnection extends BaseConnection {
});
ready.catch(() => {});
let result = await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save,
@@ -395,6 +379,85 @@ class ControlConnection extends BaseConnection {
return { stream_id: stream.id };
}
async transferSaveRequestHandler(message, request) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
if (copy) {
this.user.checkPermission("core.instance.save.copy");
} else if (source_save !== target_save) {
this.user.checkPermission("core.instance.save.rename");
}
if (instance_id === target_instance_id) {
throw new libErrors.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._master.getRequestInstance(instance_id);
let targetInstance = this._master.getRequestInstance(target_instance_id);
let sourceSlaveId = sourceInstance.config.get("instance.assigned_slave");
let targetSlaveId = targetInstance.config.get("instance.assigned_slave");
if (sourceSlaveId === null) {
throw new libErrors.RequestError("Source instance is not assigned a slave");
}
if (targetSlaveId === null) {
throw new libErrors.RequestError("Target instance is not assigned a slave");
}
// Let slave handle request if source and target is on the same slave.
if (sourceSlaveId === targetSlaveId) {
return await this.forwardRequestToInstance(message, request);
}
// Check connectivity
let sourceSlaveConnection = this._master.wsServer.slaveConnections.get(sourceSlaveId);
if (!sourceSlaveConnection || sourceSlaveConnection.closing) {
throw new libErrors.RequestError("Source slave is not connected to the master server");
}
let targetSlaveConnection = this._master.wsServer.slaveConnections.get(targetSlaveId);
if (!targetSlaveConnection || targetSlaveConnection.closing) {
throw new libErrors.RequestError("Target slave is not connected to the master server");
}
// Create stream to proxy from target to source
let stream = await routes.createProxyStream(this._master.app);
stream.events.on("timeout", () => {
if (stream.source) {
stream.source.destroy();
}
stream.events.emit("error", new libErrors.RequestError("Timed out establishing transfer stream"));
});
// Ignore errors if not listening for them to avoid crash.
stream.events.on("error", () => { /* ignore */ });
// Establish push from source slave to stream, this is done first to
// ensure the file size is known prior to the target slave pull.
await Promise.all([
this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save: source_save,
}),
events.once(stream.events, "source"),
]);
// Establish pull from target slave to stream and wait for completion.
let result = await this._master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: target_instance_id,
stream_id: stream.id,
filename: target_save,
});
// Delete source save if this is not a copy
if (!copy) {
await this._master.forwardRequestToInstance(libLink.messages.deleteSave, {
instance_id,
save: source_save,
});
}
return { save: result.save };
}
async setLogSubscriptionsRequestHandler(message) {
this.logSubscriptions = message.data;
this.updateLogSubscriptions();
@@ -501,6 +564,26 @@ class ControlConnection extends BaseConnection {
}
}
async getUserRequestHandler(message) {
let name = message.data.name;
let user = this._master.userManager.users.get(name);
if (!user) {
throw new libErrors.RequestError(`User ${name} does not exist`);
}
return {
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
ban_reason: user.banReason,
instances: [...user.instances],
player_stats: user.playerStats,
instance_stats: [...user.instanceStats],
};
}
async listUsersRequestHandler(message) {
let list = [];
for (let user of this._master.userManager.users.values()) {
@@ -511,13 +594,39 @@ class ControlConnection extends BaseConnection {
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
instances: [...user.instances],
player_stats: user.playerStats,
});
}
return { list };
}
async setUserSubscriptionsRequestHandler(message) {
this.userSubscriptions = message.data;
}
userUpdated(user) {
if (
this.userSubscriptions.all
|| this.userSubscriptions.names.includes(user.name)
) {
libLink.messages.userUpdate.send(this, {
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
ban_reason: user.banReason,
instances: [...user.instances],
player_stats: user.playerStats,
instance_stats: [...user.instanceStats],
is_deleted: user.isDeleted,
});
}
}
async createUserRequestHandler(message) {
this._master.userManager.createUser(message.data.name);
let user = this._master.userManager.createUser(message.data.name);
this._master.userUpdated(user);
}
async updateUserRolesRequestHandler(message) {
@@ -538,6 +647,7 @@ class ControlConnection extends BaseConnection {
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(user);
this._master.userUpdated(user);
}
async setUserAdminRequestHandler(message) {
@@ -553,6 +663,7 @@ class ControlConnection extends BaseConnection {
}
user.isAdmin = admin;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, admin }}, libLink.messages.adminlistUpdate);
}
@@ -570,6 +681,7 @@ class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, banned, reason }}, libLink.messages.banlistUpdate);
}
@@ -586,6 +698,7 @@ class ControlConnection extends BaseConnection {
}
user.isWhitelisted = whitelisted;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate);
}
@@ -595,6 +708,10 @@ class ControlConnection extends BaseConnection {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
user.isDeleted = true;
this._master.userManager.users.delete(message.data.name);
this._master.userUpdated(user);
if (user.is_admin) {
this.broadcastEventToSlaves({ data: { name, admin: false }}, libLink.messages.adminlistUpdate);
}
@@ -604,7 +721,6 @@ class ControlConnection extends BaseConnection {
if (user.is_banned) {
this.broadcastEventToSlaves({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate);
}
this._master.userManager.users.delete(message.data.name);
}
async debugDumpWsRequestHandler(message) {
-1
View File
@@ -1,7 +1,6 @@
"use strict";
const http = require("http");
const https = require("https");
const util = require("util");
/**
* Gracefully close a Node.js HTTP(S) server
+89 -13
View File
@@ -97,7 +97,7 @@ class Master {
}
async start(args) {
if (!this._state === "new") {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
}
@@ -137,20 +137,22 @@ class Master {
webpackConfigs.push(require("../webpack.config")({}));
}
if (args.devPlugin) {
let devPlugins = new Map();
for (let name of args.devPlugin) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new libErrors.StartupError(`No plugin named ${name}`);
}
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
config.output.publicPath = `/plugins/${name}/`;
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
}
this.app.locals.devPlugins = devPlugins;
}
/* eslint-enable node/global-require, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, {});
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
this.app.use(this.devMiddleware);
}
@@ -161,7 +163,11 @@ class Master {
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
this.exportManifest = await Master.loadJsonObject(path.join(databaseDirectory, "export_manifest.json"));
this.config.on("fieldChanged", (group, field, prev) => {
libPlugin.invokeHook(this.plugins, "onMasterConfigFieldChanged", group, field, prev);
});
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
@@ -187,6 +193,14 @@ class Master {
Master.addAppRoutes(this.app, this.pluginInfos);
if (!args.dev) {
let manifest = await Master.loadJsonObject(path.join(__dirname, "..", "dist", "web", "manifest.json"));
if (!manifest["main.js"]) {
logger.error("Missing main.js entry in dist/web/manifest.json");
}
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
}
// Load plugins
await this.loadPlugins();
@@ -346,6 +360,26 @@ class Master {
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static async loadJsonObject(filePath, throwOnMissing = false) {
let manifest = {};
try {
manifest = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (!throwOnMissing && err.code !== "ENOENT") {
throw err;
}
}
return manifest;
}
async updateExportManifest(newManifest) {
let databaseDirectory = this.config.get("master.database_directory");
await libFileOps.safeOutputFile(
path.join(databaseDirectory, "export_manifest.json"), JSON.stringify(newManifest, null, 4)
);
this.exportManifest = newManifest;
}
/**
* Query master log
*
@@ -381,8 +415,9 @@ class Master {
app.use(compression());
// Set folder to serve static content from (the website)
app.use(express.static(path.join(__dirname, "..", "dist", "web")));
app.use(express.static("static")); // Used for data export files
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
app.use("/static", express.static(path.join(__dirname, "..", "dist", "web", "static"), staticOptions));
app.use("/static", express.static("static", staticOptions)); // Used for data export files
// Add API routes
routes.addRouteHandlers(app);
@@ -397,8 +432,8 @@ class Master {
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web");
app.use(`/plugins/${pluginInfo.name}`, express.static(webPath));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web", "static");
app.use("/static", express.static(webPath, staticOptions));
}
}
@@ -421,6 +456,21 @@ class Master {
}
}
/**
* Get instance by ID for a request
*
* @param {number} instanceId - ID of instance to get.
* @returns {object} instance
* @throws {module:lib/errors.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId) {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
addInstanceHooks(instance) {
instance.config.on("fieldChanged", (group, field, prev) => {
if (group.name === "instance" && field === "name") {
@@ -453,6 +503,16 @@ class Master {
}
}
userUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.userUpdated(user);
}
}
/**
* Notify connected control clients under the given user that the
* permissions for this user may have changed.
@@ -493,6 +553,13 @@ class Master {
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
try {
let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
pluginInfo.manifest = await Master.loadJsonObject(require.resolve(manifestPath), true);
} catch (err) {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
continue;
}
@@ -582,9 +649,22 @@ class Master {
return function(req, res, next) {
let depth = routeDepth + (req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
let staticRoot = webRoot;
let mainBundle;
if (res.app.locals.mainBundle) {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
mainBundle = stats.toJson().assetsByChunkName["main"];
}
fs.readFile(path.join(__dirname, "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content.replace(/__WEB_ROOT__/g, webRoot));
res.send(content
.replace(/__CLUSTER_NAME__/g, res.app.locals.master.config.get("master.name"))
.replace(/__WEB_ROOT__/g, webRoot)
.replace(/__STATIC_ROOT__/g, staticRoot)
.replace(/__MAIN_BUNDLE__/g, mainBundle)
);
}).catch(err => {
next(err);
});
@@ -605,11 +685,7 @@ class Master {
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.instances.get(data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`);
}
let instance = this.getRequestInstance(data.instance_id);
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
+6 -1
View File
@@ -3,6 +3,7 @@ const libConfig = require("@clusterio/lib/config");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const PlayerStats = require("@clusterio/lib/PlayerStats");
const BaseConnection = require("./BaseConnection");
@@ -175,7 +176,7 @@ class SlaveConnection extends BaseConnection {
}
async playerEventEventHandler(message) {
let { instance_id, name, type } = message.data;
let { instance_id, name, type, stats } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
user = this._master.userManager.createUser(name);
@@ -186,7 +187,11 @@ class SlaveConnection extends BaseConnection {
} else if (type === "leave") {
user.notifyLeave(instance_id);
}
user.instanceStats.set(instance_id, new PlayerStats(stats));
user.recalculatePlayerStats();
this._master.userUpdated(user);
delete message.data.stats;
let instance = this._master.instances.get(instance_id);
await libPlugin.invokeHook(this._master.plugins, "onPlayerEvent", instance, message.data);
}
+1 -1
View File
@@ -176,7 +176,7 @@ ${err.stack}`
return;
}
let { seq, type, data } = message;
let { type, data } = message;
if (type === "resume") {
let connector;
+30 -3
View File
@@ -9,6 +9,7 @@ const util = require("util");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libHash = require("@clusterio/lib/hash");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
@@ -100,7 +101,23 @@ function getPlugins(req, res) {
let name = pluginInfo.name;
let loaded = req.app.locals.master.plugins.has(name);
let enabled = loaded && req.app.locals.master.config.group(pluginInfo.name).get("load_plugin");
plugins.push({ name, version: pluginInfo.version, enabled, loaded });
let web = {};
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)];
web.main = stats.toJson().assetsByChunkName[name];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, requirePath: pluginInfo.requirePath });
}
res.send(plugins);
}
@@ -180,19 +197,28 @@ async function uploadExport(req, res) {
"export/locale.json",
];
let manifest = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
let name = path.posix.basename(filePath);
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
let { name, ext } = path.posix.parse(filePath);
let hash = await libHash.hashStream(file.nodeStream());
manifest[name] = `static/${name}.${hash}${ext}`;
await libFileOps.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
await res.app.locals.master.updateExportManifest(manifest);
res.sendStatus(200);
}
async function getExportManifest(req, res) {
endpointHitCounter.labels(req.route.path).inc();
res.json(res.app.locals.master.exportManifest);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
@@ -417,6 +443,7 @@ function addRouteHandlers(app) {
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.get("/api/export-manifest", (req, res, next) => getExportManifest(req, res, next).catch(next));
app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
+25 -4
View File
@@ -4,14 +4,35 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2a2a2a" />
<title>Clusterio</title>
<title>__CLUSTER_NAME__</title>
<style>
html {
color: #fff;
background-color: #000;
}
#bootstrap-msg {
font: 20px serif;
color: #aaa;
margin: auto;
margin-top: 1em;
width: fit-content;
}
</style>
<script>
window.webRoot = new URL("__WEB_ROOT__", document.location).pathname;
window.webRoot = new URL("__WEB_ROOT__", document.location).pathname;
window.staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
function scriptError() {
let bootstrapMsg = document.getElementById("bootstrap-msg");
bootstrapMsg.textContent = "Error loading main bundle, see web console for details.";
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="__WEB_ROOT__bundle.js"></script>
<div id="root">
<div id="bootstrap-msg">Loading web interface...</div>
</div>
<script src="__STATIC_ROOT____MAIN_BUNDLE__" defer onerror="scriptError()"></script>
</body>
</html>
-2
View File
@@ -12,8 +12,6 @@ module.exports = (env = {}) => merge(common(env), {
contentBase: "./dist/web",
},
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
+13 -2
View File
@@ -28,9 +28,20 @@ impl.events[defines.events.on_player_joined_game] = function(event)
api.send_json("player_event", { type = "join", name = player.name })
end
local disconnect_reason_name = {}
if defines.disconnect_reason then
for name, i in pairs(defines.disconnect_reason) do
disconnect_reason_name[i] = name
end
end
impl.events[defines.events.on_player_left_game] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "leave", name = player.name })
api.send_json("player_event", {
type = "leave",
name = player.name,
reason = disconnect_reason_name[event.reason] or "quit",
})
end
-- Internal API
@@ -46,7 +57,7 @@ end
-- This is not part of the add_remote_interface callback to ensure it is
-- available when the clusterio_lib mod is loaded. The reason this is
-- available when the clusterio_lib mod is loaded. The reason this is
-- neccessary is that on_init for newly added mods happen before on_load
-- for existing mods, and the add_remote_interface callback is done in
-- on_load. See https://forums.factorio.com/viewtopic.php?f=25&t=81552 for
@@ -49,7 +49,7 @@ local has_create_grid = version_ge("1.1.7")
-- If the equipment is a burner the following is also present:
-- i: burner inventory
-- r: result inventory
-- b: curently burning (optional)
-- b: currently burning (optional)
-- f: remaining_burning_fuel (optional)
function serialize.serialize_equipment_grid(grid)
local serialized = {}
@@ -281,8 +281,11 @@ function serialize.deserialize_inventory(inventory, serialized)
local repeat_count = entry.r or 0
for offset = 0, repeat_count do
-- XXX what if the inventory is smaller on this instance?
local index = base_index + offset
if index > #inventory then
print("Error: Inventory index out of bounds deserializing inventory, voiding excess slots")
break
end
local slot = inventory[index]
if entry.f then
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"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.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"pidusage": "^2.0.18",
+1 -1672
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
"use strict";
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
class InstanceConnection extends libLink.Link {
constructor(connector, slave, instanceId) {
super("slave", "instance", connector);
this.slave = slave;
this.instanceId = instanceId;
this.plugins = new Map();
this.status = "stopped";
libLink.attachAllMessages(this);
for (let pluginInfo of slave.pluginInfos) {
libPlugin.attachPluginMessages(this, { info: pluginInfo });
}
}
async forwardRequestToMaster(message, request) {
return await request.send(this.slave, message.data);
}
async forwardRequestToInstance(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = this.slave.instanceConnections.get(instanceId);
if (!instanceConnection) {
// Instance is probably on another slave
return await this.forwardRequestToMaster(message, request);
}
if (request.plugin && !instanceConnection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`);
}
return await request.send(instanceConnection, message.data);
}
async forwardEventToInstance(message, event) {
let instanceId = message.data.instance_id;
let instanceConnection = this.slave.instanceConnections.get(instanceId);
if (!instanceConnection) {
// Instance is probably on another slave
await this.forwardEventToMaster(message, event);
return;
}
if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; }
event.send(instanceConnection, message.data);
}
async forwardEventToMaster(message, event) {
if (!this.slave.connector.hasSession) {
return;
}
event.send(this.slave, message.data);
}
async broadcastEventToInstance(message, event) {
for (let instanceConnection of this.slave.instanceConnections.values()) {
// Do not broadcast back to the source
if (instanceConnection === this) { continue; }
if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; }
event.send(instanceConnection, message.data);
}
}
async instanceInitializedEventHandler(message, event) {
this.plugins = new Map(Object.entries(message.data.plugins));
}
async instanceStatusChangedEventHandler(message, event) {
this.status = message.data.status;
if (this.status === "stopped") {
this.slave.instanceConnections.delete(this.instanceId);
}
this.forwardEventToMaster(message, event);
}
}
module.exports = InstanceConnection;
+844
View File
@@ -0,0 +1,844 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const yargs = require("yargs");
const events = require("events");
const pidusage = require("pidusage");
const setBlocking = require("set-blocking");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
// internal libraries
const libFileOps = require("@clusterio/lib/file_ops");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
const libPrometheus = require("@clusterio/lib/prometheus");
const libConfig = require("@clusterio/lib/config");
const { logger } = require("@clusterio/lib/logging");
const libHelpers = require("@clusterio/lib/helpers");
const Instance = require("./Instance");
const InstanceConnection = require("./InstanceConnection");
const finished = util.promisify(stream.finished);
function checkFilename(name) {
// All of these are bad in Windows only, except for /, . and ..
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("must be a string");
}
if (name === "") {
throw new Error("cannot be empty");
}
if (badChars.test(name)) {
throw new Error('cannot contain <>:"\\/|=* or control characters');
}
if (badNames.includes(name.toUpperCase())) {
throw new Error(
"cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9"
);
}
if (badEnd.test(name)) {
throw new Error("cannot end with . or space");
}
}
/**
* Clean up string to be suitable for use as filename
*
* @param {string} name - Arbitrary name string
* @returns {string} Filename suitable to use in the filesystem
* @private
*/
function cleanFilename(name) {
// copied from checkFilename
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("name must be a string");
}
if (name === "" || badNames.includes(name.toUpperCase())) {
name += "_";
}
name = name.replace(badChars, "_");
name = name.replace(badEnd, "_");
return name;
}
function checkRequestSaveName(name) {
try {
checkFilename(name);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
}
/**
* Searches for instances in the provided directory
*
* Looks through all sub-dirs of the provided directory for valid
* instance definitions and return a mapping of instance id to
* instanceInfo objects.
*
* @param {string} instancesDir - Directory containing instances
* @returns {Map<integer, Object>}
* mapping between instance id and information about this instance.
* @private
*/
async function discoverInstances(instancesDir) {
let instanceInfos = new Map();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new libConfig.InstanceConfig("slave");
let configPath = path.join(instancesDir, entry.name, "instance.json");
try {
await instanceConfig.load(JSON.parse(await fs.readFile(configPath)));
} catch (err) {
if (err.code === "ENOENT") {
continue; // Ignore folders without config.json
}
logger.error(`Error occured while parsing ${configPath}: ${err.message}`);
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"), {
path: instancePath,
config: instanceConfig,
});
}
}
return instanceInfos;
}
/**
* Handles running the slave
*
* Connects to the master server over the WebSocket and manages intsances.
* @alias module:slave/src/Slave
*/
class Slave extends libLink.Link {
// I don't like God classes, but the alternative of putting all this state
// into global variables is not much better.
constructor(connector, slaveConfig, tlsCa, pluginInfos) {
super("slave", "master", connector);
libLink.attachAllMessages(this);
this.pluginInfos = pluginInfos;
for (let pluginInfo of pluginInfos) {
libPlugin.attachPluginMessages(this, { info: pluginInfo });
}
this.config = slaveConfig;
/**
* Certificate authority used to validate TLS connections to the master.
* @type {?string}
*/
this.tlsCa = tlsCa;
this.instanceConnections = new Map();
this.discoveredInstanceInfos = new Map();
this.instanceInfos = new Map();
this.adminlist = new Set();
this.banlist = new Map();
this.whitelist = new Set();
this.connector.on("hello", data => {
this.serverVersion = data.version;
this.serverPlugins = new Map(Object.entries(data.plugins));
});
this._startup = true;
this._disconnecting = false;
this._shuttingDown = false;
this.connector.on("connect", () => {
if (this._shuttingDown) {
return;
}
this.updateInstances().catch((err) => {
if (err instanceof libErrors.SessionLost) {
return undefined;
}
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
});
});
this.connector.on("close", () => {
if (this._shuttingDown) {
return;
}
if (this._disconnecting) {
this._disconnecting = false;
this.connector.connect().catch((err) => {
logger.fatal(`Unexpected error reconnecting to master:\n${err.stack}`);
return this.shutdown();
});
} else {
logger.fatal("Master connection was unexpectedly closed");
this.shutdown();
}
});
for (let event of ["connect", "drop", "resume", "close"]) {
this.connector.on(event, () => {
for (let instanceConnection of this.instanceConnections.values()) {
libLink.messages.masterConnectionEvent.send(instanceConnection, { event });
}
});
}
}
async _createNewInstanceDir(name) {
name = cleanFilename(name);
try {
checkFilename(name);
} catch (err) {
throw new Error(`Instance folder was unepectedly invalid: name ${err.message}`);
}
let instancesDir = this.config.get("slave.instances_directory");
for (let i = 0; i < 10; i++) { // Limit attempts in case this is somehow an infinite loop
let candidateDir = path.join(instancesDir, await libFileOps.findUnusedName(instancesDir, name));
try {
await fs.mkdir(candidateDir);
} catch (err) {
if (err.code === "EEXIST") {
continue;
}
throw err;
}
return candidateDir;
}
throw Error("Unable to create instance dir, retry threshold reached");
}
async forwardRequestToInstance(message, request) {
let instanceId = message.data.instance_id;
if (!this.instanceInfos.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
let instanceConnection = this.instanceConnections.get(instanceId);
if (!instanceConnection) {
throw new libErrors.RequestError(`Instance ID ${instanceId} is not running`);
}
if (request.plugin && !instanceConnection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`);
}
return await request.send(instanceConnection, message.data);
}
async forwardEventToInstance(message, event) {
let instanceId = message.data.instance_id;
if (!this.instanceInfos.has(instanceId)) { return; }
let instanceConnection = this.instanceConnections.get(instanceId);
if (!instanceConnection) { return; }
if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; }
event.send(instanceConnection, message.data);
}
async broadcastEventToInstance(message, event) {
for (let instanceConnection of this.instanceConnections.values()) {
if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; }
event.send(instanceConnection, message.data);
}
}
async syncUserListsEventHandler(message) {
let updateList = (list, updatedList, prop, event) => {
let added = new Set(updatedList);
let removed = new Set(list);
list.forEach(el => added.delete(el));
updatedList.forEach(el => removed.delete(el));
for (let name of added) {
list.add(name);
this.broadcastEventToInstance(
{ data: { name, [prop]: true }}, event
);
}
for (let name of removed) {
list.delete(name);
this.broadcastEventToInstance(
{ data: { name, [prop]: false }}, event
);
}
};
updateList(this.adminlist, message.data.adminlist, "admin", libLink.messages.adminlistUpdate);
updateList(this.whitelist, message.data.whitelist, "whitelisted", libLink.messages.whitelistUpdate);
let addedOrChanged = new Map(message.data.banlist);
let removed = new Set(this.banlist.keys());
addedOrChanged.forEach((_, name) => removed.delete(name));
this.banlist.forEach((reason, name) => {
if (addedOrChanged.get(name) === reason) {
addedOrChanged.delete(name);
}
});
for (let [name, reason] of addedOrChanged) {
this.banlist.set(name, reason);
this.broadcastEventToInstance(
{ data: { name, banned: true, reason }}, libLink.messages.banlistUpdate
);
}
for (let name of removed) {
this.banlist.delete(name);
this.broadcastEventToInstance(
{ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate
);
}
}
async adminlistUpdateEventHandler(message) {
let { name, admin } = message.data;
if (admin) {
this.adminlist.add(name);
} else {
this.adminlist.delete(name);
}
}
async banlistUpdateEventHandler(message) {
let { name, banned, reason } = message.data;
if (banned) {
this.banlist.set(name, reason);
} else {
this.banlist.delete(name);
}
}
async whitelistUpdateEventHandler(message) {
let { name, whitelisted } = message.data;
if (whitelisted) {
this.whitelist.add(name);
} else {
this.whitelist.delete(name);
}
}
async assignInstanceRequestHandler(message) {
let { instance_id, serialized_config } = message.data;
let instanceInfo = this.instanceInfos.get(instance_id);
if (instanceInfo) {
instanceInfo.config.update(serialized_config, true, "master");
logger.verbose(`Updated config for ${instanceInfo.path}`);
} else {
instanceInfo = this.discoveredInstanceInfos.get(instance_id);
if (instanceInfo) {
instanceInfo.config.update(serialized_config, true, "master");
} else {
let instanceConfig = new libConfig.InstanceConfig("slave");
await instanceConfig.load(serialized_config, "master");
let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name"));
await Instance.create(instanceDir, this.config.get("slave.factorio_directory"));
instanceInfo = {
path: instanceDir,
config: instanceConfig,
};
this.discoveredInstanceInfos.set(instance_id, instanceInfo);
}
this.instanceInfos.set(instance_id, instanceInfo);
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
}
// Somewhat hacky, but in the event of a lost session the status is
// resent on assigment since the master server sends an assigment
// request for all the instances it knows should be on this slave.
let instanceConnection = this.instanceConnections.get(instance_id);
libLink.messages.instanceStatusChanged.send(this, {
instance_id,
status: instanceConnection ? instanceConnection.status : "stopped",
});
// save a copy of the instance config
let warnedOutput = {
_warning: "Changes to this file will be overwritten by the master server's copy.",
...instanceInfo.config.serialize(),
};
await libFileOps.safeOutputFile(
path.join(instanceInfo.path, "instance.json"),
JSON.stringify(warnedOutput, null, 4)
);
}
async unassignInstanceRequestHandler(message) {
let instanceId = message.data.instance_id;
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && ["starting", "running"].includes(instanceConnection.status)) {
await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId });
}
this.instanceInfos.delete(instanceId);
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
}
}
getRequestInstanceInfo(instanceId) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instanceInfo;
}
/**
* Initialize and connect an unloaded instance
*
* @param {number} instanceId - ID of instance to initialize.
* @returns {module:slave/slave~InstanceConnection} connection to instance.
*/
async _connectInstance(instanceId) {
let instanceInfo = this.getRequestInstanceInfo(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
}
let [connectionClient, connectionServer] = libLink.VirtualConnector.makePair();
let instanceConnection = new InstanceConnection(connectionServer, this, instanceId);
let instance = new Instance(
this, connectionClient, instanceInfo.path, this.config.get("slave.factorio_directory"), instanceInfo.config
);
this.instanceConnections.set(instanceId, instanceConnection);
await instance.init(this.pluginInfos);
return instanceConnection;
}
async getMetricsRequestHandler() {
let requests = [];
for (let instanceConnection of this.instanceConnections.values()) {
requests.push(libLink.messages.getMetrics.send(instanceConnection));
}
let results = [];
for (let response of await Promise.all(requests)) {
results.push(...response.results);
}
for await (let result of libPrometheus.defaultRegistry.collect()) {
if (result.metric.name.startsWith("process_")) {
results.push(libPrometheus.serializeResult(result, {
addLabels: { "slave_id": String(this.config.get("slave.id")) },
metricName: result.metric.name.replace("process_", "clusterio_slave_"),
}));
} else {
results.push(libPrometheus.serializeResult(result));
}
}
return { results };
}
async startInstanceRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
return await request.send(instanceConnection, message.data);
}
async loadScenarioRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
return await request.send(instanceConnection, message.data);
}
async listSavesRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection) {
return await request.send(instanceConnection, message.data);
}
let instanceInfo = this.getRequestInstanceInfo(instanceId);
return {
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
};
}
async createSaveRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
await request.send(instanceConnection, message.data);
}
async renameSaveRequestHandler(message) {
let { instance_id, old_name, new_name } = message.data;
checkRequestSaveName(old_name);
checkRequestSaveName(new_name);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.move(
path.join(instanceInfo.path, "saves", old_name),
path.join(instanceInfo.path, "saves", new_name),
{ overwrite: false },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async copySaveRequestHandler(message) {
let { instance_id, source, destination } = message.data;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.copy(
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async transferSaveRequestHandler(message) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
checkRequestSaveName(source_save);
checkRequestSaveName(target_save);
let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id);
let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
target_save = await libFileOps.findUnusedName(
path.join(targetInstanceInfo.path, "saves"), target_save, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
}
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${source_save} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves"));
return { save: target_save };
}
async sendSaveListUpdate(instance_id, savesDir) {
let instanceConnection = this.instanceConnections.get(instance_id);
let saveList;
if (instanceConnection) {
saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list;
} else {
saveList = await Instance.listSaves(savesDir, null);
}
libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList });
}
async deleteSaveRequestHandler(message) {
let { instance_id, save } = message.data;
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.unlink(path.join(instanceInfo.path, "saves", save));
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${save} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async pullSaveRequestHandler(message) {
let { instance_id, stream_id, filename } = message.data;
checkRequestSaveName(filename);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa },
stream: true,
});
if (response.statusCode !== 200) {
let content = await libHelpers.readStream(response);
throw new libErrors.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
}
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream;
while (true) {
try {
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(writeStream);
await finished(writeStream);
filename = await libFileOps.findUnusedName(savesDir, filename, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename));
await this.sendSaveListUpdate(instance_id, savesDir);
return { save: filename };
}
async pushSaveRequestHandler(message) {
let { instance_id, stream_id, save } = message.data;
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let content;
try {
// phin doesn't support streaming requests :(
content = await fs.readFile(path.join(instanceInfo.path, "saves", save));
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${save} does not exist`);
}
throw err;
}
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
phin({
url, method: "PUT",
core: { ca: this.tlsCa },
data: content,
}).catch(err => logger.error(`Error pushing save to master:\n${err.stack}`));
}
async exportDataRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
await request.send(instanceConnection, message.data);
}
async stopInstance(instanceId) {
let instanceConnection = this.instanceConnections.get(instanceId);
await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId });
}
async deleteInstanceRequestHandler(message) {
let instanceId = message.data.instance_id;
if (this.instanceConnections.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
}
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
this.discoveredInstanceInfos.delete(instanceId);
this.instanceInfos.delete(instanceId);
await fs.remove(instanceInfo.path);
}
/**
* Discover available instances
*
* Looks through the instances directory for instances and updates
* the slave and master server with the new list of instances.
*/
async updateInstances() {
this.discoveredInstanceInfos = await discoverInstances(this.config.get("slave.instances_directory"));
let list = [];
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push({
serialized_config: instanceInfo.config.serialize("master"),
status: instanceConnection ? instanceConnection.status : "stopped",
});
}
await libLink.messages.updateInstances.send(this, { instances: list });
// Handle configured auto startup instances
if (this._startup) {
this._startup = false;
for (let [instanceId, instanceInfo] of this.instanceInfos) {
if (instanceInfo.config.get("instance.auto_start")) {
try {
let instanceConnection = await this._connectInstance(instanceId);
await libLink.messages.startInstance.send(instanceConnection, {
instance_id: instanceId,
save: null,
});
} catch (err) {
logger.error(
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`
);
}
}
}
}
}
async prepareDisconnectRequestHandler(message, request) {
for (let instanceConnection of this.instanceConnections.values()) {
await libLink.messages.prepareMasterDisconnect.send(instanceConnection);
}
this._disconnecting = true;
this.connector.setClosing();
return await super.prepareDisconnectRequestHandler(message, request);
}
/**
* Stops all instances and closes the connection
*/
async shutdown() {
if (this._shuttingDown) {
return;
}
this._shuttingDown = true;
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
}
}
try {
for (let instanceId of this.instanceConnections.keys()) {
await this.stopInstance(instanceId);
}
await this.connector.close(1000, "Slave Shutdown");
// Clear silly interval in pidfile library.
pidusage.clear();
} catch (err) {
setBlocking(true);
logger.error(`
+------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
}
/**
* True if the connection to the master is connected, not in the dropped
* state,and not in the process of disconnecting.
* @type {boolean}
*/
get connected() {
return !this._disconnecting && this.connector.connected;
}
}
module.exports = Slave;
// For testing only
module.exports._checkFilename = checkFilename;
module.exports._cleanFilename = cleanFilename;
module.exports._discoverInstances = discoverInstances;
module.exports._Slave = Slave;
+5 -3
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"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.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"antd": "^4.16.0",
"assert": "^2.0.0",
"babel-loader": "^8.2.2",
@@ -38,7 +38,9 @@
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0",
"util": "^0.12.3",
"webpack": "^5.36.2"
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-manifest-plugin": "^5.0.0"
},
"publishConfig": {
"access": "public"
+34 -10
View File
@@ -25,7 +25,7 @@ async function loadScript(url) {
return result;
}
async function loadPlugins() {
async function loadPluginInfos() {
let response = await fetch(`${webRoot}api/plugins`);
let pluginList;
if (response.ok) {
@@ -36,34 +36,58 @@ async function loadPlugins() {
pluginList = [];
}
let plugins = new Map();
let pluginInfos = [];
await __webpack_init_sharing__("default");
for (let meta of pluginList) {
if (meta.web.error) {
logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`);
continue;
}
try {
await loadScript(`${webRoot}plugins/${meta.name}/remoteEntry.js`);
await loadScript(`${webRoot}${meta.web.main}`);
let container = window[`plugin_${meta.name}`];
if (!container) {
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
}
await container.init(__webpack_share_scopes__.default);
let pluginInfo = (await container.get("./info"))();
let pluginPackage = (await container.get("./package.json"))();
pluginInfo.container = container;
pluginInfo.package = (await container.get("./package.json"))();
pluginInfo.enabled = meta.enabled;
pluginInfos.push(pluginInfo);
} catch (err) {
logger.error(`Failed to load plugin info for ${meta.name}`);
if (err.stack) {
logger.error(err.stack);
}
}
}
return pluginInfos;
}
async function loadPlugins(pluginInfos) {
let plugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.enabled) {
continue;
}
try {
let WebPluginClass = libPlugin.BaseWebPlugin;
if (meta.enabled && pluginInfo.webEntrypoint) {
let webModule = (await container.get(pluginInfo.webEntrypoint))();
if (pluginInfo.webEntrypoint) {
let webModule = (await pluginInfo.container.get(pluginInfo.webEntrypoint))();
if (!webModule.WebPlugin) {
throw new Error("Plugin webEntrypoint does not export WebPlugin class");
}
WebPluginClass = webModule.WebPlugin;
}
let plugin = new WebPluginClass(container, pluginPackage, pluginInfo, logger);
let plugin = new WebPluginClass(pluginInfo.container, pluginInfo.package, pluginInfo, logger);
await plugin.init();
plugins.set(pluginInfo.name, plugin);
} catch (err) {
logger.error(`Failed to load plugin ${meta.name}`);
logger.error(`Failed to load plugin ${pluginInfo.name}`);
if (err.stack) {
logger.error(err.stack);
}
@@ -77,10 +101,10 @@ export default async function bootstrap() {
level: "verbose",
format: new WebConsoleFormat(),
}));
let plugins = await loadPlugins();
let pluginInfos = [...plugins.values()].map(p => p.info);
let pluginInfos = await loadPluginInfos();
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let plugins = await loadPlugins(pluginInfos);
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
-1
View File
@@ -1,4 +1,3 @@
import events from "events";
import React, { useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd";
const { Title } = Typography;
@@ -1,5 +1,5 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import { Button, Form, Input, Modal } from "antd";
import { libFactorio, libLink } from "@clusterio/lib";
+1 -3
View File
@@ -1,8 +1,6 @@
import React, { useContext } from "react";
import React from "react";
import { Typography } from "antd";
import { libLink } from "@clusterio/lib";
import PageLayout from "./PageLayout";
const { Paragraph } = Typography;
@@ -1,5 +1,4 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import { libConfig, libLink } from "@clusterio/lib";
@@ -1,14 +1,17 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { message, Button, Table } from "antd";
import { message, Button, Space, Table } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import { useAccount } from "../model/account";
import { useSlaveList } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
import StartStopInstanceButton from "./StartStopInstanceButton";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function InstanceList(props) {
let account = useAccount();
let history = useHistory();
let [slaveList] = useSlaveList();
@@ -43,6 +46,7 @@ export default function InstanceList(props) {
key: "assigned_slave",
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
responsive: ["sm"],
},
{
title: "Public address",
@@ -63,13 +67,24 @@ export default function InstanceList(props) {
</> : "";
},
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
responsive: ["lg"],
},
{
title: "Status",
dataIndex: "status",
render: status => <InstanceStatusTag status={status} />,
key: "status",
render: instance => <InstanceStatusTag status={instance["status"]} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
...(account.hasAnyPermission("core.instance.start", "core.instance.stop") ? [{
key: "action",
render: instance => <StartStopInstanceButton
buttonProps={{ size: "small" }}
instance={instance}
/>,
responsive: ["sm"],
align: "right",
width: 100,
}] : []),
];
if (props.hideAssignedSlave) {
@@ -1,13 +1,15 @@
import React, { useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Dropdown, Menu, Modal, PageHeader, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import InstanceConfigTree from "./InstanceConfigTree";
import LogConsole from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
@@ -20,9 +22,39 @@ import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const { Title, Paragraph } = Typography;
const { Title } = Typography;
function InstanceDescription(props) {
let account = useAccount();
const { slave, instance } = props;
let assigned = instance["assigned_slave"] !== null;
return <Descriptions
bordered
size="small"
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instance["id"]}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
@@ -60,16 +92,44 @@ export default function InstanceViewPage(props) {
</PageLayout>;
}
let instanceButtons = <Space>
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
let instanceButtonMenuItems = [];
if (account.hasPermission("core.instance.export_data")) {
instanceButtonMenuItems.push({
disabled: exportingData || instance.status !== "stopped",
key: "export",
label: "Export data",
});
}
if (account.hasPermission("core.instance.extract_players")) {
instanceButtonMenuItems.push({
disabled: instance["status"] !== "running",
key: "extract",
label: "Extract players from save",
});
}
if (account.hasPermission("core.instance.kill")) {
instanceButtonMenuItems.push({
disabled: ["unknown", "unassigned", "stopped"].includes(instance["status"]),
key: "kill",
label: "Kill process",
});
}
if (account.hasPermission("core.instance.delete")) {
if (instanceButtonMenuItems.length) {
instanceButtonMenuItems.push({ type: "divider" });
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasPermission("core.instance.export_data") && <Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
instanceButtonMenuItems.push({
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
danger: true,
key: "delete",
icon: <DeleteOutlined />,
label: "Delete",
});
}
let instanceButtonsMenu = <Menu
items={instanceButtonMenuItems}
onClick={({ key }) => {
if (key === "export") {
setExportingData(true);
libLink.messages.exportData.send(
control, { instance_id: instanceId }
@@ -78,59 +138,57 @@ export default function InstanceViewPage(props) {
).finally(() => {
setExportingData(false);
});
}}
>
Export data
</Button>}
{account.hasPermission("core.instance.delete") && <Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteInstance.send(
} else if (key === "extract") {
libLink.messages.extractPlayers.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
}}
>
<Button
danger
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
>
<DeleteOutlined />
</Button>
</Popconfirm>}
).catch(notifyErrorHandler("Error extracting player data"));
} else if (key === "kill") {
libLink.messages.killInstance.send(
control, { instance_id: instanceId }
).catch(notifyErrorHandler("Error killing instance"));
} else if (key === "delete") {
Modal.confirm({
autoFocusButton: "cancel",
content: "Permamently delete instance and server saves?",
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => {
libLink.messages.deleteInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
},
});
}
}}
/>;
let instanceButtons = <Space>
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasAnyPermission(
"core.instance.export_data",
"core.instance.extract_players",
"core.instance.kill",
"core.instance.delete",
) && <Dropdown placement="bottomRight" trigger={["click"]} overlay={instanceButtonsMenu}>
<Button>More <DownOutlined /></Button>
</Dropdown>}
</Space>;
let assigned = instance["assigned_slave"] !== null;
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
<PageHeader
className="site-page-header"
title={instance["name"]}
extra={instanceButtons}
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>
/>
<InstanceDescription slave={slave} instance={instance} />
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
@@ -147,5 +205,7 @@ export default function InstanceViewPage(props) {
}
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
<PluginExtra component="InstanceViewPage" instance={instance} />
</PageLayout>;
}
@@ -7,6 +7,7 @@ import { libConfig, libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { useInstanceList } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
@@ -28,7 +29,7 @@ function CreateInstanceButton(props) {
await instanceConfig.init();
instanceConfig.set("instance.name", values.instanceName);
let serialized_config = instanceConfig.serialize("master");
let result = await libLink.messages.createInstance.send(control, { serialized_config });
await libLink.messages.createInstance.send(control, { serialized_config });
setVisible(false);
history.push(`/instances/${instanceConfig.get("instance.id")}/view`);
}
@@ -94,5 +95,6 @@ export default function InstancesPage() {
/>
<InstanceList instances={instanceList} />
<PluginExtra component="InstancesPage" />
</PageLayout>;
}
@@ -1,5 +1,5 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import { Button, Form, Input, Modal } from "antd";
import { libFactorio, libLink } from "@clusterio/lib";
@@ -60,7 +60,7 @@ export default function LoadScenarioModal(props) {
}).then(() => {
setVisible(false);
}).catch(
notifyErrorHandler("Error creating save")
notifyErrorHandler("Error loading scenario")
).finally(() => {
setLoadingScenario(false);
});
@@ -76,7 +76,7 @@ export default function LogConsole(props) {
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
setPastLines(result.log.map((info, index) => formatLog(info, -index - 1)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
@@ -100,7 +100,7 @@ export default function LogConsole(props) {
<Paragraph code className="instance-console">
{pastLines}
{lines}
<div className="scroll-anchor" ref={anchor} />
<div className="scroll-anchor" key="anchor" ref={anchor} />
</Paragraph>
</>;
}
+3 -3
View File
@@ -1,11 +1,11 @@
import React, { Fragment, useContext } from "react";
import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typography } from "antd";
import { Button, Card, Divider, Form, Image, Input, Space, Typography } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
import logo from "../images/logo.png";
import ControlContext from "./ControlContext";
const { Paragraph, Text } = Typography;
const { Paragraph } = Typography;
function TokenAuth(props) {
@@ -22,7 +22,7 @@ function TokenAuth(props) {
>
<Input
style={{ width: "14em" }}
prefix=<LockOutlined className="site-form-item-icon" />
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Authentication Token"
/>
@@ -1,5 +1,4 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import { libConfig, libLink } from "@clusterio/lib";
@@ -1,6 +1,7 @@
import React, { useContext } from "react";
import React from "react";
import { Typography } from "antd";
import PluginExtra from "./PluginExtra";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import MasterConfigTree from "./MasterConfigTree";
@@ -9,7 +10,7 @@ import PageLayout from "./PageLayout";
const { Title } = Typography;
export default function SlavesPage() {
export default function MasterPage() {
let account = useAccount();
return <PageLayout nav={[{ name: "Master" }]}>
@@ -19,5 +20,6 @@ export default function SlavesPage() {
<LogConsole master={true} />
</>}
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
<PluginExtra component="MasterPage" />
</PageLayout>;
};
@@ -1,6 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { Layout, Menu, Breadcrumb, Typography } from "antd";
import { Layout, Breadcrumb } from "antd";
const { Content } = Layout;
@@ -0,0 +1,17 @@
import React, { useContext } from "react";
import ControlContext from "./ControlContext";
export default function PluginExtra(params) {
let control = useContext(ControlContext);
let components = [];
for (let [name, plugin] of control.plugins) {
if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, params.component)) {
let ComponentExtra = plugin.componentExtra[params.component];
components.push(<ComponentExtra key={name} plugin={plugin} {...params}/>);
}
}
return components;
}
@@ -1,10 +1,9 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
export default function PluginViewPage() {
@@ -52,7 +51,7 @@ export default function PluginViewPage() {
style={{
marginTop: "1em",
}}
message="Error loading web module"
message={pluginMeta.web.error || "Error loading web module"}
description={
"The web interface was unable to load the webpack module for this plugin. This is "+
"usually due to an incorrect or missing webpack build for the plugin. Due to the "+
@@ -8,6 +8,7 @@ import { libLink, libUsers } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
@@ -149,5 +150,6 @@ export default function RoleViewPage() {
</Form.Item>
))}
</Form>
<PluginExtra component="RoleViewPage" role={role} />
</PageLayout>;
}
@@ -8,6 +8,7 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
@@ -99,5 +100,6 @@ export default function RolesPage() {
},
})}
/>
<PluginExtra component="RolesPage" />
</PageLayout>;
}
+176 -1
View File
@@ -1,5 +1,8 @@
import React, { useContext, useState } from "react";
import { Button, List, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd";
import {
message, Button, Checkbox, Form, Input, List, Modal,
Popconfirm, Progress, Select, Space, Table, Tooltip, Upload,
} from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
@@ -9,10 +12,173 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { useInstanceList } from "../model/instance";
import { useSaves } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
function RenameModal(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Rename</Button>
<Modal
title="Rename save"
okText="Rename"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
>
<Form
form={form}
layout="vertical"
initialValues={{ newName: props.save.name }}
onFinish={values => {
libLink.messages.renameSave.send(control,
{ instance_id: props.instanceId, old_name: props.save.name, new_name: values.newName }
).then(() => {
setVisible(false);
form.resetFields();
}).catch(notifyErrorHandler("Error renaming save"));
}}
>
<Form.Item
name="newName"
label="New name"
rules={[{ required: true, message: "New name is required" }]}
>
<Input autoFocus />
</Form.Item>
</Form>
</Modal>
</>;
}
function CopyModal(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Copy</Button>
<Modal
title="Copy save"
okText="Copy"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
>
<Form
form={form}
layout="vertical"
initialValues={{ newName: props.save.name }}
onFinish={values => {
libLink.messages.copySave.send(control,
{ instance_id: props.instanceId, source: props.save.name, destination: values.newName }
).then(() => {
setVisible(false);
form.resetFields();
}).catch(notifyErrorHandler("Error copying save"));
}}
>
<Form.Item
name="newName"
label="New name"
rules={[{ required: true, message: "New name is required" }]}
>
<Input autoFocus />
</Form.Item>
</Form>
</Modal>
</>;
}
function TransferModal(props) {
let account = useAccount();
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
let [instanceList] = useInstanceList();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Transfer</Button>
<Modal
title="Transfer save"
okText="Transfer"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
destroyOnClose
>
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
initialValues={{ transferredName: props.save.name, copy: false }}
onFinish={values => {
setVisible(false);
let hide = message.loading("Transferring save...", 0);
libLink.messages.transferSave.send(control, {
instance_id: props.instanceId,
source_save: props.save.name,
target_instance_id: values.targetInstanceId,
target_save: values.transferredName || props.save.name,
copy: values.copy,
}).then(() => {
message.success("Transfer complete");
form.resetFields();
}).catch(
notifyErrorHandler("Error transfering save")
).finally(() => hide());
}}
>
<Form.Item
name="targetInstanceId"
label="Target Instance"
rules={[{ required: true, message: "Target Instance is required" }]}
>
<Select
autoFocus
showSearch
filterOption={(input, option) => option.title.toLowerCase().indexOf(input.toLowerCase()) >= 0}
>
{instanceList.filter(
instance => instance["id"] !== props.instanceId
).map((instance) => <Select.Option
key={instance["id"]}
value={instance["id"]}
title={instance["name"]}
disabled={["unassigned", "unknown"].includes(instance["status"])}
>
{instance["name"]}
{instance["status"] === "unassigned" && " (unassigned)"}
{instance["status"] === "unknown" && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
<Form.Item
name="transferredName"
label="Transferred name"
disabled={!account.hasAnyPermission("core.instance.save.rename", "core.instance.save.copy")}
>
<Input />
</Form.Item>
<Form.Item
name="copy"
valuePropName="checked"
label="Copy"
tooltip="Copy instead of moving the save to the new instance."
disabled={!account.hasPermission("core.instance.save.copy")}
>
<Checkbox />
</Form.Item>
</Form>
</Modal>
</>;
}
function formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
@@ -81,6 +247,12 @@ export default function SavesList(props) {
);
}}
>Load save</Button>}
{account.hasPermission("core.instance.save.rename") && <RenameModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.copy") && <CopyModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.download") && <Button
disabled={slaveOffline}
onClick={() => {
@@ -95,6 +267,9 @@ export default function SavesList(props) {
);
}}
>Download</Button>}
{account.hasPermission("core.instance.save.transfer") && <TransferModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.delete") && <Popconfirm
title="Permanently delete save?"
okText="Delete"
@@ -1,4 +1,4 @@
import React, { useContext, useState } from "react";
import React from "react";
export default function SectionHeader(props) {
return <div className="section-header">
+44 -16
View File
@@ -26,25 +26,58 @@ export default function SiteLayout(props) {
return null;
}
let accountMenu = <Menu>
<Menu.Item key="1" onClick={() => { history.push(`/users/${account.name}/view`); }}>{account.name}</Menu.Item>
<Menu.Divider/>
<Menu.Item key="2" danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
</Menu>;
let accountMenu = <Menu
onClick={({ key }) => {
if (key === "user") {
history.push(`/users/${account.name}/view`);
} else if (key === "logOut") {
account.logOut();
}
}}
items={[
{ label: account.name, key: "user" },
{ type: "divider" },
{ label: "Log out", danger: true, key: "logOut" },
]}
/>;
let combinedPages = [...pages];
for (let plugin of plugins.values()) {
combinedPages.push(...plugin.pages);
}
let menuItems = [];
let menuGroups = new Map();
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
if (!sidebarName || permission && !account.hasPermission(permission)) {
continue;
}
if (sidebarGroup) {
let group = menuGroups.get(sidebarGroup);
if (!group) {
group = [];
menuGroups.set(sidebarGroup, group);
}
group.push({ label: sidebarName, key: path });
} else {
menuItems.push({ label: sidebarName, key: path });
}
}
for (let [name, group] of menuGroups) {
menuItems.push({ type: "group", label: name, children: group, key: name });
}
return <Layout style={{ minHeight: "100vh" }}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{ webUiPackage.version }</span>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
<Menu.Item key="1">Dashboard</Menu.Item>
</Menu>
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["1"]}
items={[{ label: "Dashboard", key: "1" }]}
/>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
@@ -65,17 +98,12 @@ export default function SiteLayout(props) {
>
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={[currentSidebarPath]}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{combinedPages.map(({sidebarName, permission, path}) => {
if (!sidebarName || permission && !account.hasPermission(permission)) {
return null;
}
return <Menu.Item key={path}>{sidebarName}</Menu.Item>;
})}
</Menu>
items={menuItems}
/>
</Sider>
<Layout className="site-layout-content-container">
<Switch>
@@ -1,4 +1,4 @@
import React, { useContext, useState } from "react";
import React from "react";
import { Descriptions, PageHeader, Spin, Tag, Typography } from "antd";
import { useParams } from "react-router-dom";
@@ -8,6 +8,7 @@ import { useAccount } from "../model/account";
import { useInstanceList } from "../model/instance";
import { useSlave } from "../model/slave";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
const { Title } = Typography;
@@ -60,5 +61,6 @@ export default function SlaveViewPage(props) {
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole slaves={[slaveId]} />
</>}
<PluginExtra component="SlaveViewPage" slave={slave} />
</PageLayout>;
}
+112 -19
View File
@@ -1,14 +1,16 @@
import React, { useContext, useRef, useState } from "react";
import React, { useContext, useRef, useState, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { Button, Form, Input, Modal, PageHeader, Table, Tag, Typography } from "antd";
import CopyOutlined from "@ant-design/icons/lib/icons/CopyOutlined";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { useSlaveList } from "../model/slave";
import { notifyErrorHandler } from "../util/notify";
import notify, { notifyErrorHandler } from "../util/notify";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
@@ -17,12 +19,24 @@ function GenerateSlaveTokenButton(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [token, setToken] = useState(null);
let [slaveId, setSlaveId] = useState(null);
let [form] = Form.useForm();
let tokenTextAreaRef = useRef(null);
let [pluginList, setPluginList] = useState([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
const plugins = await response.json();
setPluginList(plugins);
} else {
notify("Failed to load plugin list");
}
})();
}, []);
async function generateToken() {
let values = form.getFieldsValue();
let slaveId = null;
if (values.slaveId) {
slaveId = Number.parseInt(values.slaveId, 10);
if (Number.isNaN(slaveId)) {
@@ -34,8 +48,11 @@ function GenerateSlaveTokenButton(props) {
let result = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId });
setToken(result.token);
setSlaveId(slaveId);
}
// Only install plugins that aren't filesystem paths. Npm modules have max 1 forward slash in their name.
const pluginString = pluginList.map(p => `"${p.requirePath}"`).filter(x => x.split("/") <= 1).join(" ");
return <>
<Button
onClick={() => { setVisible(true); }}
@@ -49,30 +66,105 @@ function GenerateSlaveTokenButton(props) {
setToken(null);
form.resetFields();
}}
width="700px"
>
<Form form={form} layout="vertical" requiredMark="optional">
<Form.Item name="slaveId" label="Slave ID">
<Input/>
<Input onChange={() => { generateToken().catch(notifyErrorHandler("Error generating token")); }} />
</Form.Item>
<Form.Item>
<Button
onClick={() => { generateToken().catch(notifyErrorHandler("Error generating token")); }}
>Generate</Button>
</Form.Item>
<Form.Item label="Token" required>
<Input.TextArea value={token} autoSize ref={tokenTextAreaRef} />
</Form.Item>
<Button disabled={token === null} onClick={() => {
let textArea = tokenTextAreaRef.current.resizableTextArea.textArea;
textArea.select();
document.execCommand("copy");
window.getSelection().removeAllRanges();
}}>Copy</Button>
</Form>
{token !== null && <>
<Typography.Paragraph>
Slave auth token:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message={"Copied auth token to clipboard"}
text={token}
/>
{token}
</div>
<Typography.Paragraph>
You can set the token on an existing slave with the following command:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message={"Copied configuration commands to clipboard"}
text={`npx clusterioslave config set slave.master_token ${token}
npx clusterioslave config set slave.id ${slaveId}`}
/>
<p>npx clusterioslave config set slave.master_token &lt;token&gt;</p>
<p>npx clusterioslave config set slave.id &lt;slaveId&gt;</p>
</div>
<Typography.Paragraph>
Example slave setup commands:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message={"Copied slave setup commands to clipboard"}
text={`
mkdir clusterio
cd clusterio
`+
// eslint-disable-next-line max-len
`npm init "@clusterio" -- --master-token ${token} --mode "slave" --download-headless --master-url ${document.location.origin}/ --slave-name "Slave ${slaveId || "?"}" --public-address localhost ${pluginString.length ? "--plugins" : ""} ${pluginString}`
}/>
<p>&gt; mkdir clusterio</p>
<p>&gt; cd clusterio</p>
<p>
&gt; npm init "@clusterio" --
--master-token <span className="highlight">{token} </span>
--mode "slave"
--download-headless
--master-url <span className="highlight">{document.location.origin}/ </span>
--slave-name <span className="highlight">"Slave {slaveId || "?"}" </span>
--public-address <span className="highlight">localhost </span>
{pluginString.length ? "--plugins" : ""} <span className="highlight">{pluginString}</span>
</p>
<p>&gt; ./run-slave</p>
</div>
</>}
</Modal>
</>;
}
function CopyButton({ text, message }) {
let [clipboardPermision, setClipboardPermission] = useState("granted");
useEffect(() => {
(async () => {
let result = await navigator.permissions.query({ name: "clipboard-write" });
// result.state is "granted", "denied" or "prompt"
setClipboardPermission(result.state);
result.onchange = function () {
setClipboardPermission(result.state);
};
})();
}, []);
async function checkClipboardPermission() {
let result = await navigator.permissions.query({ name: "clipboard-write" });
// result.state is "granted", "denied" or "prompt"
setClipboardPermission(result.state);
result.onchange = function () {
setClipboardPermission(result.state);
};
return result.state === "granted";
}
return <Button
className="copy-button"
danger={clipboardPermision !== "granted"}
onClick={async () => {
if (await checkClipboardPermission()) {
navigator.clipboard.writeText(text);
notify(message);
}
}}
>
<CopyOutlined />
</Button>;
}
export default function SlavesPage() {
let account = useAccount();
@@ -129,5 +221,6 @@ export default function SlavesPage() {
},
})}
/>
<PluginExtra component="SlavesPage" />
</PageLayout>;
};
@@ -11,7 +11,8 @@ export default function StartStopInstanceButton(props) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
function onClick() {
function onClick(event) {
event.stopPropagation();
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
+166 -89
View File
@@ -1,39 +1,29 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Col, Form, Input, PageHeader, Popconfirm, Popover, Row, Tag, Select, Space, Spin, Switch } from "antd";
import {
Button, Col, Descriptions, Form, Input, PageHeader, Popconfirm,
Row, Table, 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 { useInstanceList } from "../model/instance";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatLastSeen, sortLastSeen, useUser } from "../model/user";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function useUser(name) {
let control = useContext(ControlContext);
let [user, setUser] = useState({ loading: true });
function updateUser() {
// XXX optimize by requesting only the user in question
libLink.messages.listUsers.send(control).then(result => {
let match = result.list.find(u => u.name === name);
if (!match) {
setUser({ missing: true });
} else {
setUser({ ...match, present: true });
}
});
}
useEffect(() => {
updateUser();
}, [name]);
return [user, updateUser];
}
// The user page has a lot of optional elements, that does not make it
// praticularly complicated.
// eslint-disable-next-line complexity
export default function UserViewPage() {
let params = useParams();
let userName = params.name;
@@ -42,13 +32,14 @@ export default function UserViewPage() {
let account = useAccount();
let control = useContext(ControlContext);
let [user, updateUser] = useUser(userName);
let [instanceList] = useInstanceList();
let [user] = useUser(userName);
let [roles, setRoles] = useState(null);
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState(false);
let [banReasonDirty, setBanReasonDirty] = useState(false);
let [applyingRoles, setApplyingRoles] = useState(false);
let [rolesError, setRolesError] = useState();
let [banUserPopover, setBanUserPopover] = useState(false);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
@@ -58,6 +49,16 @@ export default function UserViewPage() {
});
}, []);
useEffect(() => {
if (
account.hasPermission("core.user.set_banned")
&& !banReasonDirty
&& user["ban_reason"] !== form.getFieldValue("ban_reason")
) {
form.setFieldsValue({ "ban_reason": user["ban_reason"] });
}
}, [account, form, user, banReasonDirty]);
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (user.loading) {
return <PageLayout nav={nav}>
@@ -104,10 +105,22 @@ export default function UserViewPage() {
</Select>
</Form.Item>;
function instanceName(id) {
let instance = instanceList.find(i => i.id === id);
return instance ? instance["name"] : id;
}
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={userName}
title={<Space>
{userName}
<span>
{user["is_admin"] && <Tag color="gold">Admin</Tag>}
{user["is_whitelisted"] && <Tag>Whitelisted</Tag>}
{user["is_banned"] && <Tag color="red">Banned</Tag>}
</span>
</Space>}
extra={
account.hasPermission("core.user.delete") && <Popconfirm
title={<>
@@ -131,11 +144,27 @@ export default function UserViewPage() {
</Popconfirm>
}
/>
<Form form={form}>
<Form
form={form}
labelCol={{
sm: { span: 6 },
md: { span: 6 },
lg: { span: 4 },
xl: { span: 3 },
xxl: { span: 2 },
}}
wrapperCol={{
sm: { span: 18 },
md: { span: 18 },
lg: { span: 20 },
xl: { span: 21 },
xxl: { span: 22 },
}}
>
<Form.Item
label="Roles"
>
<Row gutter={8}>
<Row gutter={[8, 8]}>
<Col flex="auto">
{account.hasPermission("core.user.update_roles")
? roleSelector
@@ -169,94 +198,142 @@ export default function UserViewPage() {
</Col>}
</Row>
</Form.Item>
<Form.Item label="In-game Admin">
{account.hasPermission("core.user.set_admin") && <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,
admin: !user["is_admin"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user admin status"));
}}
/>
</Form.Item>
<Form.Item label="Whitelisted">
</Form.Item>}
{account.hasPermission("core.user.set_whitelisted") && <Form.Item label="Whitelisted">
<Switch
checked={user["is_whitelisted"]}
disabled={!account.hasPermission("core.user.set_whitelisted")}
onClick={() => {
libLink.messages.setUserWhitelisted.send(control, {
name: userName,
whitelisted: !user["is_whitelisted"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user whitelisted status"));
}}
/>
</Form.Item>
<Form.Item label="Banned">
<Space>
{user["is_banned"] ? "Yes" : "No"}
{account.hasPermission("core.user.set_banned") && <Popover
title="Ban user"
visible={banUserPopover}
trigger="click"
onVisibleChange={() => {
if (!user["is_banned"]) {
setBanUserPopover(!banUserPopover);
}
}}
content={<Form.Item label="Reason">
<Row gutter={8}>
<Col flex="auto">
<Form.Item noStyle name="reason">
<Input/>
</Form.Item>
</Col>
<Col flex="0 0 auto">
</Form.Item>}
{account.hasPermission("core.user.set_banned")
? <Form.Item label="Ban reason">
<Row gutter={[8, 8]} justify={"end"}>
<Col flex="auto" style={{ minWidth: "20em" }}>
<Form.Item noStyle name="ban_reason" initialValue={user["ban_reason"]}>
<Input
onChange={e => {
setBanReasonDirty(e.target.value !== user["ban_reason"]);
}}
/>
</Form.Item>
</Col>
<Col flex="0 0 auto">
{user["is_banned"]
? <Space>
<Button
type="primary"
type={banReasonDirty ? "primary" : "default"}
onClick={() => {
let reason = form.getFieldValue("reason");
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: true,
reason,
reason: form.getFieldValue("ban_reason"),
}).then(() => {
setBanUserPopover(false);
updateUser();
}).catch(notifyErrorHandler("Error banning user"));
setBanReasonDirty(false);
}).catch(notifyErrorHandler("Error updating ban"));
}}
>Ban</Button>
</Col>
</Row>
</Form.Item>}
>
<Button
type="primary"
size="small"
onClick={() => {
if (user["is_banned"]) {
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: false,
reason: "",
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error pardoning user"));
}
}}
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
</Popover>}
</Space>
</Form.Item>
>Update</Button>
<Button
onClick={() => {
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: false,
reason: "",
}).catch(notifyErrorHandler("Error unbanning user"));
}}
>Unban</Button>
</Space>
: <Button
type={banReasonDirty ? "primary" : "default"}
onClick={() => {
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: true,
reason: form.getFieldValue("ban_reason"),
}).then(() => {
setBanReasonDirty(false);
}).catch(notifyErrorHandler("Error banning user"));
}}
>Ban</Button>
}
</Col>
</Row>
</Form.Item>
: user["is_banned"]
&& <Form.Item label="Ban reason">{user["ban_reason"]}</Form.Item>
}
</Form>
<SectionHeader title="Player stats" />
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
<Descriptions.Item label="Total online time">
{formatDuration(user["player_stats"]["online_time_ms"] || 0)}
</Descriptions.Item>
<Descriptions.Item label="Total join count">
{user["player_stats"]["join_count"] || 0}
</Descriptions.Item>
<Descriptions.Item label="Last seen">
{formatLastSeen(user) || " "}
</Descriptions.Item>
</Descriptions>
<SectionHeader title="Instance stats" />
<Table
size="small"
columns={[
{
title: "Instance",
key: "instance",
render: ([id]) => instanceName(id),
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a[0], b[0]),
},
{
title: "Online time",
key: "onlineTime",
render: ([, stats]) => formatDuration(stats["online_time_ms"] || 0),
sorter: (a, b) => (a[1]["online_time_ms"] || 0) - (b[1]["online_time_ms"] || 0),
},
{
title: "Join count",
key: "joinCoint",
render: ([, stats]) => stats["join_count"] || 0,
sorter: (a, b) => (a[1]["join_count"] || 0) - (b[1]["join_count"] || 0),
responsive: ["sm"],
},
{
title: "Last seen",
key: "lastSeen",
render: ([id]) => formatLastSeen(user, id),
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
},
]}
dataSource={user["instance_stats"]}
pagination={false}
rowKey={([id]) => id}
onRow={([id], rowIndex) => ({
onClick: event => {
history.push(`/instances/${id}/view`);
},
})}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
}
+26 -22
View File
@@ -1,13 +1,16 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { Button, Form, Input, Modal, PageHeader, Space, Table, Tag } from "antd";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { formatLastSeen, sortLastSeen, useUserList } from "../model/user";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
@@ -56,14 +59,11 @@ export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
let [userList] = useUserList();
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(() => {
@@ -81,7 +81,15 @@ export default function UsersPage() {
columns={[
{
title: "Name",
dataIndex: "name",
key: "name",
render: user => <Space>
{user["name"]}
<span>
{user["is_admin"] && <Tag color="gold">Admin</Tag>}
{user["is_whitelisted"] && <Tag>Whitelisted</Tag>}
{user["is_banned"] && <Tag color="red">Banned</Tag>}
</span>
</Space>,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
@@ -91,28 +99,23 @@ export default function UsersPage() {
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"],
title: "Online time",
key: "onlineTime",
render: user => user["player_stats"]["online_time_ms"]
&& formatDuration(user["player_stats"]["online_time_ms"]),
sorter: (a, b) => (a["player_stats"]["online_time_ms"] || 0) -
(b["player_stats"]["online_time_ms"] || 0),
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"],
title: "Last seen",
key: "lastSeen",
render: user => formatLastSeen(user),
sorter: sortLastSeen,
responsive: ["lg"],
},
]}
dataSource={users}
dataSource={userList}
pagination={false}
rowKey={user => user["name"]}
onRow={(user, rowIndex) => ({
@@ -121,5 +124,6 @@ export default function UsersPage() {
},
})}
/>
<PluginExtra component="UsersPage" />
</PageLayout>;
}
+20
View File
@@ -294,3 +294,23 @@ body {
height: 32px;
width: 32px;
}
/*
* Render codeblock with options for syntax highlighting
* https://user-images.githubusercontent.com/6792749/152066701-21420461-710b-4fae-a80a-89ef90a0b18f.png
*/
.codeblock {
background-color: black;
padding: 8px;
line-height: 18px;
font-family: Courier;
position: relative;
}
.codeblock .highlight {
color: yellow;
}
.codeblock .copy-button {
position: absolute;
top: 8px;
right: 8px;
}
+1 -1
View File
@@ -9,7 +9,7 @@ import ControlContext from "../components/ControlContext";
* @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.
* @property {function()} logOut - Logs out of the web interface.
*/
export function useAccount() {
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
let exportManifestCache = null;
let emptyCache = {};
export function useExportManifest() {
let [exportManifest, setExportManifest] = useState(exportManifestCache || emptyCache);
useEffect(() => {
async function load() {
let response = await fetch(`${webRoot}api/export-manifest`);
if (response.ok) {
exportManifestCache = await response.json();
setExportManifest(exportManifestCache);
}
}
if (!exportManifestCache) {
load();
}
}, []);
return exportManifest;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState } from "react";
import { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import { libLink, libLogging } from "@clusterio/lib";
+10 -4
View File
@@ -1,12 +1,18 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let itemMetadataCache = null;
export function useItemMetadata() {
let exportManifest = useExportManifest();
let [itemMetadata, setItemMetadata] = useState(itemMetadataCache || new Map());
useEffect(() => {
async function load() {
let response = await fetch(`${webRoot}export/item-metadata.json`);
if (!exportManifest["item-metadata"] || !exportManifest["item-spritesheet"]) {
return;
}
let response = await fetch(`${staticRoot}${exportManifest["item-metadata"]}`);
if (response.ok) {
let data = await response.json();
itemMetadataCache = new Map(data);
@@ -16,7 +22,7 @@ export function useItemMetadata() {
for (let [name, meta] of itemMetadataCache) {
style.sheet.insertRule(
`.item-${CSS.escape(name)} {
background-image: url("${webRoot}export/item-spritesheet.png");
background-image: url("${staticRoot}${exportManifest["item-spritesheet"]}");
background-repeat: no-repeat;
background-position: -${meta.x}px -${meta.y}px;
height: ${meta.size}px;
@@ -31,7 +37,7 @@ export function useItemMetadata() {
if (!itemMetadataCache) {
load();
}
}, []);
}, [exportManifest]);
return itemMetadata;
}
+8 -2
View File
@@ -1,12 +1,18 @@
import React, { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let localeCache = null;
export function useLocale() {
let exportManifest = useExportManifest();
let [locale, setLocale] = useState(localeCache || new Map());
useEffect(() => {
async function load() {
let response = await fetch(`${webRoot}export/locale.json`);
if (!exportManifest["locale"]) {
return;
}
let response = await fetch(`${staticRoot}${exportManifest["locale"]}`);
if (response.ok) {
let data = await response.json();
localeCache = new Map(data);
@@ -17,7 +23,7 @@ export function useLocale() {
if (!localeCache) {
load();
}
}, []);
}, [exportManifest]);
return locale;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState } from "react";
import { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import { libLink, libLogging } from "@clusterio/lib";
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState } from "react";
import { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import { libLink } from "@clusterio/lib";
+128
View File
@@ -0,0 +1,128 @@
import React, { useEffect, useContext, useState } from "react";
import { Tag } from "antd";
import ControlContext from "../components/ControlContext";
import { libLink, libLogging } from "@clusterio/lib";
const { logger } = libLogging;
function calculateLastSeen(user, instanceId) {
let stats;
if (instanceId === undefined) {
stats = user["player_stats"];
} else {
let entry = (user["instance_stats"] || []).find(([id]) => id === instanceId);
if (!entry) {
return undefined;
}
stats = entry[1];
}
if (stats["last_leave_at"] > stats["last_join_at"]) {
return stats["last_leave_at"];
}
if (stats["last_join_at"]) {
return stats["last_leave_at"];
}
return undefined;
}
export function formatLastSeen(user, instanceId = undefined) {
if (user["instances"].some(id => instanceId === undefined || id === instanceId)) {
return <Tag color="green">Online</Tag>;
}
let lastSeen = calculateLastSeen(user, instanceId);
if (lastSeen === undefined) {
return undefined;
}
return new Date(lastSeen).toLocaleString();
}
export function sortLastSeen(userA, userB, instanceIdA = undefined, instanceIdB = undefined) {
function epoch(user, instanceId) {
return user["instances"].some(id => instanceId === undefined || id === instanceId);
}
let epochA = epoch(userA, instanceIdA);
let epochB = epoch(userB, instanceIdB);
if (epochA !== epochB) {
return epochA - epochB;
}
let lastSeenA = calculateLastSeen(userA, instanceIdA) || 0;
let lastSeenB = calculateLastSeen(userB, instanceIdB) || 0;
return lastSeenA - lastSeenB;
}
export function useUser(name) {
let control = useContext(ControlContext);
let [user, setUser] = useState({ loading: true });
function updateUser() {
libLink.messages.getUser.send(control, { name }).then(result => {
setUser({ ...result, present: true });
}).catch(err => {
logger.error(`Failed to get user: ${err}`);
setUser({ missing: true });
});
}
useEffect(() => {
if (typeof name !== "string") {
setUser({ missing: true });
return undefined;
}
updateUser();
function updateHandler(newUser) {
setUser({ ...newUser, present: true });
}
control.onUserUpdate(name, updateHandler);
return () => {
control.offUserUpdate(name, updateHandler);
};
}, [name]);
return [user, updateUser];
}
export function useUserList() {
let control = useContext(ControlContext);
let [userList, setUserList] = useState([]);
function updateUserList() {
libLink.messages.listUsers.send(control).then(result => {
setUserList(result.list);
}).catch(err => {
logger.error(`Failed to list users:\n${err}`);
});
}
useEffect(() => {
updateUserList();
function updateHandler(newUser) {
setUserList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.name === newUser.name);
if (!newUser.is_deleted) {
if (index !== -1) {
newList[index] = newUser;
} else {
newList.push(newUser);
}
} else if (index !== -1) {
newList.splice(index, 1);
}
return newList;
});
}
control.onUserUpdate(null, updateHandler);
return () => {
control.offUserUpdate(null, updateHandler);
};
}, []);
return [userList];
}
+33
View File
@@ -0,0 +1,33 @@
const formatSecond = new Intl.NumberFormat(undefined, { style: "unit", unit: "second", unitDisplay: "narrow" }).format;
const formatMinute = new Intl.NumberFormat(undefined, { style: "unit", unit: "minute", unitDisplay: "narrow" }).format;
const formatHour = new Intl.NumberFormat(undefined, { style: "unit", unit: "hour", unitDisplay: "narrow" }).format;
const formatDay = new Intl.NumberFormat(undefined, { style: "unit", unit: "day", unitDisplay: "narrow" }).format;
/**
* Formats a duration of time in milliseconds to something human readable.
* @param {number} ms - Duration to formatDuration
* @param {object=} options - Options to control the formatting.
* @returns {string} human readable representation of the duration.
*/
export function formatDuration(ms, options = {}) {
let result = "";
if (ms < 0) {
result += "-";
ms = -ms;
}
if (ms > 86400e3) {
result += formatDay(Math.floor(ms / 86400e3));
ms %= 86400e3;
}
if (ms > 3600e3) {
result += formatHour(Math.floor(ms / 3600e3));
ms %= 3600e3;
}
if (ms > 60e3) {
result += formatMinute(Math.floor(ms / 60e3));
ms %= 60e3;
}
result += formatSecond(Math.round(ms / 1e3));
return result;
}
+61
View File
@@ -86,6 +86,7 @@ export class Control extends libLink.Link {
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.userUpdateHandlers = new Map();
this.logHandlers = new Map();
this.connector.on("connect", data => {
@@ -101,6 +102,9 @@ export class Control extends libLink.Link {
this.updateSaveListSubscriptions().catch(err => logger.error(
`Unexpected error updating save list subscriptions:\n${err.stack}`
));
this.updateUserSubscriptions().catch(err => logger.error(
`Unexpected error updating user subscriptions:\n${err.stack}`
));
this.updateLogSubscriptions().catch(err => logger.error(
`Unexpected error updating log subscriptions:\n${err.stack}`
));
@@ -315,6 +319,63 @@ export class Control extends libLink.Link {
});
}
async userUpdateEventHandler(message) {
let handlers = [].concat(
this.userUpdateHandlers.get(null) || [],
this.userUpdateHandlers.get(message.data.name) || [],
);
for (let handler of handlers) {
handler(message.data);
}
};
async onUserUpdate(name, handler) {
if (name !== null && typeof name !== "string") {
throw new Error("Invalid user name");
}
let handlers = this.userUpdateHandlers.get(name);
if (!handlers) {
handlers = [];
this.userUpdateHandlers.set(name, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateUserSubscriptions();
}
}
async offUserUpdate(name, handler) {
let handlers = this.userUpdateHandlers.get(name);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for user ${name} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for user ${name}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
this.userUpdateHandlers.delete(name);
await this.updateUserSubscriptions();
}
}
async updateUserSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setUserSubscriptions.send(this, {
all: this.userUpdateHandlers.has(null),
names: [...this.userUpdateHandlers.keys()].filter(e => e !== null),
});
}
async logMessageEventHandler(message) {
let info = message.data.info;
+13 -2
View File
@@ -1,6 +1,7 @@
"use strict";
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
module.exports = (env = {}) => ({
mode: env.production ? "production" : "development",
@@ -9,8 +10,14 @@ module.exports = (env = {}) => ({
maxAssetSize: 2**21,
maxEntrypointSize: 2**21,
},
output: {
publicPath: "auto",
assetModuleFilename: "static/[hash][ext][query]",
filename: "static/[name].[contenthash].js",
},
plugins: [
new CleanWebpackPlugin(),
new WebpackManifestPlugin({ publicPath: "" }),
// required for winston
new webpack.ProvidePlugin({
@@ -81,7 +88,7 @@ module.exports = (env = {}) => ({
},
{
test: /\.png$/,
use: require.resolve("file-loader"),
type: "asset/resource",
},
],
},
@@ -91,9 +98,10 @@ module.exports = (env = {}) => ({
// Required for winston
"util": require.resolve("util/"),
"os": require.resolve("os-browserify/browser"),
"buffer": require.resolve("buffer/"),
// Required for zlib
"assert": require.resolve("assert"),
"assert": require.resolve("assert/"),
"stream": require.resolve("stream-browserify"),
"events": require.resolve("events/"),
@@ -101,4 +109,7 @@ module.exports = (env = {}) => ({
"zlib": require.resolve("browserify-zlib"),
},
},
optimization: {
moduleIds: "deterministic",
},
});
+4 -4
View File
@@ -1,13 +1,13 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env production",
"prepare": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -20,8 +20,8 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.11",
"@clusterio/web_ui": "^2.0.0-alpha.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
-3
View File
@@ -9,15 +9,12 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "global_chat",
library: { type: "var", name: "plugin_global_chat" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
"./package.json": "./package.json",
-1
View File
@@ -4,7 +4,6 @@ const path = require("path");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
async function loadDatabase(config, logger) {
let itemsPath = path.resolve(config.get("master.database_directory"), "inventories.json");
@@ -1,7 +1,7 @@
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local ensure_character = require("modules/inventory_sync/ensure_character")
local serialize = require("modules/inventory_sync/serialize")
local load_crafting_queue = require("modules/inventory_sync/load_crafting_queue")
local function download_inventory(player_name, data, number, total)
local player = game.get_player(player_name)
@@ -33,10 +33,8 @@ local function download_inventory(player_name, data, number, total)
-- Give the player a character and pretend that's the synced player data
ensure_character(player)
-- Restore player position in case they moved while being spectator
if record.surface and record.position then
player.teleport(record.position, record.surface)
end
-- Restore player position and driving state
restore_position(record, player)
global.inventory_sync.active_downloads[player_name] = nil
player_record.dirty = player.connected
@@ -58,84 +56,20 @@ local function download_inventory(player_name, data, number, total)
return
end
-- Download is complete
progress_dialog.remove(player)
-- 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, unable to switch " .. player.name .. " out of editor mode")
player.print("ERROR: Inventory sync failed, unable to switch out of editor mode")
return
end
-- Stash temporary inventory if it exists
local stashed_corpse
if player_record.dirty and player.character then
local character = player.character
local surface = character.surface
local position = character.position
local corpse_name = character.prototype.character_corpse.name
player.character = nil
character.die()
stashed_corpse = surface.find_entity(corpse_name, position)
end
-- Deserialize downloaded player data
local serialized_player = game.json_to_table(record.data)
serialize.deserialize_player(player, serialized_player)
-- Restore player position in case they moved while being spectator
if record.surface and record.position then
player.teleport(record.position, record.surface)
end
-- Load crafting queue
if player.character then
load_crafting_queue(serialized_player.crafting_queue, player)
end
-- Transfer items from stashed inventory
if stashed_corpse then
local main = player.get_main_inventory()
local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
if main then
for i = 1, #stash do
-- Try transfering a stack
local source = stash[i]
if source and source.valid_for_read then
local target = main.find_empty_stack()
if not target then
break
end
target.transfer_stack(source)
end
end
end
if stash.is_empty() then
stashed_corpse.destroy()
else
player.print(
"Your temprorary inventory did not fit into your synced one and the remaining items have " ..
"been placed in a corpse below you."
)
end
end
-- Player may have left before the download completed
player_record.dirty = player.connected
player_record.sync = true
player_record.generation = serialized_player.generation
-- Remove download session and data
-- Remove download session and store finished download
global.inventory_sync.active_downloads[player_name] = nil
global.inventory_sync.finished_downloads[player_name] = record
local ticks = game.ticks_played - record.started
player.print("Imported player data in " .. ticks .. " ticks")
log("Imported player data for " .. player_name .. " in " .. ticks .. " ticks")
if player.connected then
inventory_sync.finish_download(player, record)
end
end
return download_inventory
+152 -13
View File
@@ -8,8 +8,8 @@ When a player joins the game, request the player data from master and deserializ
]]
local clusterio_api = require("modules/clusterio/api")
local upload_inventory = require("modules/inventory_sync/upload_inventory")
local download_inventory = require("modules/inventory_sync/download_inventory")
local serialize = require("modules/inventory_sync/serialize")
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")
@@ -30,10 +30,10 @@ end
-- Remove all stored player data by this module
local function remove_player(player)
global.inventory_sync.saved_crafting_queue[player.name] = nil
global.inventory_sync.players_waiting_for_acquire[player.name] = nil
global.inventory_sync.players_in_cutscene_to_sync[player.name] = nil
global.inventory_sync.active_downloads[player.name] = nil
global.inventory_sync.finished_downloads[player.name] = nil
global.inventory_sync.active_uploads[player.name] = nil
global.inventory_sync.players[player.name] = nil
end
@@ -42,8 +42,110 @@ end
inventory_sync = {}
-- This function is called by instance.js as /sc inventory_sync.download_inventory("danielv", Escaped JSON string, package_number, total_packages_count) with data from the master
inventory_sync.download_inventory = download_inventory
-- This function is called internally when a player leaves the game for their inventory to upload to the master
inventory_sync.upload_inventory = upload_inventory
-- This function is called internally when a player leaves the game to
-- serialize the player for upload.
function inventory_sync.serialize_player(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
error("Unable to switch out of editor mode")
return
end
local serialized_player = serialize.serialize_player(player)
serialized_player.generation = player_record.generation
return serialized_player
end
local can_enter_vehicle = {
[defines.controllers.character] = true,
[defines.controllers.god] = true,
[defines.controllers.editor] = true,
}
local function restore_position(player, record)
if record.vehicle and record.vehicle.valid and can_enter_vehicle[player.controller_type] then
player.teleport(record.vehicle.position, record.vehicle.surface)
player.driving = true
-- Teleport to safe location if unable to enter vehicle
if not player.driving and player.controller_type == defines.controllers.character then
local safe_position = record.vehicle.surface.find_non_colliding_position(
player.character.name, player.position, 32, 1/8
)
if safe_position then
player.teleport(safe_position, player.surface)
end
end
elseif record.surface and record.position then
player.teleport(record.position, record.surface)
end
end
function inventory_sync.deserialize_player(player, finished_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
error("Unable to switch out of editor mode")
return
end
-- Stash temporary inventory if it exists
local stashed_corpse
local player_record = global.inventory_sync.players[player.name]
if player_record.dirty and player.character then
local character = player.character
local surface = character.surface
local position = character.position
local corpse_name = character.prototype.character_corpse.name
player.character = nil
character.die()
stashed_corpse = surface.find_entity(corpse_name, position)
end
-- Deserialize downloaded player data
local serialized_player = game.json_to_table(finished_record.data)
serialize.deserialize_player(player, serialized_player)
-- Restore player position and driving state
restore_position(player, finished_record)
-- Transfer items from stashed inventory
if stashed_corpse then
local main = player.get_main_inventory()
local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
if main then
for i = 1, #stash do
-- Try transfering a stack
local source = stash[i]
if source and source.valid_for_read then
local target = main.find_empty_stack()
if not target then
break
end
target.transfer_stack(source)
end
end
end
if stash.is_empty() then
stashed_corpse.destroy()
else
player.print(
"Your temprorary inventory did not fit into your synced one and the remaining items have " ..
"been placed in a corpse below you."
)
end
end
end
inventory_sync.events = {}
inventory_sync.events[clusterio_api.events.on_server_startup] = function(event)
@@ -51,9 +153,6 @@ inventory_sync.events[clusterio_api.events.on_server_startup] = function(event)
if not global.inventory_sync then
global.inventory_sync = {}
end
if not global.inventory_sync.saved_crafting_queue then
global.inventory_sync.saved_crafting_queue = {}
end
if not global.inventory_sync.players then
global.inventory_sync.players = {}
@@ -65,6 +164,9 @@ inventory_sync.events[clusterio_api.events.on_server_startup] = function(event)
if not global.inventory_sync.active_downloads then
global.inventory_sync.active_downloads = {}
end
if not global.inventory_sync.finished_downloads then
global.inventory_sync.finished_downloads = {}
end
if not global.inventory_sync.active_uploads then
global.inventory_sync.active_uploads = {}
end
@@ -146,6 +248,18 @@ function inventory_sync.sync_player(acquire_response)
return
end
local finished_record = global.inventory_sync.finished_downloads[acquire_response.player_name]
if finished_record then
if acquire_response.status == "acquired" and finished_record.generation == acquire_response.generation then
inventory_sync.finish_download(player, finished_record)
return
end
-- The acquisition either failed or the current finished download is
-- stale, delete it and try downloading it again.
global.inventory_sync.finished_downloads[acquire_response.player_name] = nil
end
local download_record = global.inventory_sync.active_downloads[acquire_response.player_name]
if download_record then
-- Ignore the response if it affirms the current active download
@@ -214,6 +328,20 @@ function inventory_sync.sync_player(acquire_response)
end
end
function inventory_sync.finish_download(player, finished_record)
local status, result = pcall(inventory_sync.deserialize_player, player, finished_record)
if not status then
log("ERROR: Deserializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Deserializing player data failed: " .. result)
end
global.inventory_sync.finished_downloads[player.name] = nil
local player_record = global.inventory_sync.players[player.name]
player_record.dirty = true
player_record.sync = true
player_record.generation = finished_record.generation
end
-- Download inventory from master
inventory_sync.events[defines.events.on_player_joined_game] = function(event)
-- Send acquire request even if an active download is currently in plogress
@@ -263,11 +391,19 @@ inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
end
player_record.generation = player_record.generation + 1
local status, result = pcall(inventory_sync.serialize_player, player, player_record)
if not status then
log("ERROR: Serializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Serializing player data failed: " .. result)
return
end
global.inventory_sync.active_uploads[player.name] = {
serialized = result,
last_attempt = game.ticks_played,
timeout = math.random(600, 1200), -- Start with 10-20 seconds for the timeout
}
inventory_sync.upload_inventory(player, player_record)
clusterio_api.send_json("inventory_sync_upload", result)
end
-- Invoked by the instance code to confirm the master has received the uploaded data
@@ -310,13 +446,16 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener
-- If this is a synced player turn them into a spectator while the
-- player data is downloading
if player_record.sync then
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
if character ~= nil then character.destroy() end
-- Store original position to teleport back to
record.surface = player.surface
record.position = player.position
if player.driving then
record.vehicle = player.vehicle
end
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
if character ~= nil then character.destroy() end
-- Indicate this state shouldn't be persisted
player_record.dirty = false
@@ -359,7 +498,7 @@ function inventory_sync.check_active_uploads()
log("Retrying upload for " .. player_name)
record.last_attempt = game.ticks_played
record.timeout = math.random(record.timeout, record.timeout * 2)
inventory_sync.upload_inventory(player, player_record)
clusterio_api.send_json("inventory_sync_upload", record.serialized)
end
end
end
@@ -1,27 +0,0 @@
-- Load crafting queue from a table
local function load_crafting_queue(crafting_queue, player)
local inventory = player.get_main_inventory()
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Add items to inventory
for _, item in pairs(crafting_queue.ingredients) do
inventory.insert(item)
end
-- Load crafting queue
for _, queueItem in pairs(crafting_queue.crafting_queue) do
-- Start crafting (consume items)
player.begin_crafting {
count = queueItem.count,
recipe = queueItem.recipe,
-- silent = true, -- Fail silently if items are missing
}
end
-- Remove extra inventory slots
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
end
return load_crafting_queue
@@ -1,26 +0,0 @@
local load_crafting_queue = require("modules/inventory_sync/load_crafting_queue")
local restore_crafts = {}
function restore_crafts.command(event)
if event.player_index then
local player = game.get_player(event.player_index)
local crafting_queue = global.inventory_sync.saved_crafting_queue[player.name]
if crafting_queue == nil then
player.print("No saved crafting queue")
return
end
load_crafting_queue(crafting_queue, player)
global.inventory_sync.saved_crafting_queue[player.name] = nil
end
end
function restore_crafts.add_commands()
commands.add_command("restore-crafts", "Load crafting queue from global", restore_crafts.command)
commands.add_command("crc", "Shorthand for /restore-crafts", restore_crafts.command)
end
return restore_crafts
@@ -1,103 +0,0 @@
local save_crafts = {}
function save_crafts.command(event)
if event.player_index then
local player = game.get_player(event.player_index)
local crafting_queue = {}
-- Avoid overwriting old save
if global.inventory_sync.saved_crafting_queue[player.name] ~= nil then
player.print("You already have a saved crafting queue, restore it with /restore-crafts")
return
end
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Save current items
local inventory = player.get_main_inventory()
local old_items = inventory.get_contents()
-- Cancel old crafts to get the items back
while player.crafting_queue_size > 0 do
local old_queue = player.crafting_queue
local queueItem = player.crafting_queue[1]
-- Cancel craft
player.cancel_crafting {
index = 1,
count = 1,
}
local rightmost_right_index = 0 -- 0 indexed since it is subtractive in a 1 indexed language
local new_queue = player.crafting_queue
while
new_queue ~= nil and
new_queue[#new_queue - rightmost_right_index] ~= nil and
new_queue[#new_queue - rightmost_right_index].count >= old_queue[#old_queue - rightmost_right_index].count
do
rightmost_right_index = rightmost_right_index + 1
end
local oldItem = old_queue[#old_queue - rightmost_right_index]
local newItem = nil
if new_queue ~= nil then
newItem = new_queue[#new_queue - rightmost_right_index]
end
-- Figure out how many items to add to queue
local added = oldItem.count
if newItem ~= nil then
added = oldItem.count - newItem.count
if oldItem.recipe ~= newItem.recipe then
log("ERROR: Old item "..oldItem.recipe.." is not equal "..newItem.recipe)
end
end
-- If the last item we added was of the same type, merge them in the queue
if #crafting_queue > 0 and crafting_queue[#crafting_queue].recipe == oldItem.recipe then
crafting_queue[#crafting_queue].count = crafting_queue[#crafting_queue].count + added
else
-- If the last item was of a different type, add a new item to the queue
table.insert(crafting_queue, {
recipe = oldItem.recipe,
count = added,
})
end
-- game.print("Saved craft "..oldItem.recipe)
end
-- Find amount of items added and remove from inventory
local new_items = inventory.get_contents()
local difference = {}
for k,v in pairs(new_items) do
local old_count = old_items[k]
local diff = v
if old_count ~= nil then
diff = diff - old_count
end
if diff > 0 then
local ingredient = {
name = k,
count = diff,
}
inventory.remove(ingredient)
table.insert(difference, ingredient)
end
end
-- Remove extra inventory slots
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
global.inventory_sync.saved_crafting_queue[player.name] = {
crafting_queue = crafting_queue,
ingredients = difference,
}
end
end
function save_crafts.add_commands()
commands.add_command("save-crafts", "Save crafting queue for transport to a different server", save_crafts.command)
commands.add_command("csc", "Shorthand for /save-crafts", save_crafts.command)
end
return save_crafts
+141 -5
View File
@@ -75,9 +75,11 @@ function serialize.serialize_personal_logistic_slots(player)
local serialized = nil
-- Serialize personal logistic slots
for i = 1, 200 do -- Nobody will have more than 200 logistic slots, right?
local last_valid = 0
for i = 1, 65536 do
local slot = player.get_personal_logistic_slot(i)
if slot.name ~= nil then
if slot.name then
last_valid = i
if not serialized then
serialized = {}
end
@@ -86,6 +88,10 @@ function serialize.serialize_personal_logistic_slots(player)
min = slot.min,
max = slot.max,
}
-- Stop after 100 empty slots
elseif last_valid + 100 <= i then
break
end
end
@@ -98,14 +104,132 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
end
-- Load personal logistic slots
for i = 1, 200 do
local slot = serialized[tostring(i)] -- 1 is empty to force array to be spare
for i, slot in pairs(serialized) do
if slot ~= nil then
player.set_personal_logistic_slot(i, slot)
player.set_personal_logistic_slot(tonumber(i), slot)
end
end
end
-- Crafting queue is a table with the following fields:
-- crafting_queue
-- ingredients
function serialize.serialize_crafting_queue(player)
local crafting_queue = {}
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Save current items
local inventory = player.get_main_inventory()
local old_items = inventory.get_contents()
-- Cancel old crafts to get the items back
while player.crafting_queue_size > 0 do
local old_queue = player.crafting_queue
local queueItem = player.crafting_queue[1]
-- Cancel craft
player.cancel_crafting {
index = 1,
count = 1,
}
local rightmost_right_index = 0 -- 0 indexed since it is subtractive in a 1 indexed language
local new_queue = player.crafting_queue
while
new_queue ~= nil and
new_queue[#new_queue - rightmost_right_index] ~= nil and
new_queue[#new_queue - rightmost_right_index].count >= old_queue[#old_queue - rightmost_right_index].count
do
rightmost_right_index = rightmost_right_index + 1
end
local oldItem = old_queue[#old_queue - rightmost_right_index]
local newItem = nil
if new_queue ~= nil then
newItem = new_queue[#new_queue - rightmost_right_index]
end
-- Figure out how many items to add to queue
local added = oldItem.count
if newItem ~= nil then
added = oldItem.count - newItem.count
if oldItem.recipe ~= newItem.recipe then
log("ERROR: Old item "..oldItem.recipe.." is not equal "..newItem.recipe)
end
end
-- If the last item we added was of the same type, merge them in the queue
if #crafting_queue > 0 and crafting_queue[#crafting_queue].recipe == oldItem.recipe then
crafting_queue[#crafting_queue].count = crafting_queue[#crafting_queue].count + added
else
-- If the last item was of a different type, add a new item to the queue
table.insert(crafting_queue, {
recipe = oldItem.recipe,
count = added,
})
end
-- game.print("Saved craft "..oldItem.recipe)
end
-- Find amount of items added and remove from inventory
local new_items = inventory.get_contents()
local difference = {}
for k,v in pairs(new_items) do
local old_count = old_items[k]
local diff = v
if old_count ~= nil then
diff = diff - old_count
end
if diff > 0 then
local ingredient = {
name = k,
count = diff,
}
inventory.remove(ingredient)
table.insert(difference, ingredient)
end
end
-- Remove extra inventory slots
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
local serialized = {
crafting_queue = crafting_queue,
ingredients = difference,
}
-- Restore the crafting queue that was just destructively serialized
serialize.deserialize_crafting_queue(player, serialized)
return serialized
end
function serialize.deserialize_crafting_queue(player, serialized)
local inventory = player.get_main_inventory()
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Add items to inventory
for _, item in pairs(serialized.ingredients) do
inventory.insert(item)
end
-- Load crafting queue
for _, queueItem in pairs(serialized.crafting_queue) do
-- Start crafting (consume items)
player.begin_crafting {
count = queueItem.count,
recipe = queueItem.recipe,
-- silent = true, -- Fail silently if items are missing
}
end
-- Remove extra inventory slots
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
end
local controller_to_name = {}
for name, value in pairs(defines.controllers) do
controller_to_name[value] = name
@@ -125,6 +249,7 @@ end
-- inventories: non-character inventories
-- hotbar: table of string indexes to names (optional)
-- personal_logistic_slots (optional)
-- crafting_queue (optional)
function serialize.serialize_player(player)
local serialized = {
controller = controller_to_name[player.controller_type],
@@ -173,6 +298,11 @@ function serialize.serialize_player(player)
end
end
-- Serialize crafting queue
if player.character then
serialized.crafting_queue = serialize.serialize_crafting_queue(player)
end
return serialized
end
@@ -248,6 +378,12 @@ function serialize.deserialize_player(player, serialized)
end
end
end
-- Deserialize crafting queue
if player.character and serialized.crafting_queue then
serialize.deserialize_crafting_queue(player, serialized.crafting_queue)
end
end
return serialize
@@ -1,31 +0,0 @@
local clusterio_api = require("modules/clusterio/api")
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
-- Serialize crafting queue saved by /csc or /save-crafts OR automatically
if player.character then
save_crafts.command({player_index = player.name})
end
serialized_player.crafting_queue = global.inventory_sync.saved_crafting_queue[player.name]
global.inventory_sync.saved_crafting_queue[player.name] = nil
clusterio_api.send_json("inventory_sync_upload", serialized_player)
end
return upload_inventory
+4 -4
View File
@@ -1,13 +1,13 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.11",
"version": "2.0.0-alpha.13",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"scripts": {
"build": "webpack-cli --env production",
"prepare": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -23,8 +23,8 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.11",
"@clusterio/web_ui": "^2.0.0-alpha.11",
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
-3
View File
@@ -9,15 +9,12 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "inventory_sync",
library: { type: "var", name: "plugin_inventory_sync" },
filename: "remoteEntry.js",
exposes: {
"./info": "./info.js",
"./package.json": "./package.json",
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more