mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
216
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb2365c9a | ||
|
|
259d18eb30 | ||
|
|
04852196ad | ||
|
|
fc1daa7e71 | ||
|
|
e234d3db36 | ||
|
|
7b8127fef8 | ||
|
|
db84b5a6ce | ||
|
|
c79ced28dd | ||
|
|
2408dc8985 | ||
|
|
2953eb17d6 | ||
|
|
c9619397b6 | ||
|
|
c8a6b8094e | ||
|
|
7ee9c60e38 | ||
|
|
4b0a33174d | ||
|
|
db27650b60 | ||
|
|
aadf24db99 | ||
|
|
2496e756f1 | ||
|
|
5ba9f94b7e | ||
|
|
9fa41e37f3 | ||
|
|
d184b9db81 | ||
|
|
08e7434d24 | ||
|
|
0245ccd16c | ||
|
|
deb6842a4d | ||
|
|
ac62f66aa1 | ||
|
|
097827b515 | ||
|
|
9a3edc8c11 | ||
|
|
096f338277 | ||
|
|
db562ce6a0 | ||
|
|
8017497bdd | ||
|
|
3cb9843f23 | ||
|
|
a063cf2db5 | ||
|
|
72357dfd23 | ||
|
|
0df6701326 | ||
|
|
f5e558cb2f | ||
|
|
995f0934ed | ||
|
|
ef74436cde | ||
|
|
51c025c79b | ||
|
|
12767fd58c | ||
|
|
826d89c7dc | ||
|
|
393652c1d9 | ||
|
|
246ce73d94 | ||
|
|
951bad4134 | ||
|
|
ae17b00b6f | ||
|
|
36808b34de | ||
|
|
105f99ced3 | ||
|
|
430a64722e | ||
|
|
7e9604a254 | ||
|
|
5369f283d7 | ||
|
|
654465e4eb | ||
|
|
c5a0c0a873 | ||
|
|
16a466aed0 | ||
|
|
3e836c1dd3 | ||
|
|
ddf15f9b92 | ||
|
|
54897c1447 | ||
|
|
2a221456d9 | ||
|
|
48a57bf16c | ||
|
|
8742b4127c | ||
|
|
e32637503d | ||
|
|
2401fe25fa | ||
|
|
f0b60119cb | ||
|
|
4d5597a1da | ||
|
|
2973485321 | ||
|
|
1e4292876c | ||
|
|
ca6464baeb | ||
|
|
c2cb58d088 | ||
|
|
38c249ec70 | ||
|
|
2e1c1fd3c8 | ||
|
|
4edc7a2949 | ||
|
|
9b5c6f8554 | ||
|
|
0de964ec02 | ||
|
|
7db091d427 | ||
|
|
9cf7dea579 | ||
|
|
9465a488cc | ||
|
|
5e94198216 | ||
|
|
854d2b3c79 | ||
|
|
946847c95d | ||
|
|
ce95f868fd | ||
|
|
e5a63a3508 | ||
|
|
1e4d709a06 | ||
|
|
a93f048814 | ||
|
|
5319ff3907 | ||
|
|
68b8ac6b8c | ||
|
|
a979c0cfac | ||
|
|
3da8c30a64 | ||
|
|
f8c72a1e0a | ||
|
|
4a059efe18 | ||
|
|
dec5467538 | ||
|
|
61cd5bd76c | ||
|
|
1b1524e787 | ||
|
|
488d81f424 | ||
|
|
1ff1e3e5f8 | ||
|
|
decee0ecc2 | ||
|
|
170a3d3cf7 | ||
|
|
601ebba919 | ||
|
|
fd1f3329bd | ||
|
|
fd72cd4fd5 | ||
|
|
200ccf8e06 | ||
|
|
285d71e13d | ||
|
|
8de76b5cfe | ||
|
|
8ecc6d1c83 | ||
|
|
6da7b84757 | ||
|
|
072e95046a | ||
|
|
5aa4d99ea3 | ||
|
|
f1ac22881c | ||
|
|
fb85869d09 | ||
|
|
c69672ec60 | ||
|
|
3e1a41393a | ||
|
|
e9225a4b2c | ||
|
|
df80effbdf | ||
|
|
35a60e798e | ||
|
|
dfd0cb2d36 | ||
|
|
da941a299c | ||
|
|
81e3c9e19b | ||
|
|
b9e8ffa3d1 | ||
|
|
62efc63830 | ||
|
|
e6e5805a49 | ||
|
|
a3d98595e2 | ||
|
|
6af34dc3a6 | ||
|
|
1cf3e39d72 | ||
|
|
b192c82dcc | ||
|
|
9138efca6f | ||
|
|
52238de9b3 | ||
|
|
cc064c2fd1 | ||
|
|
baf7dbfe0d | ||
|
|
0bc6cf1178 | ||
|
|
8638632600 | ||
|
|
6d5d9ced47 | ||
|
|
e825f643e2 | ||
|
|
c4f656996e | ||
|
|
103c0e3820 | ||
|
|
6da349c888 | ||
|
|
15c3bb81a6 | ||
|
|
4a628df108 | ||
|
|
25ae9b9a0d | ||
|
|
6b10f2ab7d | ||
|
|
770e6a41d4 | ||
|
|
375f591fe5 | ||
|
|
fcdbf59f7a | ||
|
|
6c80227103 | ||
|
|
0016c6271a | ||
|
|
c557b7d6b2 | ||
|
|
64c328c53d | ||
|
|
e5994287fb | ||
|
|
7126014619 | ||
|
|
3b8e89ea00 | ||
|
|
e72ee3ecaf | ||
|
|
31cfc45e58 | ||
|
|
834fc8cf98 | ||
|
|
eb3b7df390 | ||
|
|
76882ad1c4 | ||
|
|
15381ac260 | ||
|
|
b7e6acb24e | ||
|
|
b86305db8c | ||
|
|
fe803a99ca | ||
|
|
4612826664 | ||
|
|
a5a99e2ab1 | ||
|
|
56584a25c8 | ||
|
|
03ba7cb68c | ||
|
|
cc605250db | ||
|
|
ae34a86cbf | ||
|
|
86de5ae151 | ||
|
|
bcc25047f2 | ||
|
|
f5ce0a8a8c | ||
|
|
55bbc10986 | ||
|
|
4fef2d8143 | ||
|
|
4432a689bb | ||
|
|
6013fcd528 | ||
|
|
71fb8541d5 | ||
|
|
4398d33fea | ||
|
|
92814aa9b3 | ||
|
|
69d4a883ad | ||
|
|
2e5617c4b5 | ||
|
|
f930ed36a0 | ||
|
|
83b720eade | ||
|
|
c3a1b420ea | ||
|
|
c889d8f325 | ||
|
|
e23d07e965 | ||
|
|
08a8182173 | ||
|
|
9e7a7a602f | ||
|
|
6e5e5f0aeb | ||
|
|
7769c1f89e | ||
|
|
a2a5cf6d4c | ||
|
|
77e9b929dc | ||
|
|
b6306031dc | ||
|
|
0564eee400 | ||
|
|
f548ac2ecc | ||
|
|
c9fd99589b | ||
|
|
23d8a819af | ||
|
|
14b3331c8e | ||
|
|
f266c441db | ||
|
|
ed73e3ad1e | ||
|
|
4f9a8fb45c | ||
|
|
6910737374 | ||
|
|
bdfe8e630e | ||
|
|
66719375af | ||
|
|
ef3d5209eb | ||
|
|
e4eac3f9ad | ||
|
|
107284e796 | ||
|
|
bad86a5ff2 | ||
|
|
034ca4c250 | ||
|
|
2b101d594a | ||
|
|
c40362fa7f | ||
|
|
e8b515c733 | ||
|
|
e5582b5558 | ||
|
|
3b6ebeb653 | ||
|
|
bfec83996a | ||
|
|
4a72c14b74 | ||
|
|
f529457f96 | ||
|
|
642504874b | ||
|
|
d99e160a27 | ||
|
|
714e590496 | ||
|
|
bfcb57002d | ||
|
|
8b59c89322 | ||
|
|
31e9f59d8d | ||
|
|
0c74aef68e | ||
|
|
18e9fc8e7c |
No files matched your search
+1
-4
@@ -6,10 +6,7 @@ module.exports = {
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11,
|
||||
"es2022": true,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `master` as Docker `latest` image.
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
# Run tests for any PRs.
|
||||
pull_request:
|
||||
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
# TODO: Change variable to your image's name.
|
||||
IMAGE_NAME: clusterio
|
||||
|
||||
jobs:
|
||||
# Build dependencies and clusterio image
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build docker image
|
||||
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_final
|
||||
|
||||
- name: Export docker image
|
||||
run: docker save -o clusterio_docker_image $IMAGE_NAME
|
||||
|
||||
- name: Upload docker image
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: clusterio_docker_image
|
||||
# Build dependencies and clusterio image with test tooling
|
||||
build_testing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build docker image
|
||||
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_testing
|
||||
|
||||
- name: Export docker image
|
||||
run: docker save -o clusterio_docker_image_testing $IMAGE_NAME
|
||||
|
||||
- name: Upload docker image
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image_testing
|
||||
path: clusterio_docker_image_testing
|
||||
# Upload clusterio_lib and subspace_storage versions
|
||||
upload_mods:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download docker image
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: .
|
||||
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image
|
||||
|
||||
- name: Create container
|
||||
run: docker create --name clusterio_container $IMAGE_NAME
|
||||
|
||||
- name: Move mods out of container
|
||||
run: docker cp clusterio_container:/clusterio/sharedMods ./mods
|
||||
|
||||
- name: Remove container
|
||||
run: docker rm clusterio_container
|
||||
|
||||
- name: List extracted mods
|
||||
run: ls mods
|
||||
|
||||
- name: Upload mods
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: mods
|
||||
path: mods
|
||||
# Run tests.
|
||||
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
|
||||
test:
|
||||
needs: build_testing
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download docker image
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image_testing
|
||||
path: .
|
||||
- name: ls
|
||||
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/clusterio && ls /home/runner/work/clusterio/clusterio
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image_testing
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -f docker-compose.test.yml ]; then
|
||||
docker-compose --file docker-compose.test.yml run unit_test
|
||||
else
|
||||
docker build . --file Dockerfile
|
||||
fi
|
||||
# Push image to GitHub Packages.
|
||||
# See also https://docs.docker.com/docker-hub/builds/
|
||||
push:
|
||||
# Ensure test job passes before pushing image.
|
||||
needs: [test, build]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: .
|
||||
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.actor }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "master" ] && VERSION=latest
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
@@ -0,0 +1,54 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x, 14.x, 16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- 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: 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: 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: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -39,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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<!--
|
||||
|
||||
+29
-14
@@ -86,19 +86,20 @@ Defaults to null which means generate a secure secret on startup.
|
||||
### master.heartbeat_interval
|
||||
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
A lower value means less time between connections going stale and them being closed.
|
||||
|
||||
Defaults to 45.
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### master.connector_shutdown_timeout
|
||||
### master.session_timeout
|
||||
|
||||
Timeout in seconds to use while shutting down the master server for link sessions that currently don't have an active connection and haven't been closed down properly.
|
||||
This happens when the network between a slave and the master server goes down unexpectedly, or the slave goes down without closing the connection properly either do to crashing or the machine it's hosted on going down.
|
||||
If the timeout expires the session is terminated and data loss may occur, if the slave went down this is unavoidable.
|
||||
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
|
||||
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
|
||||
In the case of the master or client crashing the data loss is unavoidable.
|
||||
|
||||
Defaults to 30.
|
||||
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `master.heartbeat_interval`.
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### master.metrics_timeout
|
||||
@@ -197,12 +198,12 @@ This is used by plugins like Server Select to give the correct address to switch
|
||||
Defaults to "localhost".
|
||||
|
||||
|
||||
### slave.reconnect_delay
|
||||
### slave.max_reconnect_delay
|
||||
|
||||
Delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all slaves trying to reconnect at the same time.
|
||||
Maximum delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 5.
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
@@ -270,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.
|
||||
@@ -399,8 +413,9 @@ If you have a self signed certificate on the master server you will need to copy
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### control.reconnect_delay
|
||||
### control.max_reconnect_delay
|
||||
|
||||
Duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
|
||||
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 2.
|
||||
Defaults to 60.
|
||||
+12
-11
@@ -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.
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ The following messages are the only valid messages that can be sent during the h
|
||||
Sent by the master server when the WebSocket connection has been opened.
|
||||
|
||||
- `version: string` - The version of the master, e.g. "2.0.0".
|
||||
- `plugins: Object<string, string>` -
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
|
||||
|
||||
#### `register_slave`
|
||||
@@ -111,7 +111,7 @@ Slave handshake for establishing a new connection session.
|
||||
- `version: string` - The protocol version of the slave, e.g. "2.0.0".
|
||||
- `name: string` - Name of the slave.
|
||||
- `id: integer` - ID of the slave.
|
||||
- `plugins: Object<string, string>` -
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are available on the slave.
|
||||
|
||||
#### `register_control`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
@@ -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.
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"plugins/*",
|
||||
"test"
|
||||
],
|
||||
"version": "2.0.0-alpha.9"
|
||||
}
|
||||
+19
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
+69
-16
@@ -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");
|
||||
@@ -15,6 +14,7 @@ const yargs = require("yargs");
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
const availablePlugins = [
|
||||
@@ -85,9 +85,14 @@ class LineSplitter extends stream.Transform {
|
||||
class InstallError extends Error { }
|
||||
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let temporary = `${file}.tmp`;
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
const asyncExec = util.promisify(child_process.execFile);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
@@ -119,6 +124,13 @@ async function execSlave(args) {
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "ctl"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
|
||||
function validateSlaveToken(token) {
|
||||
let parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
@@ -156,9 +168,22 @@ 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 fs.writeFile("package.json", JSON.stringify({
|
||||
await safeOutputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -206,34 +231,45 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriomaster.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Master
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=nogroup
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
@@ -247,24 +283,24 @@ WantedBy=multi-user.target
|
||||
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusterioslave.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Slave
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=nogroup
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
@@ -350,7 +386,7 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
|
||||
if (["standalone", "slave"].includes(answers.mode)) {
|
||||
let myIp;
|
||||
let myIp = "localhost";
|
||||
if (args.publicAddress) {
|
||||
answers.publicAddress = args.publicAddress;
|
||||
} else {
|
||||
@@ -420,7 +456,11 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) { answers.plugins = []; }
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
@@ -463,7 +503,7 @@ async function downloadLinuxServer() {
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
res.pipe(writeStream);
|
||||
|
||||
await events.once(res, "end");
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
@@ -514,10 +554,15 @@ async function main() {
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
});
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
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. " +
|
||||
@@ -534,6 +579,7 @@ async function main() {
|
||||
|
||||
if (!dev) {
|
||||
await validateInstallDir();
|
||||
await validateNotRoot(args);
|
||||
}
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
@@ -566,6 +612,7 @@ async function main() {
|
||||
let slaveToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
await execSlave(["config", "set", "slave.master_token", slaveToken]);
|
||||
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
|
||||
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
@@ -576,6 +623,7 @@ async function main() {
|
||||
await execSlave(["config", "set", "slave.name", answers.slaveName]);
|
||||
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
|
||||
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
|
||||
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
|
||||
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
@@ -584,6 +632,11 @@ async function main() {
|
||||
await writeScripts(answers.mode);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
await execCtl(["control-config", "set", "control.master_url", answers.masterUrl]);
|
||||
await execCtl(["control-config", "set", "control.master_token", answers.masterToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log(`Successfully installed ${answers.mode}`);
|
||||
if (adminToken) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
+6
-872
@@ -8,12 +8,7 @@
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
const version = require("./package").version;
|
||||
const asTable = require("as-table").configure({ delimiter: " | " });
|
||||
const events = require("events");
|
||||
const path = require("path");
|
||||
const winston = require("winston");
|
||||
const setBlocking = require("set-blocking");
|
||||
const phin = require("phin");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
@@ -24,849 +19,8 @@ const libCommand = require("@clusterio/lib/command");
|
||||
const libSharedCommands = require("@clusterio/lib/shared_commands");
|
||||
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
const libFactorio = require("@clusterio/lib/factorio");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
|
||||
|
||||
function print(...content) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
|
||||
|
||||
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
|
||||
const masterConfigCommands = new libCommand.CommandTree({
|
||||
name: "config", alias: ["c"], description: "master config management",
|
||||
});
|
||||
masterConfigCommands.add(new libCommand.Command({
|
||||
definition: ["list", "List master configuration"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.getMasterConfig.send(control);
|
||||
|
||||
for (let group of response.serialized_config.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
print(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
masterConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set <field> [value]", "Set field in master config", (yargs) => {
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (args.stdin) {
|
||||
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await libLink.messages.setMasterConfigField.send(control, {
|
||||
field: args.field,
|
||||
value: args.value,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
masterConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set-prop <field> <prop> [value]", "Set property of field in master config", (yargs) => {
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (args.stdin) {
|
||||
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
}
|
||||
let request = {
|
||||
field: args.field,
|
||||
prop: args.prop,
|
||||
};
|
||||
try {
|
||||
if (args.value !== undefined) {
|
||||
request.value = JSON.parse(args.value);
|
||||
}
|
||||
} catch (err) {
|
||||
// See note for the instance version of set-prop
|
||||
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) {
|
||||
throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`);
|
||||
}
|
||||
request.value = args.value;
|
||||
}
|
||||
await libLink.messages.setMasterConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
masterCommands.add(masterConfigCommands);
|
||||
|
||||
|
||||
const masterPluginCommands = new libCommand.CommandTree({
|
||||
name: "plugin", alias: ["p"], description: "master plugin inspection",
|
||||
});
|
||||
masterPluginCommands.add(new libCommand.Command({
|
||||
definition: ["list", "List plugins on master"],
|
||||
handler: async function(args, control) {
|
||||
let url = new URL(control.config.get("control.master_url"));
|
||||
url.pathname += "api/plugins";
|
||||
let response = await phin({
|
||||
url,
|
||||
parse: "json",
|
||||
core: { ca: control.tlsCa },
|
||||
});
|
||||
print(asTable(response.body));
|
||||
},
|
||||
}));
|
||||
masterCommands.add(masterPluginCommands);
|
||||
|
||||
|
||||
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List slaves connected to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listSlaves.send(control);
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: ["generate-token", "Generate token for a slave", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let slaveId = typeof args.id === "number" ? args.id : null;
|
||||
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId });
|
||||
print(response.token);
|
||||
},
|
||||
}));
|
||||
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: ["create-config", "Create slave config", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null });
|
||||
yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null });
|
||||
yargs.option("generate-token", {
|
||||
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
|
||||
});
|
||||
yargs.option("output", {
|
||||
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.createSlaveConfig.send(control, {
|
||||
id: args.id, name: args.name, generate_token: args.generateToken,
|
||||
});
|
||||
|
||||
let content = JSON.stringify(response.serialized_config, null, 4);
|
||||
if (args.output === "-") {
|
||||
print(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.outputFile(args.output, content, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new libErrors.CommandError(`File ${args.output} already exists`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const instanceCommands = new libCommand.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List instances known to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listInstances.send(control);
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create an instance", (yargs) => {
|
||||
// XXX TODO: set any specific options?
|
||||
yargs.positional("name", { describe: "Instance name", type: "string" });
|
||||
yargs.options({
|
||||
"id": { type: "number", nargs: 1, describe: "Instance id" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceConfig = new libConfig.InstanceConfig("control");
|
||||
await instanceConfig.init();
|
||||
if (args.id) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
let serialized_config = instanceConfig.serialize("master");
|
||||
let response = await libLink.messages.createInstance.send(control, { serialized_config });
|
||||
},
|
||||
}));
|
||||
|
||||
const instanceConfigCommands = new libCommand.CommandTree({
|
||||
name: "config", alias: ["c"], description: "Instance config management",
|
||||
});
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
|
||||
|
||||
for (let group of response.serialized_config.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
print(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
if (args.stdin) {
|
||||
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await libLink.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: args.field,
|
||||
value: args.value,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
if (args.stdin) {
|
||||
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
}
|
||||
let request = {
|
||||
instance_id: instanceId,
|
||||
field: args.field,
|
||||
prop: args.prop,
|
||||
};
|
||||
try {
|
||||
if (args.value !== undefined) {
|
||||
request.value = JSON.parse(args.value);
|
||||
}
|
||||
} catch (err) {
|
||||
// If this is from stdin or looks like an array, object or string
|
||||
// literal throw the parse error, otherwise assume this is a string.
|
||||
// The resoning behind this is that correctly quoting the string
|
||||
// with the all the layers of quote removal at play is difficult.
|
||||
// See the following table for how to pass "That's a \" quote" in
|
||||
// different environments:
|
||||
// cmd : """""That's a \\"" quote"""""
|
||||
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
|
||||
// PowerShell : '"""""That''s a \\"" quote"""""'
|
||||
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
|
||||
// bash : '""That'\''s a \" quote""'
|
||||
// bash + npx : '""That'\''s a \" quote""'
|
||||
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
|
||||
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) {
|
||||
throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`);
|
||||
}
|
||||
request.value = args.value;
|
||||
}
|
||||
await libLink.messages.setInstanceConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["assign <instance> [slave]", "Assign instance to a slave", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
|
||||
yargs.positional("slave", { describe: "Slave to assign to or unassign if none", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let slaveId = args.slave ? await libCommand.resolveSlave(control, args.slave) : null;
|
||||
await libLink.messages.assignInstanceCommand.send(control, {
|
||||
instance_id: instanceId,
|
||||
slave_id: slaveId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
async function loadMapSettings(args) {
|
||||
let seed = args.seed !== undefined ? args.seed : null;
|
||||
let mapGenSettings = null;
|
||||
let mapSettings = null;
|
||||
if (args.mapExchangeString) {
|
||||
let parsed = libFactorio.readMapExchangeString(args.mapExchangeString);
|
||||
mapGenSettings = parsed.map_gen_settings;
|
||||
mapSettings = parsed.map_settings;
|
||||
}
|
||||
if (args.mapGenSettings) {
|
||||
mapGenSettings = JSON.parse(await fs.readFile(args.mapGenSettings));
|
||||
}
|
||||
if (args.mapSettings) {
|
||||
mapSettings = JSON.parse(await fs.readFile(args.mapSettings));
|
||||
}
|
||||
|
||||
return {
|
||||
seed,
|
||||
mapGenSettings,
|
||||
mapSettings,
|
||||
};
|
||||
}
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId });
|
||||
for (let entry of response.list) {
|
||||
entry.mtime = new Date(entry.mtime_ms).toLocaleString();
|
||||
delete entry.mtime_ms;
|
||||
}
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
|
||||
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
|
||||
yargs.options({
|
||||
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
|
||||
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
|
||||
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
|
||||
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.createSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
name: args.name,
|
||||
seed,
|
||||
map_gen_settings: mapGenSettings,
|
||||
map_settings: mapSettings,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
|
||||
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let filename = args.name || path.basename(args.filepath);
|
||||
if (!filename.endsWith(".zip")) {
|
||||
throw new libErrors.CommandError("Save name must end with .zip");
|
||||
}
|
||||
// phin doesn't support streaming requests :(
|
||||
let content = await fs.readFile(args.filepath);
|
||||
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let url = new URL(control.config.get("control.master_url"));
|
||||
url.pathname += "api/upload-save";
|
||||
url.searchParams.append("instance_id", instanceId);
|
||||
url.searchParams.append("filename", filename);
|
||||
|
||||
let result = await phin({
|
||||
url, method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": control.config.get("control.master_token"),
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
core: { ca: control.tlsCa },
|
||||
data: content,
|
||||
parse: "json",
|
||||
});
|
||||
|
||||
for (let error of result.body.errors || []) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
for (let requestError of result.body.request_errors || []) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
if (result.body.saves && result.body.saves.length) {
|
||||
logger.info(`Successfully uploaded as ${result.body.saves[0]}`);
|
||||
}
|
||||
|
||||
if ((result.body.errors || []).length || (result.body.request_errors || []).length) {
|
||||
throw new libErrors.CommandError("Uploading save failed");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["download-save <instance> <save>", "Download a save from an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
|
||||
yargs.positional("save", { describe: "Save to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let result = await libLink.messages.downloadSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save,
|
||||
});
|
||||
|
||||
let url = new URL(control.config.get("control.master_url"));
|
||||
url.pathname += `api/stream/${result.stream_id}`;
|
||||
let response = await phin({
|
||||
url, method: "GET",
|
||||
core: { ca: control.tlsCa },
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let stream;
|
||||
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
while (true) {
|
||||
try {
|
||||
stream = fs.createWriteStream(tempFilename, { flags: "wx" });
|
||||
await events.once(stream, "open");
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
response.pipe(stream);
|
||||
await events.once(stream, "finish");
|
||||
|
||||
let filename = await libFileOps.findUnusedName(".", args.save, ".zip");
|
||||
await fs.rename(tempFilename, filename);
|
||||
|
||||
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["delete-save <instance> <save>", "Delete a save from an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
|
||||
yargs.positional("save", { describe: "Save to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.deleteSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.exportData.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["start <instance>", "Start instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.startInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save || null,
|
||||
});
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
|
||||
yargs.options({
|
||||
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
|
||||
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
|
||||
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
|
||||
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
let response = await libLink.messages.loadScenario.send(control, {
|
||||
instance_id: instanceId,
|
||||
scenario: args.scenario,
|
||||
seed,
|
||||
map_gen_settings: mapGenSettings,
|
||||
map_settings: mapSettings,
|
||||
});
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["stop <instance>", "Stop instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.stopInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["delete <instance>", "Delete instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.deleteInstance.send(control, {
|
||||
instance_id: await libCommand.resolveInstance(control, args.instance),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
|
||||
yargs.positional("command", { describe: "command to send", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.sendRcon.send(control, {
|
||||
instance_id: await libCommand.resolveInstance(control, args.instance),
|
||||
command: args.command,
|
||||
});
|
||||
|
||||
// Factorio includes a newline in it's response output.
|
||||
process.stdout.write(response.result);
|
||||
},
|
||||
}));
|
||||
|
||||
const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" });
|
||||
permissionCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List permissions in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listPermissions.send(control);
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const roleCommands = new libCommand.CommandTree({ name: "role", description: "Role management" });
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List roles in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listRoles.send(control);
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create a new role", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of role to create", type: "string" });
|
||||
yargs.options({
|
||||
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
|
||||
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.createRole.send(control, {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
permissions: args.permissions,
|
||||
});
|
||||
logger.info(`Created role ID ${response.id}`);
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["edit <role>", "Edit existing role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to edit", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "New name for role", nargs: 1, type: "string" },
|
||||
"description": { describe: "New description for role", nargs: 1, type: "string" },
|
||||
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
|
||||
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
|
||||
"remove-perms": {
|
||||
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
|
||||
},
|
||||
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let role = await libCommand.retrieveRole(control, args.role);
|
||||
|
||||
if (args.name !== undefined) {
|
||||
role.name = args.name;
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
role.description = args.description;
|
||||
}
|
||||
if (args.addPerms) {
|
||||
role.permissions = role.permissions.concat(args.addPerms);
|
||||
}
|
||||
if (args.removePerms) {
|
||||
let perms = new Set(role.permissions);
|
||||
for (let perm of args.removePerms) {
|
||||
perms.delete(perm);
|
||||
}
|
||||
role.permissions = [...perms];
|
||||
}
|
||||
if (args.setPerms !== undefined) {
|
||||
role.permissions = args.setPerms;
|
||||
}
|
||||
await libLink.messages.updateRole.send(control, role);
|
||||
|
||||
if (args.grantDefault) {
|
||||
await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["delete <role>", "Delete role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let role = await libCommand.retrieveRole(control, args.role);
|
||||
await libLink.messages.deleteRole.send(control, { id: role.id });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" });
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List user in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listUsers.send(control);
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create a user", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of user to create", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.createUser.send(control, { name: args.name });
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
|
||||
yargs.options({
|
||||
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserAdmin.send(control, {
|
||||
name: args.user, create: args.create, admin: !args.revoke,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
|
||||
yargs.options({
|
||||
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserWhitelisted.send(control, {
|
||||
name: args.user, create: args.create, whitelisted: !args.remove,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
|
||||
yargs.options({
|
||||
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
|
||||
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserBanned.send(control, {
|
||||
name: args.user, create: args.create, banned: !args.pardon, reason: args.reason,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
|
||||
yargs.positional("roles", { describe: "roles to assign", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listRoles.send(control);
|
||||
|
||||
let resolvedRoles = [];
|
||||
for (let roleName of args.roles) {
|
||||
if (/^-?\d+$/.test(roleName)) {
|
||||
let roleId = parseInt(roleName, 10);
|
||||
resolvedRoles.push(roleId);
|
||||
|
||||
} else {
|
||||
let found = false;
|
||||
for (let role of response.list) {
|
||||
if (role.name === roleName) {
|
||||
resolvedRoles.push(role.id);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new libErrors.CommandError(`No role named ${roleName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles });
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["delete <user>", "Delete user", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.deleteUser.send(control, { name: args.user });
|
||||
},
|
||||
}));
|
||||
|
||||
const logCommands = new libCommand.CommandTree({ name: "log", description: "Log inspection" });
|
||||
logCommands.add(new libCommand.Command({
|
||||
definition: ["follow", "follow cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"master": { describe: "Follow log of the master server", nargs: 0, type: "boolean", default: false },
|
||||
"slave": { describe: "Follow log of given slave", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (!args.all && !args.master && !args.slave && !args.instance) {
|
||||
logger.error("At least one of --all, --master, --slave and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
|
||||
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
|
||||
await control.setLogSubscriptions({ all: args.all, master: args.master, slave_ids, instance_ids });
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
logCommands.add(new libCommand.Command({
|
||||
definition: ["query", "Query cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"master": { describe: "Query log of the master server", nargs: 0, type: "boolean", default: false },
|
||||
"slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
|
||||
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null },
|
||||
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
|
||||
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
if (!args.all && !args.master && !args.slave && !args.instance) {
|
||||
logger.error("At least one of --all, --master, --slave and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
|
||||
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
|
||||
let result = await libLink.messages.queryLog.send(control, {
|
||||
all: args.all,
|
||||
master: args.master,
|
||||
slave_ids,
|
||||
instance_ids,
|
||||
max_level: args.maxLevel,
|
||||
limit: args.limit,
|
||||
order: args.start ? "asc" : "desc",
|
||||
});
|
||||
|
||||
if (!args.start) {
|
||||
result.log.reverse();
|
||||
}
|
||||
|
||||
let stdoutLogger = winston.createLogger({
|
||||
level: "verbose",
|
||||
levels,
|
||||
format: new libLoggingUtils.TerminalFormat({ showTimestamp: true }),
|
||||
transports: [
|
||||
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
|
||||
],
|
||||
});
|
||||
for (let info of result.log) {
|
||||
stdoutLogger.log(info);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" });
|
||||
debugCommands.add(new libCommand.Command({
|
||||
definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.debugDumpWs.send(control);
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
const commands = require("./src/commands");
|
||||
|
||||
|
||||
/**
|
||||
@@ -927,12 +81,16 @@ class Control extends libLink.Link {
|
||||
this.keepOpen = false;
|
||||
}
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler() { }
|
||||
|
||||
async instanceUpdateEventHandler() { }
|
||||
|
||||
async saveListUpdateEventHandler() { }
|
||||
|
||||
async userUpdateEventHandler() { }
|
||||
|
||||
async setLogSubscriptions({
|
||||
all = false,
|
||||
master = false,
|
||||
@@ -985,30 +143,6 @@ async function loadPlugins(pluginList) {
|
||||
return controlPlugins;
|
||||
}
|
||||
|
||||
async function registerCommands(controlPlugins, yargs) {
|
||||
const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
|
||||
rootCommands.add(masterCommands);
|
||||
rootCommands.add(slaveCommands);
|
||||
rootCommands.add(instanceCommands);
|
||||
rootCommands.add(permissionCommands);
|
||||
rootCommands.add(roleCommands);
|
||||
rootCommands.add(userCommands);
|
||||
rootCommands.add(logCommands);
|
||||
rootCommands.add(debugCommands);
|
||||
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
await controlPlugin.addCommands(rootCommands);
|
||||
}
|
||||
|
||||
for (let [name, command] of rootCommands.subCommands) {
|
||||
if (name === command.name) {
|
||||
command.register(yargs);
|
||||
}
|
||||
}
|
||||
|
||||
return rootCommands;
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterioctl")
|
||||
@@ -1069,7 +203,7 @@ async function startControl() {
|
||||
let controlPlugins = await loadPlugins(pluginList);
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
let rootCommands = await registerCommands(controlPlugins, yargs);
|
||||
let rootCommands = await commands.registerCommands(controlPlugins, yargs);
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"version": "2.0.0-alpha.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.9",
|
||||
"@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
@@ -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;
|
||||
@@ -5,8 +5,12 @@ const fs = require("fs-extra");
|
||||
const JSZip = require("jszip");
|
||||
const klaw = require("klaw");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
async function buildMod(args, info) {
|
||||
if (args.build) {
|
||||
@@ -42,7 +46,7 @@ async function buildMod(args, info) {
|
||||
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
||||
console.log(`Writing ${modPath}`);
|
||||
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
|
||||
await events.once(writeStream, "finish");
|
||||
await finished(writeStream);
|
||||
|
||||
} else {
|
||||
let modDir = path.join(args.outputDir, modName);
|
||||
@@ -79,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}`);
|
||||
@@ -90,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,
|
||||
|
||||
@@ -15,7 +15,7 @@ MasterGroup.groupName = "master";
|
||||
MasterGroup.define({
|
||||
name: "name",
|
||||
title: "Name",
|
||||
description: "Name of the cluster",
|
||||
description: "Name of the cluster.",
|
||||
type: "string",
|
||||
initial_value: "Your Cluster",
|
||||
});
|
||||
@@ -83,7 +83,7 @@ MasterGroup.define({
|
||||
MasterGroup.define({
|
||||
name: "heartbeat_interval",
|
||||
title: "Heartbeat Interval",
|
||||
description: "Interval heartbeats are sent out on WebSocket connections",
|
||||
description: "Interval heartbeats are sent out on WebSocket connections.",
|
||||
type: "number",
|
||||
initial_value: 15,
|
||||
});
|
||||
@@ -97,21 +97,21 @@ MasterGroup.define({
|
||||
MasterGroup.define({
|
||||
name: "metrics_timeout",
|
||||
title: "Metrics Timeout",
|
||||
description: "Timeout in seconds for metrics gathering from slaves",
|
||||
description: "Timeout in seconds for metrics gathering from slaves.",
|
||||
type: "number",
|
||||
initial_value: 8,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "proxy_stream_timeout",
|
||||
title: "Proxy Stream Timeout",
|
||||
description: "Timeout in seconds for proxy streams to start flowing",
|
||||
description: "Timeout in seconds for proxy streams to start flowing.",
|
||||
type: "number",
|
||||
initial_value: 15,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "default_role_id",
|
||||
title: "Default role",
|
||||
description: "ID of role assigned by default to new users",
|
||||
description: "ID of role assigned by default to new users.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
initial_value: 1,
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const libFileOps = require("./file_ops");
|
||||
const { basicType } = require("./helpers");
|
||||
|
||||
|
||||
@@ -78,7 +79,7 @@ async function loadJsonAsMap(filePath) {
|
||||
*/
|
||||
async function saveMapAsJson(filePath, map) {
|
||||
let obj = mapToObject(map);
|
||||
await fs.outputFile(filePath, JSON.stringify(obj, null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, 4));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +139,7 @@ async function loadJsonArrayAsMap(filePath) {
|
||||
* @throws {Error} if an error occured writing to the file.
|
||||
*/
|
||||
async function saveMapAsJsonArray(filePath, map) {
|
||||
await fs.outputFile(filePath, JSON.stringify([...map.values()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, 4));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const ini = require("ini");
|
||||
const Jimp = require("jimp");
|
||||
const JSZip = require("jszip");
|
||||
|
||||
const libIni = require("../ini");
|
||||
const libBuildMod = require("../build_mod");
|
||||
|
||||
/**
|
||||
@@ -312,7 +312,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
|
||||
}
|
||||
|
||||
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
|
||||
mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
|
||||
mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
|
||||
|
||||
for (let mod of modOrder) {
|
||||
if (["core", "base", "export"].includes(mod)) {
|
||||
@@ -330,7 +330,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
|
||||
}
|
||||
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
|
||||
let content = await file.async("nodebuffer");
|
||||
mergeLocale(ini.parse(content.toString("utf8")));
|
||||
mergeLocale(libIni.parse(content.toString("utf8")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,9 +284,11 @@ async function patch(savePath, modules) {
|
||||
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
|
||||
|
||||
// Write back the save
|
||||
let tempSavePath = `${savePath}.tmp`;
|
||||
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
|
||||
let pipe = stream.pipe(fs.createWriteStream(savePath));
|
||||
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
|
||||
await events.once(pipe, "finish");
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+51
-108
@@ -6,12 +6,14 @@ const path = require("path");
|
||||
const events = require("events");
|
||||
const util = require("util");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const ini = require("ini");
|
||||
const rconClient = require("rcon-client");
|
||||
|
||||
const libErrors = require("../errors");
|
||||
const libFileOps = require("../file_ops");
|
||||
const libIni = require("../ini");
|
||||
const { logger } = require("../logging");
|
||||
const { escapeRegExp } = require("../helpers");
|
||||
const { LineSplitter } = require("../stream");
|
||||
|
||||
|
||||
/**
|
||||
@@ -193,82 +195,6 @@ async function generatePassword(length) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Line splitter for chunked data
|
||||
*
|
||||
* Splits a stream of bytes into lines terminated by either line feed or
|
||||
* carriage return followed by linefeed. Each line sepparated is passed
|
||||
* to the callback one at a time without the line terminator included.
|
||||
*
|
||||
* Data is streamed via the data method, and once the stream ends the
|
||||
* end method should be called. This ensures an unterminated line at
|
||||
* the end of the stream is passed to the callback.
|
||||
*
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
class LineSplitter {
|
||||
|
||||
/**
|
||||
* Create a line splitter
|
||||
*
|
||||
* @param {function(Buffer)} callback - Function called for every line.
|
||||
*/
|
||||
constructor(callback) {
|
||||
this._callback = callback;
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream in bytes
|
||||
*
|
||||
* Input bytes to split lines over. Will invoke the callback for every
|
||||
* line found in buf and keep any potential partial line at the end of
|
||||
* buf for the next invocation.
|
||||
*
|
||||
* @param {Buffer} buf - Data to stream in.
|
||||
*/
|
||||
data(buf) {
|
||||
if (this._partial) {
|
||||
buf = Buffer.concat([this._partial, buf]);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
while (buf.length) {
|
||||
let end = buf.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._partial = buf;
|
||||
break;
|
||||
}
|
||||
|
||||
let next = end + 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end >= 1 && buf[end-1] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
let line = buf.slice(0, end);
|
||||
buf = buf.slice(next);
|
||||
this._callback(line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark end of stream
|
||||
*
|
||||
* Signal the end of the bytestream. If the previous invocation of the
|
||||
* data method ended with a partial line this will invoke the callback
|
||||
* with this line.
|
||||
*/
|
||||
end() {
|
||||
if (this._partial) {
|
||||
this._callback(this._partial);
|
||||
this._partial = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret lines of output from Factorio
|
||||
*
|
||||
@@ -483,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
|
||||
@@ -502,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 -
|
||||
@@ -530,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 */
|
||||
@@ -561,9 +492,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
|
||||
// Array of possible causes for an unexpected shutdown
|
||||
this._unexpected = [];
|
||||
|
||||
this._stdout = new LineSplitter((line) => { this._handleOutput(line, "stdout"); });
|
||||
this._stderr = new LineSplitter((line) => { this._handleOutput(line, "stderr"); });
|
||||
this._killed = false;
|
||||
|
||||
// Track autosaving
|
||||
this._runningAutosave = null;
|
||||
@@ -576,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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -689,7 +620,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
|
||||
let config = {
|
||||
host: "localhost",
|
||||
host: "127.0.0.1", // localhost may resolve to ::1 while Factorio listen to only IPv4 by default.
|
||||
port: this.rconPort,
|
||||
password: this.rconPassword,
|
||||
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
|
||||
@@ -760,37 +691,41 @@ class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
|
||||
_attachStdio() {
|
||||
this._server.stdout.on("data", chunk => { this._stdout.data(chunk); });
|
||||
this._server.stdout.on("close", () => { this._stdout.end(); });
|
||||
this._server.stderr.on("data", chunk => { this._stderr.data(chunk); });
|
||||
this._server.stderr.on("close", () => { this._stderr.end(); });
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
stdout.on("data", line => { this._handleOutput(line, "stdout"); });
|
||||
this._server.stdout.pipe(stdout);
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
stderr.on("data", line => { this._handleOutput(line, "stderr"); });
|
||||
this._server.stderr.pipe(stderr);
|
||||
}
|
||||
|
||||
_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- ")}`
|
||||
));
|
||||
@@ -866,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}`);
|
||||
}
|
||||
@@ -896,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"] : []),
|
||||
],
|
||||
{
|
||||
@@ -940,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"] : []),
|
||||
],
|
||||
{
|
||||
@@ -1078,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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1167,23 +1111,23 @@ class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
|
||||
async _writeConfigIni() {
|
||||
let content = ini.encode({
|
||||
let content = libIni.stringify({
|
||||
path: {
|
||||
"read-data": this.dataPath(),
|
||||
"write-data": this.writePath(),
|
||||
},
|
||||
});
|
||||
await fs.outputFile(this.writePath("config.ini"), content);
|
||||
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
|
||||
}
|
||||
|
||||
async _writeMapSettings(mapGenSettings, mapSettings) {
|
||||
if (mapGenSettings) {
|
||||
await fs.outputFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
|
||||
);
|
||||
}
|
||||
if (mapSettings) {
|
||||
await fs.outputFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
|
||||
);
|
||||
}
|
||||
@@ -1200,6 +1144,5 @@ module.exports = {
|
||||
_findVersion: findVersion,
|
||||
_randomDynamicPort: randomDynamicPort,
|
||||
_generatePassword: generatePassword,
|
||||
_LineSplitter: LineSplitter,
|
||||
_parseOutput: parseOutput,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const crypto = require("crypto"); // needed for getTempFile
|
||||
/**
|
||||
* Returns the newest file in a directory
|
||||
*
|
||||
@@ -68,8 +68,49 @@ async function findUnusedName(directory, name, extension = "") {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
* racy and a file or folder may have been created in the folder by the time
|
||||
* this function returns.
|
||||
*
|
||||
* @param {string} prefix - Prefix for file
|
||||
* @param {string} suffix - Suffix for file
|
||||
* @param {string} tmpdir - Directory for temp file
|
||||
*/
|
||||
async function getTempFile(prefix, suffix, tmpdir) {
|
||||
prefix = (typeof prefix !== "undefined") ? prefix : "tmp.";
|
||||
suffix = (typeof suffix !== "undefined") ? suffix : "";
|
||||
tmpdir = (typeof tmpdir !== "undefined") ? tmpdir : "./";
|
||||
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
||||
let freeFile = await findUnusedName(tmpdir, fileName);
|
||||
let fullPath = path.join(tmpdir, freeFile);
|
||||
return fullPath;
|
||||
}
|
||||
/**
|
||||
* Safely write data to a file
|
||||
*
|
||||
* Same as fs-extra.outputFile except the data is written to a temporary
|
||||
* file that's renamed over the target file. The name of the temporary file
|
||||
* is the same as the target file with the suffix `.tmp` added.
|
||||
*
|
||||
* If the operation fails it may leave behind the temporary file. This
|
||||
* should not be too much of an issue as the next time the same file is
|
||||
* written the temporary will be overwritten and renamed to the target file.
|
||||
*
|
||||
* @param {string} file - Path to file to write.
|
||||
* @param {string|Buffer} data - Content to write.
|
||||
* @param {object|string} options - see fs.writeFile, `flag` must not be set.
|
||||
*/
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let temporary = `${file}.tmp`;
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNewestFile,
|
||||
findUnusedName,
|
||||
getTempFile,
|
||||
safeOutputFile,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Factorio Compatible INI parser
|
||||
*
|
||||
* @module lib/ini
|
||||
* @author Hornwitser
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Parse INI file into object of sections
|
||||
* @param {string} input - content of INI file to parse.
|
||||
* @returns {object<string, object<string, string>>}
|
||||
* sections parsed from the ini file.
|
||||
*/
|
||||
function parse(input) {
|
||||
const sections = {};
|
||||
let currentSection = sections;
|
||||
const lines = input.split(/\r\n|\n/);
|
||||
for (let number = 1; number <= lines.length; number++) {
|
||||
let line = lines[number - 1].replace(/^[\t ]+/, "");
|
||||
if (!line || line[0] === "#" || line[0] === ";") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read section header
|
||||
if (line[0] === "[") {
|
||||
line = line.replace(/[\t ]+$/, "");
|
||||
if (line.slice(-1) !== "]") {
|
||||
throw new Error(`Unterminated section header on line ${number}`);
|
||||
}
|
||||
const name = line.slice(1, -1);
|
||||
if (Object.prototype.hasOwnProperty.call(sections, name)) {
|
||||
throw new Error(`Duplicated section [${name}] on line ${number}`);
|
||||
}
|
||||
currentSection = {};
|
||||
sections[name] = currentSection;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read key value
|
||||
const equalIndex = line.indexOf("=");
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Missing value for key ${line} on line ${number}`);
|
||||
}
|
||||
const key = line.slice(0, equalIndex);
|
||||
if (Object.prototype.hasOwnProperty.call(currentSection, key)) {
|
||||
throw new Error(`Duplicated key ${key} on line ${number}`);
|
||||
}
|
||||
const value = line.slice(equalIndex + 1);
|
||||
currentSection[key] = value;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an object of section definitions into an INI file
|
||||
* @param {object<string, object<string, string>>} sections -
|
||||
* Sections to encode into ini format
|
||||
* @returns {string} sections serialized into INI format
|
||||
*/
|
||||
function stringify(sections) {
|
||||
let serialized = [];
|
||||
let first = true;
|
||||
|
||||
for (let [key, value] of Object.entries(sections)) {
|
||||
if (typeof value === "string") {
|
||||
serialized.push(`${key}=${value}`);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let [section, content] of Object.entries(sections)) {
|
||||
if (typeof content === "object") {
|
||||
if (!first) {
|
||||
serialized.push("");
|
||||
}
|
||||
first = false;
|
||||
serialized.push(`[${section}]`);
|
||||
for (let [key, value] of Object.entries(content)) {
|
||||
serialized.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serialized.length) { serialized.push(""); }
|
||||
return serialized.join("\n");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parse,
|
||||
stringify,
|
||||
};
|
||||
@@ -73,7 +73,12 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
this._check("connected");
|
||||
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
|
||||
logger.verbose("Connector | closing after heartbeat timed out");
|
||||
this._socket.close(1008, "Heartbeat timeout");
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket.close(4008, "Heartbeat timeout");
|
||||
} else {
|
||||
this._socket.terminate();
|
||||
}
|
||||
|
||||
} else {
|
||||
this._socket.send(JSON.stringify({
|
||||
@@ -440,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+138
-10
@@ -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) {
|
||||
@@ -337,6 +338,7 @@ let slaveProperties = {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"connected": { type: "boolean" },
|
||||
"public_address": { type: ["null", "string"] },
|
||||
};
|
||||
|
||||
messages.listSlaves = new Request({
|
||||
@@ -372,6 +374,7 @@ let instanceProperties = {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"assigned_slave": { type: ["null", "integer"] },
|
||||
"game_port": { type: ["null", "integer"] },
|
||||
"status": { enum: [
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
@@ -548,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"],
|
||||
@@ -571,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"],
|
||||
@@ -627,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"],
|
||||
@@ -634,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"],
|
||||
@@ -739,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"],
|
||||
@@ -748,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"],
|
||||
@@ -1085,6 +1177,30 @@ messages.debugWsMessage = new Event({
|
||||
},
|
||||
});
|
||||
|
||||
messages.accountUpdate = new Event({
|
||||
type: "account_update",
|
||||
links: ["master-control"],
|
||||
eventRequired: [],
|
||||
eventProperties: {
|
||||
"roles": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
required: ["name", "id", "permissions"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"permissions": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.logMessage = new Event({
|
||||
type: "log_message",
|
||||
links: ["slave-master", "master-control"],
|
||||
@@ -1121,11 +1237,13 @@ messages.instanceInitialized = new Event({
|
||||
messages.instanceStatusChanged = new Event({
|
||||
type: "instance_status_changed",
|
||||
links: ["instance-slave", "slave-master"],
|
||||
eventRequired: ["instance_id", "status"],
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"status": { enum: [
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]},
|
||||
"game_port": { type: ["null", "integer"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1145,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"],
|
||||
@@ -1211,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
/**
|
||||
* Utilities for logging on the Node.js side
|
||||
*
|
||||
* @author Hornwitser
|
||||
* @module lib/logging_utils
|
||||
*/
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const winston = require("winston");
|
||||
const Transport = require("winston-transport");
|
||||
const { LEVEL, MESSAGE } = require("triple-beam");
|
||||
@@ -6,6 +17,11 @@ const chalk = require("chalk");
|
||||
|
||||
const libLink = require("./link");
|
||||
const libErrors = require("./errors");
|
||||
const libFileOps = require("./file_ops");
|
||||
const { levels, logger } = require("./logging");
|
||||
const libStream = require("./stream");
|
||||
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
/**
|
||||
@@ -55,6 +71,7 @@ function formatServerOutput(parsed) {
|
||||
|
||||
/**
|
||||
* Formats winston log messages for a character terminal.
|
||||
* @static
|
||||
*/
|
||||
class TerminalFormat {
|
||||
constructor(options = {}) {
|
||||
@@ -95,6 +112,7 @@ class TerminalFormat {
|
||||
|
||||
/**
|
||||
* Sends logs over a lib/link connection.
|
||||
* @static
|
||||
*/
|
||||
class LinkTransport extends Transport {
|
||||
constructor(options) {
|
||||
@@ -121,10 +139,385 @@ class LinkTransport extends Transport {
|
||||
}
|
||||
}
|
||||
|
||||
const logFileGlob = /^[a-z]+-(\d{4}-\d{2}-\d{2})\.log$/;
|
||||
/**
|
||||
* Filter object for logs
|
||||
* @typedef {Object} module:lib/logging_utils~LogFilter
|
||||
* @property {string} [max_level] -
|
||||
* Maximum log level to include. Higher levels are more verbose.
|
||||
* @property {boolean} [all] -
|
||||
* Include log entries from master, all slaves and all instances.
|
||||
* @property {boolean} [master] -
|
||||
* Include log entries from the master server.
|
||||
* @property {Array<number>} [slave_ids] -
|
||||
* Include log entries for the given slaves and instances of those
|
||||
* slaves by id.
|
||||
* @property {Array<number>} [instance_ids] -
|
||||
* Include log entries for the given instances by id.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Keeps an index over a log directory to speed up queries to it
|
||||
* @static
|
||||
*/
|
||||
class LogIndex {
|
||||
constructor(logDirectory, serialized) {
|
||||
this.logDirectory = logDirectory;
|
||||
this.index = new Map();
|
||||
|
||||
if (serialized.version !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let [file, serializedEntry] of Object.entries(serialized.files)) {
|
||||
this.index.set(file, {
|
||||
levels: new Set(serializedEntry.levels),
|
||||
master: serializedEntry.master,
|
||||
slave_ids: new Set(serializedEntry.slave_ids),
|
||||
instance_ids: new Set(serializedEntry.instance_ids),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
let serialized = {
|
||||
version: 0,
|
||||
files: {},
|
||||
};
|
||||
|
||||
for (let [file, entry] of this.index) {
|
||||
serialized.files[file] = {
|
||||
levels: [...entry.levels],
|
||||
master: entry.master,
|
||||
slave_ids: [...entry.slave_ids],
|
||||
instance_ids: [...entry.instance_ids],
|
||||
};
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create an empty index for the given directory
|
||||
*
|
||||
* Loads index.json from the given log directy expecting it to contain
|
||||
* data about previously indexed files saved with {@link
|
||||
* module:lib/logging_utils.LogIndex#save}.
|
||||
*
|
||||
* @param {string} logDirectory - Path to directory to load from.
|
||||
* @returns {module:lib/logging_utils.LogIndex} loaded or created log index.
|
||||
*/
|
||||
static async load(logDirectory) {
|
||||
try {
|
||||
let content = await fs.readFile(path.join(logDirectory, "index.json"));
|
||||
return new LogIndex(logDirectory, JSON.parse(content));
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
logger.warn(`Failed to load ${path.join(logDirectory, "index.json")}: ${err}`);
|
||||
}
|
||||
return new LogIndex(logDirectory, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save index
|
||||
*
|
||||
* Saves corrently held index into index.json in the log directory it
|
||||
* was loaded/created from.
|
||||
*/
|
||||
async save() {
|
||||
await libFileOps.safeOutputFile(
|
||||
path.join(this.logDirectory, "index.json"),
|
||||
JSON.stringify(this.serialize(), null, 4)
|
||||
);
|
||||
}
|
||||
|
||||
async indexFile(file) {
|
||||
let filePath = path.join(this.logDirectory, file);
|
||||
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(lineStream);
|
||||
let entry = {
|
||||
levels: new Set(),
|
||||
master: false,
|
||||
slave_ids: new Set(),
|
||||
instance_ids: new Set(),
|
||||
};
|
||||
for await (let line of lineStream) {
|
||||
let info;
|
||||
try {
|
||||
info = JSON.parse(line);
|
||||
} catch (err) {
|
||||
entry.levels.add("info");
|
||||
entry.master = true;
|
||||
continue;
|
||||
}
|
||||
if (info.level) {
|
||||
entry.levels.add(info.level);
|
||||
}
|
||||
if (info.slave_id === undefined) {
|
||||
entry.master = true;
|
||||
} else if (info.instance_id === undefined) {
|
||||
entry.slave_ids.add(info.slave_id);
|
||||
}
|
||||
if (info.instance_id !== undefined) {
|
||||
entry.instance_ids.add(info.instance_id);
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build index for files currently present in the log directory
|
||||
*/
|
||||
async buildIndex() {
|
||||
let today = new Date(Date.now() - 300e3).toISOString().slice(0, 10);
|
||||
|
||||
for (let file of await fs.readdir(this.logDirectory)) {
|
||||
let match = logFileGlob.exec(file);
|
||||
if (!match || match[1] >= today || this.index.has(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = await this.indexFile(file);
|
||||
this.index.set(file, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file includes entries for the given filter
|
||||
*
|
||||
* @param {string} file -
|
||||
* Name of file in the log directory to check for.
|
||||
* @param {module:lib/logging_utils~LogFilter} filter -
|
||||
* Filter to check index if file contains entries for.
|
||||
* @returns {boolean}
|
||||
* true if the file may contain entries included by the filter.
|
||||
*/
|
||||
filterIncludesFile(file, { max_level, all, master, instance_ids, slave_ids }) {
|
||||
let entry = this.index.get(file);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
if (max_level && hasOwnProperty.call(levels, max_level)) {
|
||||
let fileLevels = [...entry.levels].map(
|
||||
fileLevel => (hasOwnProperty.call(levels, fileLevel) ? levels[fileLevel] : levels.verbose)
|
||||
);
|
||||
if (fileLevels.every(level => level > levels[max_level])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return true;
|
||||
}
|
||||
if (master && entry.master) {
|
||||
return true;
|
||||
}
|
||||
if (slave_ids && slave_ids.some(id => entry.slave_ids.has(id))) {
|
||||
return true;
|
||||
}
|
||||
if (instance_ids && instance_ids.some(id => entry.instance_ids.has(id))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create log filter by level and source.
|
||||
*
|
||||
* @param {module:lib/logging_utils~LogFilter} filter -
|
||||
* Filter to filter log entries by.
|
||||
* @returns {function(object): boolean}
|
||||
* filter returning true for log entries that match it.
|
||||
* @static
|
||||
*/
|
||||
function logFilter({ all, master, slave_ids, instance_ids, max_level }) {
|
||||
return info => {
|
||||
// Note: reversed to filter out undefined levels
|
||||
if (max_level && !(levels[info.level] <= levels[max_level])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return true;
|
||||
}
|
||||
if (master && info.slave_id === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
slave_ids
|
||||
&& info.slave_id !== undefined
|
||||
&& info.instance_id === undefined
|
||||
&& slave_ids.includes(info.slave_id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (instance_ids && info.instance_id !== undefined && instance_ids.includes(info.instance_id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter object for querying logs
|
||||
* @typedef {Object} module:lib/logging_utils~QueryLogFilter
|
||||
* @property {number} [limit=100] - Maximum number of entries to return.
|
||||
* @property {string} [order="asc"] -
|
||||
* Return entries in ascending ("asc") date order or desceding ("desc")
|
||||
* date order.
|
||||
* @property {string} [max_level] -
|
||||
* Maximum log level to include. Higher levels are more verbose.
|
||||
* @property {boolean} [all] -
|
||||
* Include log entries from master, all slaves and all instances.
|
||||
* @property {boolean} [master] -
|
||||
* Include log entries from the master server.
|
||||
* @property {Array<number>} [slave_ids] -
|
||||
* Include log entries for the given slaves and instances of those
|
||||
* slaves by id.
|
||||
* @property {Array<number>} [instance_ids] -
|
||||
* Include log entries for the given instances by id.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Query log directory
|
||||
*
|
||||
* @param {string} logDirectory - path to directory with logs.
|
||||
* @param {module:lib/logging_utils~QueryLogFilter} filter -
|
||||
* Filter to limit logs by.
|
||||
* @param {module:lib/logging_utils.LogIndex=} index -
|
||||
* Index to speed up query with.
|
||||
* @returns {Array<Object>} log entries matching the filter
|
||||
*/
|
||||
async function queryLog(logDirectory, filter, index) {
|
||||
let files = (await fs.readdir(logDirectory)).filter(entry => logFileGlob.test(entry));
|
||||
|
||||
filter = {
|
||||
limit: 100,
|
||||
order: "asc",
|
||||
...filter,
|
||||
};
|
||||
|
||||
files.sort();
|
||||
if (filter.order === "desc") {
|
||||
files.reverse();
|
||||
}
|
||||
|
||||
const includeEntry = logFilter(filter);
|
||||
let log = [];
|
||||
for (let file of files) {
|
||||
if (index && !index.filterIncludesFile(file, filter)) {
|
||||
continue;
|
||||
}
|
||||
let filePath = path.join(logDirectory, file);
|
||||
let fileStream;
|
||||
let lineStream;
|
||||
if (filter.order === "asc") {
|
||||
lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
} else {
|
||||
lineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
|
||||
fileStream = await libStream.createReverseReadStream(filePath);
|
||||
}
|
||||
lineStream.on("data", line => {
|
||||
let info;
|
||||
try {
|
||||
info = JSON.parse(line);
|
||||
} catch (err) {
|
||||
info = { level: "info", message: line.toString("utf8") };
|
||||
}
|
||||
if (includeEntry(info)) {
|
||||
log.push(info);
|
||||
if (log.length >= filter.limit) {
|
||||
lineStream.destroy();
|
||||
fileStream.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
fileStream.pipe(lineStream);
|
||||
try {
|
||||
await finished(lineStream);
|
||||
} catch (err) {
|
||||
if (log.length < filter.limit || err.code !== "ERR_STREAM_PREMATURE_CLOSE") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.length >= filter.limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async function migrateLogs(log, directory, pattern) {
|
||||
const dateRegExp = /^\d{4}-\d{2}-\d{2}/;
|
||||
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(log);
|
||||
fileStream.pipe(lineStream);
|
||||
|
||||
let currentDate = null;
|
||||
let output = null;
|
||||
|
||||
async function switchOutput(newOutputPath) {
|
||||
if (output) {
|
||||
output.end();
|
||||
await finished(output);
|
||||
}
|
||||
|
||||
output = fs.createWriteStream(newOutputPath, { flags: "ax" });
|
||||
try {
|
||||
await events.once(output, "open");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`writing ${newOutputPath}`);
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Discarding logs for ${newDate} due to target logfile already existing`);
|
||||
output = null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await (let line of lineStream) {
|
||||
let info = {};
|
||||
try {
|
||||
info = JSON.parse(line);
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (typeof info.timestamp === "string" && dateRegExp.test(info.timestamp)) {
|
||||
let newDate = info.timestamp.slice(0, 10);
|
||||
// Only update date forward in time.
|
||||
if (newDate !== currentDate && (!currentDate || newDate > currentDate)) {
|
||||
await switchOutput(path.join(directory, pattern.replace("%DATE%", newDate)));
|
||||
currentDate = newDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (output) {
|
||||
if (!output.write(Buffer.concat([line, Buffer.from("\n")]))) {
|
||||
await events.once(output, "drain");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
TerminalFormat,
|
||||
LinkTransport,
|
||||
LogIndex,
|
||||
logFilter,
|
||||
queryLog,
|
||||
migrateLogs,
|
||||
|
||||
// for testing only
|
||||
_formatServerOutput: formatServerOutput,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"browser": "browser.js",
|
||||
@@ -14,8 +14,8 @@
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
"chalk": "^4.1.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fs-extra": "^8.1.0",
|
||||
"ini": "^1.3.5",
|
||||
"isomorphic-ws": "^4.0.1",
|
||||
"jimp": "^0.12.1",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
|
||||
+145
-10
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const helpers = require("./helpers");
|
||||
|
||||
/**
|
||||
* Conceptual base for master and instance plugins.
|
||||
@@ -38,7 +39,7 @@ class BaseInstancePlugin {
|
||||
|
||||
/**
|
||||
* Instance the plugin started for
|
||||
* @type {module:slave/slave~Instance}
|
||||
* @type {module:slave/src/Instance}
|
||||
*/
|
||||
this.instance = instance;
|
||||
|
||||
@@ -48,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;
|
||||
|
||||
@@ -60,6 +61,9 @@ class BaseInstancePlugin {
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
this.logger = instance.logger.child({ plugin: this.info.name });
|
||||
|
||||
this._pendingRconMessages = [];
|
||||
this._sendingRconMessages = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,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.
|
||||
*/
|
||||
@@ -202,14 +206,81 @@ 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) { }
|
||||
|
||||
async sendRcon(message, expectEmpty) {
|
||||
/**
|
||||
* Send RCON message to instance
|
||||
*
|
||||
* Send a message or command to the server which can potentially be
|
||||
* executed out of order in relation to other RCON commands.
|
||||
*
|
||||
* This should not be called before onStart or after onStop. A simple
|
||||
* way to achieve this is to check that the instance status is running
|
||||
* before sending commands. Note that the instance status is set to
|
||||
* running some time after `onStart` and some time before `onStop` is
|
||||
* invoked.
|
||||
*
|
||||
* @param {string} message - message to send to server over RCON.
|
||||
* @param {boolean=} [expectEmpty=false] -
|
||||
* if true throw if the response is not empty. Useful for detecting
|
||||
* errors that might have been sent in response.
|
||||
* @returns {Promise<string>} response from server.
|
||||
*/
|
||||
async sendRcon(message, expectEmpty=false) {
|
||||
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send serially ordered RCON message to instance
|
||||
*
|
||||
* Send a message or command to the server which will not be executed
|
||||
* out of order in relation to other RCON commands sent with this
|
||||
* method. The ordering applies per plugin, and two plugins sending
|
||||
* commands with this method may execute out of order in relation to
|
||||
* each other.
|
||||
*
|
||||
* This should not be called before onStart or after onStop. A simple
|
||||
* way to achieve this is to check that the instance status is running
|
||||
* before sending commands. Note that the instance status is set to
|
||||
* running some time after `onStart` and some time before `onStop` is
|
||||
* invoked.
|
||||
*
|
||||
* @param {string} message - message to send to server over RCON.
|
||||
* @param {boolean=} [expectEmpty=false] -
|
||||
* if true throw if the response is not empty. Useful for detecting
|
||||
* errors that might have been sent in response.
|
||||
* @returns {Promise<string>} response from server.
|
||||
*/
|
||||
async sendOrderedRcon(message, expectEmpty=false) {
|
||||
let promise = new Promise((resolve, reject) => {
|
||||
this._pendingRconMessages.push({resolve, reject, message, expectEmpty});
|
||||
});
|
||||
if (!this._sendingRconMessages) {
|
||||
this._sendPendingRconMessages();
|
||||
}
|
||||
return await promise;
|
||||
}
|
||||
|
||||
async _sendPendingRconMessages() {
|
||||
this._sendingRconMessages = true;
|
||||
while (this._pendingRconMessages.length) {
|
||||
let task = this._pendingRconMessages.shift();
|
||||
try {
|
||||
let result = await this.sendRcon(task.message, task.expectEmpty);
|
||||
task.resolve(result);
|
||||
} catch (err) {
|
||||
task.reject(err);
|
||||
}
|
||||
}
|
||||
this._sendingRconMessages = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,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.
|
||||
*
|
||||
@@ -296,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.
|
||||
*/
|
||||
@@ -418,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) { }
|
||||
|
||||
@@ -508,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.
|
||||
@@ -573,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 = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -667,8 +794,16 @@ async function invokeHook(plugins, hook, ...args) {
|
||||
let results = [];
|
||||
for (let [name, plugin] of plugins) {
|
||||
try {
|
||||
let result = await plugin[hook](...args);
|
||||
if (result !== undefined) {
|
||||
// Use an object to detect if the hook failed on timeout or tried to return a value looking like an error
|
||||
const timeout = {};
|
||||
let result = await helpers.timeout(
|
||||
plugin[hook](...args),
|
||||
15000,
|
||||
timeout
|
||||
);
|
||||
if (result === timeout) {
|
||||
throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`);
|
||||
} else if (result !== undefined) {
|
||||
results.push(result);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+19
-1
@@ -104,10 +104,27 @@ const serverHandshake = ajv.compile({
|
||||
"session_timeout": { type: "number" },
|
||||
"heartbeat_interval": { type: "number" },
|
||||
"account": {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name"],
|
||||
required: ["name", "roles"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"roles": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
required: ["name", "id", "permissions"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"permissions": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -161,6 +178,7 @@ const clientHandshake = ajv.compile({
|
||||
"version": { type: "string" },
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"public_address": { type: "string" },
|
||||
"plugins": {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* @module lib/shared_commands
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libConfig = require("./config");
|
||||
const libFileOps = require("./file_ops");
|
||||
const { logger } = require("./logging");
|
||||
const libHelpers = require("./helpers");
|
||||
|
||||
@@ -70,7 +70,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
|
||||
}
|
||||
|
||||
pluginList.set(pluginInfo.name, pluginPath);
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
print(`Added ${pluginInfo.name}`);
|
||||
|
||||
} else if (command === "remove") {
|
||||
@@ -80,7 +80,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
print(`Removed ${args.name}`);
|
||||
|
||||
} else if (command === "list") {
|
||||
@@ -155,7 +155,7 @@ async function handleConfigCommand(args, instance, configPath) {
|
||||
|
||||
try {
|
||||
instance.set(args.field, args.value);
|
||||
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
|
||||
logger.error(err.message);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use strict";
|
||||
const assert = require("assert");
|
||||
const fs = require("fs-extra");
|
||||
const stream = require("stream");
|
||||
|
||||
|
||||
/**
|
||||
* Transform splitting a stream into lines
|
||||
*
|
||||
* Splits up a stream of chunks by newlines and passes on the lines one by
|
||||
* one removing the line feed and optional carriage return from the lines.
|
||||
*
|
||||
* @example
|
||||
* let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
* lineStream.on("data", line => { console.log(line.toString("utf8")); });
|
||||
* let fileStream = fs.createReadStream(path);
|
||||
* fileStream.pipe(lineStream);
|
||||
*
|
||||
* @memberof module:lib/stream
|
||||
*/
|
||||
class LineSplitter extends stream.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (this._partial) {
|
||||
chunk = Buffer.concat([this._partial, chunk]);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
while (chunk.length) {
|
||||
let end = chunk.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._partial = chunk;
|
||||
break;
|
||||
}
|
||||
|
||||
let next = end + 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
let line = chunk.slice(0, end);
|
||||
chunk = chunk.slice(next);
|
||||
this.push(line);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
if (this._partial) {
|
||||
this.push(this._partial);
|
||||
this._partial = null;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform splitting a reverse stream into lines
|
||||
*
|
||||
* Splits up a reverse stream of chunks created by {@link
|
||||
* module:lib/stream.createReverseReadStream} by newlines and passes on the
|
||||
* lines one by one in reverse order removing the line feed and optional
|
||||
* carriage return from the lines.
|
||||
*
|
||||
* @example
|
||||
* let revLineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
|
||||
* revLineStream.on("data", line => { console.log(line.toString("utf8")); });
|
||||
* let revFileStream = await libStream.createReverseReadStream(path);
|
||||
* revFileStream.pipe(revLineStream);
|
||||
*
|
||||
* @memberof module:lib/stream
|
||||
*/
|
||||
class ReverseLineSplitter extends stream.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (this._partial) {
|
||||
chunk = Buffer.concat([chunk, this._partial]);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
while (chunk.length) {
|
||||
let next = chunk.lastIndexOf("\n", -2);
|
||||
if (next === -1) {
|
||||
this._partial = chunk;
|
||||
break;
|
||||
}
|
||||
|
||||
let end = chunk.length - 1;
|
||||
if (chunk[end] === "\n".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end - next > 0 && chunk[end] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
let line = chunk.slice(next + 1, end + 1);
|
||||
chunk = chunk.slice(0, next + 1);
|
||||
this.push(line);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
if (this._partial) {
|
||||
let end = this._partial.length - 1;
|
||||
if (this._partial[end] === "\n".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end > 0 && this._partial[end] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
this.push(this._partial.slice(0, end + 1));
|
||||
this._partial = null;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a file read stream running in reverse
|
||||
*
|
||||
* Like fs.createReadStream but provides data chunks starting from the end
|
||||
* of the file and progressing towards the start of the file. The chunks
|
||||
* are not reversed, thus the file content can be recreated by concatenating
|
||||
* the chunks together in the reverse order they are read.
|
||||
*
|
||||
* @param {string} path -
|
||||
* Path to file to open. May be anything accepted by fs.open.
|
||||
* @param {object} options -
|
||||
* Options to pass to fs.createReadStream. Values passed for `fd` and
|
||||
* `fs` will be ignored.
|
||||
* @memberof module:lib/stream
|
||||
*/
|
||||
async function createReverseReadStream(path, options) {
|
||||
const fileFd = await fs.open(path, "r");
|
||||
const fileSize = (await fs.fstat(fileFd)).size;
|
||||
let filePosition = fileSize;
|
||||
const reverseFs = {
|
||||
read(fd, buffer, offset, length, position, callback) {
|
||||
assert(fd === fileFd);
|
||||
assert(position === undefined);
|
||||
length = Math.min(length, filePosition);
|
||||
filePosition -= length;
|
||||
return fs.read(fd, buffer, offset, length, filePosition, callback);
|
||||
},
|
||||
open() { assert(false); },
|
||||
close(fd, callback) { return fs.close(fd, callback); },
|
||||
};
|
||||
return fs.createReadStream("", { ...options, fd: fileFd, fs: reverseFs });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LineSplitter,
|
||||
ReverseLineSplitter,
|
||||
createReverseReadStream,
|
||||
};
|
||||
+104
-21
@@ -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,39 +427,59 @@ definePermission({
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list",
|
||||
title: "List saves on instance",
|
||||
title: "List saves",
|
||||
description: "List the saves currently on the instance.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.create",
|
||||
title: "Create new instance save",
|
||||
description: "Create new savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.delete",
|
||||
title: "Delete instance save",
|
||||
description: "Delete savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.upload",
|
||||
title: "Upload save to instance",
|
||||
description: "Upload savegames to instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.download",
|
||||
title: "Download save from instance",
|
||||
description: "Download savegames from instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list_subscribe",
|
||||
title: "Subscribe to save list updatess",
|
||||
description: "Subscribe to be notifed on updates to the list of saves of instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.create",
|
||||
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 save",
|
||||
description: "Delete savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.upload",
|
||||
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",
|
||||
description: "Download savegames from instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.export_data",
|
||||
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",
|
||||
|
||||
+53
-18
@@ -22,10 +22,12 @@ const setBlocking = require("set-blocking");
|
||||
const yargs = require("yargs");
|
||||
const util = require("util");
|
||||
const winston = require("winston");
|
||||
require("winston-daily-rotate-file");
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
// homebrew modules
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
const libPrometheus = require("@clusterio/lib/prometheus");
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
@@ -41,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);
|
||||
@@ -61,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")),
|
||||
@@ -82,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) {
|
||||
@@ -162,7 +164,7 @@ async function handleBootstrapCommand(args, masterConfig) {
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await fs.outputFile(args.output, content);
|
||||
await libFileOps.safeOutputFile(args.output, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +190,12 @@ async function initialize() {
|
||||
choices: ["none"].concat(Object.keys(levels)),
|
||||
type: "string",
|
||||
})
|
||||
.option("log-directory", {
|
||||
nargs: 1,
|
||||
describe: "Directory to place logs in",
|
||||
default: "logs",
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "master config file to use",
|
||||
@@ -226,20 +234,45 @@ async function initialize() {
|
||||
.argv
|
||||
;
|
||||
|
||||
{
|
||||
// Migration from alpha-10 single file logs, note that we can't use
|
||||
// the logger here as it's not initalized yet.
|
||||
/* eslint-disable no-console */
|
||||
let clusterLogDirectory = path.join(parameters.args.logDirectory, "cluster");
|
||||
if (!await fs.pathExists(clusterLogDirectory) && await fs.pathExists("cluster.log")) {
|
||||
console.log("Migrating cluster log...");
|
||||
await fs.ensureDir(clusterLogDirectory);
|
||||
await libLoggingUtils.migrateLogs("cluster.log", clusterLogDirectory, "cluster-%DATE%.log");
|
||||
console.log("Migration complete, you should delete cluster.log now");
|
||||
}
|
||||
|
||||
let masterLogDirectory = path.join(parameters.args.logDirectory, "master");
|
||||
if (!await fs.pathExists(masterLogDirectory) && await fs.pathExists("master.log")) {
|
||||
console.log("Migrating master log...");
|
||||
await fs.ensureDir(masterLogDirectory);
|
||||
await libLoggingUtils.migrateLogs("master.log", masterLogDirectory, "master-%DATE%.log");
|
||||
console.log("Migration complete, you should delete master.log now");
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
// Combined log stream of the whole cluster.
|
||||
parameters.clusterLogger = winston.createLogger({
|
||||
format: winston.format.json(),
|
||||
level: "verbose",
|
||||
levels,
|
||||
});
|
||||
parameters.clusterLogger.add(new winston.transports.File({
|
||||
filename: "cluster.log",
|
||||
parameters.clusterLogger.add(new winston.transports.DailyRotateFile({
|
||||
filename: "cluster-%DATE%.log",
|
||||
utc: true,
|
||||
dirname: path.join(parameters.args.logDirectory, "cluster"),
|
||||
}));
|
||||
|
||||
// Log stream for the master server.
|
||||
logger.add(new winston.transports.File({
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
format: winston.format.json(),
|
||||
filename: "master.log",
|
||||
filename: "master-%DATE%.log",
|
||||
dirname: path.join(parameters.args.logDirectory, "master"),
|
||||
}));
|
||||
logger.add(new winston.transports.Stream({
|
||||
stream: parameters.clusterLogger,
|
||||
@@ -294,7 +327,9 @@ async function initialize() {
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let bytes = await asyncRandomBytes(256);
|
||||
parameters.masterConfig.set("master.auth_secret", bytes.toString("base64"));
|
||||
await fs.outputFile(parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(
|
||||
parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4)
|
||||
);
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
@@ -321,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;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"description": "Implementation of Clusterio master server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"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,28 +19,27 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.9",
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"busboy": "^0.3.1",
|
||||
"compression": "^1.7.2",
|
||||
"express": "^4.16.2",
|
||||
"fs-extra": "^8.1.0",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"node-factorio-api": "^0.3.8",
|
||||
"set-blocking": "^2.0.0",
|
||||
"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.9",
|
||||
"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": {
|
||||
|
||||
@@ -64,6 +64,7 @@ class BaseConnection extends libLink.Link {
|
||||
}
|
||||
|
||||
async disconnect(code, reason) {
|
||||
this.connector.setClosing();
|
||||
try {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use strict";
|
||||
const jwt = require("jsonwebtoken");
|
||||
const util = require("util");
|
||||
const winston = require("winston");
|
||||
const events = require("events");
|
||||
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const { levels, logger } = require("@clusterio/lib/logging");
|
||||
const { logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libPrometheus = require("@clusterio/lib/prometheus");
|
||||
@@ -54,6 +53,11 @@ class ControlConnection extends BaseConnection {
|
||||
instance_ids: [],
|
||||
};
|
||||
|
||||
this.userSubscriptions = {
|
||||
all: false,
|
||||
names: [],
|
||||
};
|
||||
|
||||
this.logTransport = null;
|
||||
this.logSubscriptions = {
|
||||
all: false,
|
||||
@@ -106,6 +110,7 @@ class ControlConnection extends BaseConnection {
|
||||
version: slave.version,
|
||||
id: slave.id,
|
||||
name: slave.name,
|
||||
public_address: slave.public_address || null,
|
||||
connected: this._master.wsServer.slaveConnections.has(slave.id),
|
||||
});
|
||||
}
|
||||
@@ -160,15 +165,13 @@ 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,
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +183,7 @@ class ControlConnection extends BaseConnection {
|
||||
id: instance.config.get("instance.id"),
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
});
|
||||
}
|
||||
@@ -199,6 +203,7 @@ class ControlConnection extends BaseConnection {
|
||||
id: instance.config.get("instance.id"),
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
});
|
||||
}
|
||||
@@ -247,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);
|
||||
}
|
||||
@@ -264,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"),
|
||||
};
|
||||
@@ -288,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");
|
||||
}
|
||||
@@ -307,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);
|
||||
@@ -319,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;
|
||||
@@ -383,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,
|
||||
@@ -393,34 +379,90 @@ 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();
|
||||
}
|
||||
|
||||
static logFilter({ all, master, slave_ids, instance_ids, max_level }) {
|
||||
return info => {
|
||||
// Note: reversed to filter out undefined levels
|
||||
if (max_level && !(levels[info.level] <= levels[max_level])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return true;
|
||||
}
|
||||
if (master && info.slave_id === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (info.slave_id && slave_ids.includes(info.slave_id)) {
|
||||
return true;
|
||||
}
|
||||
if (info.instance_id && instance_ids.includes(info.instance_id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
updateLogSubscriptions() {
|
||||
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
|
||||
if (all || master || slave_ids.length || instance_ids.length) {
|
||||
@@ -428,7 +470,7 @@ class ControlConnection extends BaseConnection {
|
||||
this.logTransport = new libLoggingUtils.LinkTransport({ link: this });
|
||||
this._master.clusterLogger.add(this.logTransport);
|
||||
}
|
||||
this.logTransport.filter = this.constructor.logFilter(this.logSubscriptions);
|
||||
this.logTransport.filter = libLoggingUtils.logFilter(this.logSubscriptions);
|
||||
|
||||
} else if (this.logTransport) {
|
||||
this._master.clusterLogger.remove(this.logTransport);
|
||||
@@ -437,34 +479,16 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async queryLogRequestHandler(message) {
|
||||
let transport = new winston.transports.File({ filename: "cluster.log" });
|
||||
let query = util.promisify((options, callback) => transport.query(options, callback));
|
||||
|
||||
// Available options and defaults inferred from reading the source
|
||||
// rows: number = limit || 10
|
||||
// limit: number // alias of rows.
|
||||
// start: number = 0
|
||||
// until: Date = now
|
||||
// from: Date = until - 24h
|
||||
// order: "asc"|"desc" = "desc"
|
||||
// fields: Array<string>
|
||||
// level: string
|
||||
// This interface is junk:
|
||||
// - The level option filters by exact match
|
||||
// - No way to add your own filter
|
||||
// - No way to stream results
|
||||
// - The log file is read starting from the beginning
|
||||
let setDuration = lastQueryLogTime.startTimer();
|
||||
let log = await query({
|
||||
order: message.data.order,
|
||||
limit: Infinity,
|
||||
from: new Date(0),
|
||||
});
|
||||
let { all, master, slave_ids, instance_ids } = message.data;
|
||||
|
||||
log = log.filter(this.constructor.logFilter(message.data));
|
||||
if (message.data.limit) {
|
||||
log = log.slice(0, message.data.limit);
|
||||
let log;
|
||||
if (!all && master && !slave_ids.length && !instance_ids.length) {
|
||||
log = await this._master.queryMasterLog(message.data);
|
||||
} else {
|
||||
log = await this._master.queryClusterLog(message.data);
|
||||
}
|
||||
|
||||
setDuration();
|
||||
return { log };
|
||||
}
|
||||
@@ -513,6 +537,7 @@ class ControlConnection extends BaseConnection {
|
||||
role.name = name;
|
||||
role.description = description;
|
||||
role.permissions = new Set(permissions);
|
||||
this._master.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async grantDefaultRolePermissionsRequestHandler(message) {
|
||||
@@ -522,6 +547,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
this._master.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async deleteRoleRequestHandler(message) {
|
||||
@@ -534,9 +560,30 @@ class ControlConnection extends BaseConnection {
|
||||
this._master.userManager.roles.delete(id);
|
||||
for (let user of this._master.userManager.users.values()) {
|
||||
user.roles.delete(role);
|
||||
this._master.userPermissionsUpdated(user);
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
@@ -547,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) {
|
||||
@@ -573,6 +646,8 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
user.roles = resolvedRoles;
|
||||
this._master.userPermissionsUpdated(user);
|
||||
this._master.userUpdated(user);
|
||||
}
|
||||
|
||||
async setUserAdminRequestHandler(message) {
|
||||
@@ -588,6 +663,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
user.isAdmin = admin;
|
||||
this._master.userUpdated(user);
|
||||
this.broadcastEventToSlaves({ data: { name, admin }}, libLink.messages.adminlistUpdate);
|
||||
}
|
||||
|
||||
@@ -605,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);
|
||||
}
|
||||
|
||||
@@ -621,6 +698,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
user.isWhitelisted = whitelisted;
|
||||
this._master.userUpdated(user);
|
||||
this.broadcastEventToSlaves({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate);
|
||||
}
|
||||
|
||||
@@ -630,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);
|
||||
}
|
||||
@@ -639,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,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
|
||||
|
||||
+173
-16
@@ -10,6 +10,9 @@ const path = require("path");
|
||||
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
const libPrometheus = require("@clusterio/lib/prometheus");
|
||||
@@ -94,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}`);
|
||||
}
|
||||
|
||||
@@ -115,6 +118,14 @@ class Master {
|
||||
}
|
||||
|
||||
async _startInternal(args) {
|
||||
this.logDirectory = args.logDirectory;
|
||||
this.clusterLogIndex = await libLoggingUtils.LogIndex.load(path.join(this.logDirectory, "cluster"));
|
||||
this.clusterLogBuildInterval = setInterval(() => {
|
||||
this.clusterLogIndex.buildIndex().catch(
|
||||
err => logger.error(`Error building cluster log index:\n${err.stack}`)
|
||||
);
|
||||
}, 600e3);
|
||||
|
||||
// Start webpack development server if enabled
|
||||
if (args.dev || args.devPlugin) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
@@ -126,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);
|
||||
}
|
||||
|
||||
@@ -150,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);
|
||||
}
|
||||
@@ -176,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();
|
||||
|
||||
@@ -238,9 +263,13 @@ class Master {
|
||||
// This function should never throw.
|
||||
this._state = "stopping";
|
||||
logger.info("Stopping master");
|
||||
clearInterval(this.clusterLogBuildInterval);
|
||||
if (this.clusterLogIndex) {
|
||||
await this.clusterLogIndex.save();
|
||||
}
|
||||
|
||||
logger.info("Saving config");
|
||||
await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
|
||||
|
||||
if (this.devMiddleware) {
|
||||
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
|
||||
@@ -296,7 +325,7 @@ class Master {
|
||||
}
|
||||
|
||||
static async saveSlaves(filePath, slaves) {
|
||||
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
|
||||
}
|
||||
|
||||
static async loadInstances(filePath) {
|
||||
@@ -328,7 +357,54 @@ class Master {
|
||||
serialized.push(instance.config.serialize());
|
||||
}
|
||||
|
||||
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
}
|
||||
|
||||
static 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
|
||||
*
|
||||
* @param {module:lib/logging_utils~QueryLogFilter} filter -
|
||||
* Filter to limit entries with. Note that only the master log can
|
||||
* be queried from this function.
|
||||
* @returns {Array<Object>} log entries matching the filter
|
||||
*/
|
||||
async queryMasterLog(filter) {
|
||||
return await libLoggingUtils.queryLog(
|
||||
path.join(this.logDirectory, "master"), filter,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query cluster log
|
||||
*
|
||||
* @param {module:lib/logging_utils~QueryLogFilter} filter -
|
||||
* Filter to limit entries with.
|
||||
* @returns {Array<Object>} log entries matching the filter
|
||||
*/
|
||||
async queryClusterLog(filter) {
|
||||
return await libLoggingUtils.queryLog(
|
||||
path.join(this.logDirectory, "cluster"), filter, this.clusterLogIndex,
|
||||
);
|
||||
}
|
||||
|
||||
static addAppRoutes(app, pluginInfos) {
|
||||
@@ -339,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);
|
||||
@@ -355,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +443,7 @@ class Master {
|
||||
version: slave.version,
|
||||
id: slave.id,
|
||||
name: slave.name,
|
||||
public_address: slave.public_address || null,
|
||||
connected: this.wsServer.slaveConnections.has(slave.id),
|
||||
};
|
||||
|
||||
@@ -378,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") {
|
||||
@@ -410,8 +503,63 @@ 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.
|
||||
* @param {module:lib/users.User} user - User permisions updated for.
|
||||
*/
|
||||
userPermissionsUpdated(user) {
|
||||
for (let controlConnection of this.wsServer.controlConnections) {
|
||||
if (controlConnection.user === user) {
|
||||
libLink.messages.accountUpdate.send(controlConnection, {
|
||||
"roles": [...user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected control clients with the given role that the
|
||||
* permissions may have changed.
|
||||
* @param {module:lib/users.Role} role - Role permisions updated for.
|
||||
*/
|
||||
rolePermissionsUpdated(role) {
|
||||
for (let controlConnection of this.wsServer.controlConnections) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
libLink.messages.accountUpdate.send(controlConnection, {
|
||||
"roles": [...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
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;
|
||||
}
|
||||
@@ -501,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);
|
||||
});
|
||||
@@ -524,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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -28,6 +29,7 @@ class SlaveConnection extends BaseConnection {
|
||||
id: this._id,
|
||||
name: this._name,
|
||||
version: this._version,
|
||||
public_address: registerData.public_address,
|
||||
plugins: registerData.plugins,
|
||||
});
|
||||
|
||||
@@ -87,6 +89,7 @@ class SlaveConnection extends BaseConnection {
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = message.data.status;
|
||||
instance.game_port = message.data.game_port;
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
this._master.instanceUpdated(instance);
|
||||
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
@@ -173,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);
|
||||
@@ -184,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,6 +1,7 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libUsers = require("@clusterio/lib/users");
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ class UserManager {
|
||||
users: serializedUsers,
|
||||
roles: serializedRoles,
|
||||
};
|
||||
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -136,7 +136,7 @@ class WsServer {
|
||||
let timeoutId = setTimeout(() => {
|
||||
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1008, "Handshake timeout");
|
||||
socket.terminate();
|
||||
this.pendingSockets.delete(socket);
|
||||
}, 30*1000);
|
||||
|
||||
@@ -176,7 +176,7 @@ ${err.stack}`
|
||||
return;
|
||||
}
|
||||
|
||||
let { seq, type, data } = message;
|
||||
let { type, data } = message;
|
||||
|
||||
if (type === "resume") {
|
||||
let connector;
|
||||
@@ -266,7 +266,6 @@ ${err.stack}`
|
||||
let connection = this.slaveConnections.get(data.id);
|
||||
if (connection) {
|
||||
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
|
||||
connection.connector.setTimeout(15); // Slave connection is likely stalled
|
||||
await connection.disconnect(1008, "Registered from another connection");
|
||||
}
|
||||
|
||||
@@ -298,6 +297,11 @@ ${err.stack}`
|
||||
this.controlConnections.push(connection);
|
||||
additionalReadyData["account"] = {
|
||||
name: user.name,
|
||||
roles: [...user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,19 +73,10 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
|
||||
continue(socket, lastSeq) {
|
||||
|
||||
// It's possible the previous connection hasn't closed yet due to a
|
||||
// stale connection. If this is the case the connector will have to
|
||||
// wait for the previous socket to close down before it can continue
|
||||
// from a new socket, or there will be overlapping events from both.
|
||||
// stale connection. Terminate it if so.
|
||||
if (this._state === "connected") {
|
||||
this._socket.close(4003, "Session Hijacked");
|
||||
|
||||
// Correctly waiting for the connector to close and then
|
||||
// handling all the edge cases like multiple continues happening
|
||||
// in parallel or connections going stale is far too difficult.
|
||||
// Kill the connection here and let the client reconnect later
|
||||
// when the connector is not held up by an active connection.
|
||||
socket.close(1013, "Session Busy");
|
||||
socket.terminate();
|
||||
this._socket.terminate();
|
||||
this._socket.once("close", () => this.continue(socket, lastSeq));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
const Busboy = require("busboy");
|
||||
const crypto = require("crypto");
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const JSZip = require("jszip");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const path = require("path");
|
||||
const util = require("util");
|
||||
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libHash = require("@clusterio/lib/hash");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
@@ -47,7 +49,18 @@ async function getMetrics(req, res, next) {
|
||||
let requests = [];
|
||||
let timeout = req.app.locals.master.config.get("master.metrics_timeout") * 1000;
|
||||
for (let slaveConnection of req.app.locals.master.wsServer.slaveConnections.values()) {
|
||||
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
|
||||
if (!slaveConnection.connected) {
|
||||
continue;
|
||||
}
|
||||
requests.push(libHelpers.timeout(
|
||||
libLink.messages.getMetrics.send(slaveConnection).catch(err => {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
logger.error(`Unexpected error gathering metrics from slave:\n${err.stack}`);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
timeout, null
|
||||
));
|
||||
}
|
||||
|
||||
for await (let result of await libPrometheus.defaultRegistry.collect()) {
|
||||
@@ -88,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);
|
||||
}
|
||||
@@ -168,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 fs.outputFile(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");
|
||||
@@ -405,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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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: [
|
||||
@@ -25,6 +23,8 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
"react-dom": { singleton: true },
|
||||
"react-router": { singleton: true },
|
||||
"react-router-dom": { singleton: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -68,14 +68,12 @@ function serialize.serialize_equipment_grid(grid)
|
||||
}
|
||||
if equipment.shield > 0 then entry.s = equipment.shield end
|
||||
if equipment.energy > 0 then entry.e = equipment.energy end
|
||||
-- TODO: Test with Industrial Revolution
|
||||
if equipment.burner then
|
||||
local burner = equipment.burner
|
||||
entry.i = serialize.serialize_inventory(burner.inventory)
|
||||
entry.r = serialize.serialize_inventory(burner.burnt_result_inventory)
|
||||
if burner.curently_burning then
|
||||
entry.b = {}
|
||||
serialize.serialize_item_stack(burner.curently_burning, entry.b)
|
||||
if burner.currently_burning then
|
||||
entry.b = burner.currently_burning.name
|
||||
entry.f = burner.remaining_burning_fuel
|
||||
end
|
||||
end
|
||||
@@ -98,7 +96,8 @@ function serialize.deserialize_equipment_grid(grid, serialized)
|
||||
if entry.s then equipment.shield = entry.s end
|
||||
if entry.e then equipment.energy = entry.e end
|
||||
if entry.i then
|
||||
if entry.b then serialize.deserialize_item_stack(burner.currently_burning, entry.b) end
|
||||
local burner = equipment.burner
|
||||
if entry.b then burner.currently_burning = entry.b end
|
||||
if entry.f then burner.remaining_burning_fuel = entry.f end
|
||||
serialize.deserialize_inventory(burner.burnt_result_inventory, entry.r)
|
||||
serialize.deserialize_inventory(burner.inventory, entry.i)
|
||||
@@ -282,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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio slave server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "slave.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
@@ -18,12 +18,13 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.9",
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"fs-extra": "^8.1.0",
|
||||
"phin": "^3.5.1",
|
||||
"pidusage": "^2.0.18",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.5",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
+28
-1663
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"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.9",
|
||||
"@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"
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import events from "events";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
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;
|
||||
|
||||
function getInitialValues(config, props) {
|
||||
let initialValues = {};
|
||||
for (let [name, GroupClass] of props.ConfigClass.groups) {
|
||||
let group = config.group(name);
|
||||
for (let def of GroupClass.definitions.values()) {
|
||||
if (!group.canAccess(def.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = group.get(def.name);
|
||||
let fieldName = `${group.name}.${def.name}`;
|
||||
|
||||
if (def.type === "object") {
|
||||
for (let prop of Object.keys(value)) {
|
||||
let propPath = `${group.name}.${def.name}.${prop}`;
|
||||
initialValues[propPath] = JSON.stringify(value[prop]);
|
||||
}
|
||||
} else {
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
export default function BaseConfigTree(props) {
|
||||
let [config, setConfig] = useState(null);
|
||||
@@ -16,8 +40,13 @@ export default function BaseConfigTree(props) {
|
||||
let newConfig = new props.ConfigClass("control");
|
||||
await newConfig.load(serializedConfig);
|
||||
setConfig(newConfig);
|
||||
return newConfig;
|
||||
}
|
||||
useEffect(() => { updateConfig(); }, [props.id]);
|
||||
useEffect(() => {
|
||||
updateConfig().then(updatedConfig => {
|
||||
form.setFieldsValue(getInitialValues(updatedConfig, props));
|
||||
});
|
||||
}, [props.id]);
|
||||
|
||||
function renderInput(def) {
|
||||
if (def.type === "boolean") {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
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();
|
||||
|
||||
function slaveName(slaveId) {
|
||||
let slave = slaveList.find(s => s.id === slaveId);
|
||||
if (slave) {
|
||||
return slave.name;
|
||||
}
|
||||
return String(slaveId);
|
||||
}
|
||||
|
||||
function instancePublicAddress(instance) {
|
||||
let slave = slaveList.find(s => s.id === instance.assigned_slave);
|
||||
if (slave && slave.public_address) {
|
||||
if (instance.game_port) {
|
||||
return `${slave.public_address}:${instance.game_port}`;
|
||||
}
|
||||
return slave.public_address;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
},
|
||||
{
|
||||
title: "Assigned Slave",
|
||||
key: "assigned_slave",
|
||||
render: instance => slaveName(instance["assigned_slave"]),
|
||||
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
{
|
||||
title: "Public address",
|
||||
key: "public_address",
|
||||
render: instance => {
|
||||
let public_address = instancePublicAddress(instance);
|
||||
return public_address ? <>
|
||||
{public_address}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined/>}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(public_address);
|
||||
message.success("Copied public address!");
|
||||
}}
|
||||
/>
|
||||
</> : "";
|
||||
},
|
||||
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "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) {
|
||||
columns.splice(1, 1);
|
||||
}
|
||||
|
||||
return <Table
|
||||
size={props.size || "large"}
|
||||
columns={columns}
|
||||
dataSource={props.instances}
|
||||
rowKey={instance => instance["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/instances/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Tag } from "antd";
|
||||
import React from "react";
|
||||
|
||||
export const statusColors = {
|
||||
unknown: "#8c8c8c",
|
||||
unassigned: "#eb2f96",
|
||||
stopped: "#cf1322",
|
||||
starting: "#ad8b00",
|
||||
running: "#389e0d",
|
||||
stopping: "#d48806",
|
||||
creating_save: "#096dd9",
|
||||
exporting_data: "#08979c",
|
||||
deleted: "#262626",
|
||||
};
|
||||
|
||||
|
||||
export default function InstanceStatusTag(props) {
|
||||
return <Tag color={statusColors[props.status]}>
|
||||
{props.status.replace("_", " ")}
|
||||
</Tag>;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
|
||||
import { Alert, Button, Descriptions, 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 InstanceConsole from "./InstanceConsole";
|
||||
import LogConsole from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
import AssignInstanceModal from "./AssignInstanceModal";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
@@ -17,16 +20,48 @@ import SavesList from "./SavesList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useSlave } from "../model/slave";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
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);
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [instance] = useInstance(instanceId);
|
||||
let [slave] = useSlave(Number(instance["assigned_slave"]));
|
||||
@@ -38,20 +73,63 @@ export default function InstanceViewPage(props) {
|
||||
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
if (instance.missing) {
|
||||
if (instance.missing || instance["status"] === "deleted") {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>Instance not found</h2>
|
||||
<p>Instance with id {instanceId} was not found on the master server.</p>
|
||||
<Alert
|
||||
message={instance["status"] === "deleted" ? "Instance has been deleted" : "Instance not found" }
|
||||
showIcon
|
||||
description={<>Instance with id {instanceId} was not found on the master server.</>}
|
||||
type="warning"
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => { history.push("/instances"); }}
|
||||
>
|
||||
Go back to instances list
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
let instanceButtons = <Space>
|
||||
<StartStopInstanceButton instance={instance} />
|
||||
<LoadScenarioModal instance={instance} />
|
||||
<Button
|
||||
loading={exportingData}
|
||||
disabled={instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
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" });
|
||||
}
|
||||
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 }
|
||||
@@ -60,65 +138,74 @@ export default function InstanceViewPage(props) {
|
||||
).finally(() => {
|
||||
setExportingData(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Export data
|
||||
</Button>
|
||||
<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"]
|
||||
}
|
||||
<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">{instance["status"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
/>
|
||||
<InstanceDescription slave={slave} instance={instance} />
|
||||
|
||||
<SavesList instance={instance} />
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<InstanceConsole id={instanceId} />
|
||||
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
}
|
||||
{
|
||||
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
|
||||
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
}
|
||||
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
|
||||
{
|
||||
account.hasPermission("core.instance.send_rcon")
|
||||
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
}
|
||||
|
||||
<InstanceConfigTree id={instanceId} />
|
||||
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
|
||||
|
||||
<PluginExtra component="InstanceViewPage" instance={instance} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
import { Button, Form, Input, Modal, PageHeader } from "antd";
|
||||
|
||||
import { libConfig, libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
import InstanceList from "./InstanceList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useSlaveList } from "../model/slave";
|
||||
|
||||
|
||||
function CreateInstanceButton(props) {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -50,7 +51,7 @@ function CreateInstanceButton(props) {
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
<Input/>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -58,47 +59,42 @@ function CreateInstanceButton(props) {
|
||||
}
|
||||
|
||||
export default function InstancesPage() {
|
||||
let history = useHistory();
|
||||
let [slaveList] = useSlaveList();
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [instanceList] = useInstanceList();
|
||||
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Instances"
|
||||
extra=<CreateInstanceButton />
|
||||
extra={<>
|
||||
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
|
||||
{account.hasPermission("core.instance.start")
|
||||
&& <Button onClick={e => instanceList.forEach(instance => {
|
||||
if (instance.status === "stopped") {
|
||||
libLink.messages.startInstance.send(
|
||||
control, { instance_id: instance.id, save: null }
|
||||
).catch(notifyErrorHandler("Error starting instance"));
|
||||
}
|
||||
})
|
||||
}>
|
||||
Start all
|
||||
</Button>}
|
||||
{account.hasPermission("core.instance.stop")
|
||||
&& <Button onClick={e => instanceList.forEach(instance => {
|
||||
if (["starting", "running"].includes(instance.status)) {
|
||||
libLink.messages.stopInstance.send(
|
||||
control, { instance_id: instance.id }
|
||||
).catch(notifyErrorHandler("Error stopping instance"));
|
||||
}
|
||||
})
|
||||
}>
|
||||
Stop all
|
||||
</Button>}
|
||||
</>}
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
},
|
||||
{
|
||||
title: "Assigned Slave",
|
||||
key: "assigned_slave",
|
||||
render: instance => {
|
||||
let slave = slaveList.find(s => s.id === instance.assigned_slave);
|
||||
if (slave) {
|
||||
return slave.name;
|
||||
}
|
||||
return instance.assigned_slave;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
},
|
||||
]}
|
||||
dataSource={instanceList}
|
||||
rowKey={instance => instance["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/instances/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<InstanceList instances={instanceList} />
|
||||
<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);
|
||||
});
|
||||
|
||||
+29
-18
@@ -3,6 +3,7 @@ import { Typography } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
@@ -48,30 +49,40 @@ function formatLog(info, key) {
|
||||
return <span key={key}>[{level}] {info.message}<br/></span>;
|
||||
}
|
||||
|
||||
export default function InstanceConsole(props) {
|
||||
export default function LogConsole(props) {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let anchor = useRef();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
let [lines, setLines] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let logFilter = {
|
||||
all: props.all || false,
|
||||
master: props.master || false,
|
||||
slave_ids: props.slaves || [],
|
||||
instance_ids: props.instances || [],
|
||||
};
|
||||
|
||||
// Scroll view to the anchor so it sticks to the bottom
|
||||
let parent = anchor.current.parentElement;
|
||||
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
|
||||
|
||||
libLink.messages.queryLog.send(control, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [props.id],
|
||||
max_level: null,
|
||||
limit: 400,
|
||||
order: "desc",
|
||||
}).then(result => {
|
||||
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
|
||||
}).catch(err => {
|
||||
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
if (account.hasPermission("core.log.query")) {
|
||||
setPastLines([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
libLink.messages.queryLog.send(control, {
|
||||
...logFilter,
|
||||
max_level: null,
|
||||
limit: 400,
|
||||
order: "desc",
|
||||
}).then(result => {
|
||||
setPastLines(result.log.map((info, index) => formatLog(info, -index - 1)).reverse());
|
||||
}).catch(err => {
|
||||
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
} else {
|
||||
setPastLines([]);
|
||||
}
|
||||
|
||||
function logHandler(info) {
|
||||
setLines(currentLines => currentLines.concat(
|
||||
@@ -79,17 +90,17 @@ export default function InstanceConsole(props) {
|
||||
));
|
||||
}
|
||||
|
||||
control.onInstanceLog(props.id, logHandler);
|
||||
control.onLog(logFilter, logHandler);
|
||||
return () => {
|
||||
control.offInstanceLog(props.id, logHandler);
|
||||
control.offLog(logFilter, logHandler);
|
||||
};
|
||||
}, [props.id]);
|
||||
}, [props.all, props.master, (props.slaves || []).join(), (props.instances || []).join()]);
|
||||
|
||||
return <>
|
||||
<Paragraph code className="instance-console">
|
||||
{pastLines}
|
||||
{lines}
|
||||
<div className="scroll-anchor" ref={anchor} />
|
||||
<div className="scroll-anchor" key="anchor" ref={anchor} />
|
||||
</Paragraph>
|
||||
</>;
|
||||
}
|
||||
@@ -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,12 +1,25 @@
|
||||
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";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
export default function MasterPage() {
|
||||
let account = useAccount();
|
||||
|
||||
export default function SlavesPage() {
|
||||
return <PageLayout nav={[{ name: "Master" }]}>
|
||||
<h2>Master</h2>
|
||||
<MasterConfigTree />
|
||||
{account.hasPermission("core.log.follow") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<LogConsole master={true} />
|
||||
</>}
|
||||
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
|
||||
<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 "+
|
||||
|
||||
@@ -6,6 +6,8 @@ import notify from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
export default function PluginsPage() {
|
||||
let plugins = useContext(ControlContext).plugins;
|
||||
@@ -47,6 +49,8 @@ export default function PluginsPage() {
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: plugin => (plugin.info ? plugin.info.title : plugin.meta.name),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
@@ -60,11 +64,13 @@ export default function PluginsPage() {
|
||||
}
|
||||
return plugin.package.version;
|
||||
},
|
||||
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
|
||||
},
|
||||
{
|
||||
title: "Loaded",
|
||||
dataIndex: ["meta", "loaded"],
|
||||
render: loaded => (loaded ? "Yes" : null),
|
||||
sorter: (a, b) => a.loaded - b.loaded,
|
||||
responsive: ["sm"],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
|
||||
import { Button, Checkbox, Form, Input, PageHeader, Popconfirm, Spin } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import { libLink, libUsers } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
@@ -42,6 +44,7 @@ export default function RoleViewPage() {
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [role, updateRole] = useRole(roleId);
|
||||
let [edited, setEdited] = useState(false);
|
||||
@@ -50,14 +53,20 @@ export default function RoleViewPage() {
|
||||
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
|
||||
if (role.loading) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>{roleId}</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={roleId}
|
||||
/>
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (role.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>Role not found</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Role not found"
|
||||
/>
|
||||
<p>Role with id {roleId} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -72,6 +81,7 @@ export default function RoleViewPage() {
|
||||
},
|
||||
};
|
||||
|
||||
let canUpdate = account.hasPermission("core.role.update");
|
||||
return <PageLayout nav={nav}>
|
||||
<Form
|
||||
initialValues={initialValues}
|
||||
@@ -93,40 +103,35 @@ export default function RoleViewPage() {
|
||||
}).catch(notifyErrorHandler("Error applying changes"));
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<Col flex="auto">
|
||||
<h2>{role["name"]}</h2>
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
<Space>
|
||||
<Button type={edited ? "primary" : "default"} htmlType="submit">
|
||||
Apply
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={role["name"]}
|
||||
extra={<>
|
||||
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
|
||||
{account.hasPermission("core.role.delete") && <Popconfirm
|
||||
title="Delete role?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteRole.send(
|
||||
control, { id: roleId }
|
||||
).then(() => {
|
||||
history.push("/roles");
|
||||
}).catch(notifyErrorHandler("Error deleting role"));
|
||||
}}
|
||||
>
|
||||
<Button danger >
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="Delete role?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteRole.send(
|
||||
control, { id: roleId }
|
||||
).then(() => {
|
||||
history.push("/roles");
|
||||
}).catch(notifyErrorHandler("Error deleting role"));
|
||||
}}
|
||||
>
|
||||
<Button danger >
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Popconfirm>}
|
||||
</>}
|
||||
/>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input/>
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input/>
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
<h3>Permissions</h3>
|
||||
{[...libUsers.permissions.values()].map(({name, title, description}) => (
|
||||
@@ -141,9 +146,10 @@ export default function RoleViewPage() {
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox/>
|
||||
<Checkbox disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
<PluginExtra component="RoleViewPage" role={role} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,50 +1,105 @@
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import DataTable from "./data-table";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
export default function UsersPage() {
|
||||
function CreateRoleButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
|
||||
async function createRole() {
|
||||
let values = form.getFieldsValue();
|
||||
if (!values.roleName) {
|
||||
form.setFields([{ name: "roleName", errors: ["Name is required"] }]);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await libLink.messages.createRole.send(control, {
|
||||
name: values.roleName,
|
||||
description: values.description || "",
|
||||
permissions: [],
|
||||
});
|
||||
setVisible(false);
|
||||
history.push(`/roles/${result.id}/view`);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setVisible(true); }}
|
||||
>Create</Button>
|
||||
<Modal
|
||||
title="Create Role"
|
||||
okText="Create"
|
||||
visible={visible}
|
||||
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
|
||||
onCancel={() => { setVisible(false); }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="roleName" label="Name">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
|
||||
async function listRoles() {
|
||||
let result = await libLink.messages.listRoles.send(control);
|
||||
return result["list"].map(item => ({
|
||||
key: item["id"],
|
||||
"Name": item["name"],
|
||||
"Description": item["description"],
|
||||
}));
|
||||
}
|
||||
let [roles, setRoles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
libLink.messages.listRoles.send(control).then(result => {
|
||||
setRoles(result["list"]);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, []);
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
<h2>Roles</h2>
|
||||
<DataTable
|
||||
DataFunction={listRoles}
|
||||
AddRecord={{
|
||||
fields: [
|
||||
{ dataIndex: "name", title: "Name" },
|
||||
{ dataIndex: "description", title: "Description" },
|
||||
],
|
||||
insert: async args => {
|
||||
await libLink.messages.createRole.send(control, {
|
||||
name: args.name,
|
||||
description: args.description || "",
|
||||
permissions: [],
|
||||
});
|
||||
},
|
||||
}}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/roles/${record.key}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Roles"
|
||||
extra={account.hasPermission("core.role.create") && <CreateRoleButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
},
|
||||
]}
|
||||
dataSource={roles}
|
||||
pagination={false}
|
||||
rowKey={role => role["id"]}
|
||||
onRow={(role, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/roles/${role["id"]}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<PluginExtra component="RolesPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,17 +1,184 @@
|
||||
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";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
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";
|
||||
@@ -24,6 +191,7 @@ function formatBytes(bytes) {
|
||||
}
|
||||
|
||||
export default function SavesList(props) {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let saves = useSaves(props.instance.id);
|
||||
let [starting, setStarting] = useState(false);
|
||||
@@ -65,7 +233,7 @@ export default function SavesList(props) {
|
||||
columnWidth: 33,
|
||||
expandRowByClick: true,
|
||||
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
|
||||
<Button
|
||||
{account.hasPermission("core.instance.start") && <Button
|
||||
loading={starting}
|
||||
disabled={props.instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
@@ -78,8 +246,14 @@ export default function SavesList(props) {
|
||||
() => { setStarting(false); }
|
||||
);
|
||||
}}
|
||||
>Load save</Button>
|
||||
<Button
|
||||
>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={() => {
|
||||
libLink.messages.downloadSave.send(
|
||||
@@ -92,8 +266,11 @@ export default function SavesList(props) {
|
||||
notifyErrorHandler("Error downloading save")
|
||||
);
|
||||
}}
|
||||
>Download</Button>
|
||||
<Popconfirm
|
||||
>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"
|
||||
placement="top"
|
||||
@@ -105,7 +282,7 @@ export default function SavesList(props) {
|
||||
}}
|
||||
>
|
||||
<Button danger disabled={slaveOffline}>Delete</Button>
|
||||
</Popconfirm>
|
||||
</Popconfirm>}
|
||||
</Space>,
|
||||
}}
|
||||
/>;
|
||||
@@ -158,15 +335,19 @@ export default function SavesList(props) {
|
||||
};
|
||||
|
||||
return <div>
|
||||
<SectionHeader title="Saves" extra=<Space>
|
||||
<Upload {...uploadProps} >
|
||||
<SectionHeader title="Saves" extra={<Space>
|
||||
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
|
||||
<Button disabled={slaveOffline}>Upload save</Button>
|
||||
</Upload>
|
||||
<CreateSaveModal instance={props.instance} />
|
||||
</Space> />
|
||||
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
|
||||
{saveTable}
|
||||
</Upload.Dragger>
|
||||
</Upload>}
|
||||
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
|
||||
</Space>} />
|
||||
{
|
||||
account.hasPermission("core.instance.save.upload")
|
||||
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
|
||||
{saveTable}
|
||||
</Upload.Dragger>
|
||||
: saveTable
|
||||
}
|
||||
{uploadingFiles.length ? <List>
|
||||
{uploadingFiles.map(file => <List.Item key={file.uid}>
|
||||
{file.name}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
export default function SectionHeader(props) {
|
||||
return <div className="section-header">
|
||||
|
||||
@@ -26,25 +26,58 @@ export default function SiteLayout(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let accountMenu = <Menu>
|
||||
<Menu.ItemGroup title={account.name}/>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item 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,14 +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, path}) => (
|
||||
sidebarName ? <Menu.Item key={path}>{sidebarName}</Menu.Item> : null)
|
||||
)}
|
||||
</Menu>
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout className="site-layout-content-container">
|
||||
<Switch>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Descriptions, PageHeader, Spin, Tag, Typography } from "antd";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import InstanceList from "./InstanceList";
|
||||
import LogConsole from "./LogConsole";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
import { useSlave } from "../model/slave";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
export default function SlaveViewPage(props) {
|
||||
let params = useParams();
|
||||
let slaveId = Number(params.id);
|
||||
let account = useAccount();
|
||||
let [instanceList] = useInstanceList();
|
||||
let [slave] = useSlave(slaveId);
|
||||
|
||||
instanceList = instanceList.filter(instance => instance["assigned_slave"] === slaveId);
|
||||
|
||||
let nav = [{ name: "Slaves", path: "/slaves" }, { name: slave.name || slaveId }];
|
||||
if (slave.loading) {
|
||||
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
if (slave.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={slaveId}
|
||||
/>
|
||||
<p>Slave with id {slaveId} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={slave.name || slaveId}
|
||||
/>
|
||||
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
|
||||
<Descriptions.Item label="Name">{slave["name"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Connected">
|
||||
<Tag color={slave["connected"] ? "#389e0d" : "#cf1322"}>
|
||||
{slave["connected"] ? "Connected" : "Disconnected"}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Agent">{slave["agent"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Version">{slave["version"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{account.hasPermission("core.instance.list") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
|
||||
<InstanceList instances={instanceList} size="small" hideAssignedSlave />
|
||||
</>}
|
||||
{account.hasPermission("core.log.follow") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<LogConsole slaves={[slaveId]} />
|
||||
</>}
|
||||
<PluginExtra component="SlaveViewPage" slave={slave} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,24 +1,42 @@
|
||||
import React, { useContext, useRef, useState } from "react";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
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;
|
||||
|
||||
|
||||
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)) {
|
||||
@@ -30,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); }}
|
||||
@@ -45,64 +66,161 @@ 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 <token></p>
|
||||
<p>npx clusterioslave config set slave.id <slaveId></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>> mkdir clusterio</p>
|
||||
<p>> cd clusterio</p>
|
||||
<p>
|
||||
> 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>> ./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 control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let history = useHistory();
|
||||
let [slaveList] = useSlaveList();
|
||||
|
||||
return <PageLayout nav={[{ name: "Slaves" }]}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Slaves"
|
||||
extra=<GenerateSlaveTokenButton />
|
||||
extra={account.hasPermission("core.slave.generate_token") && <GenerateSlaveTokenButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
},
|
||||
{
|
||||
title: "Agent",
|
||||
dataIndex: "agent",
|
||||
sorter: (a, b) => strcmp(a["agent"], b["agent"]),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
dataIndex: "version",
|
||||
sorter: (a, b) => strcmp(a["version"], b["version"]),
|
||||
},
|
||||
{
|
||||
title: "Public address",
|
||||
dataIndex: "public_address",
|
||||
sorter: (a, b) => strcmp(a["public_address"], b["public_address"]),
|
||||
},
|
||||
{
|
||||
title: "Connected",
|
||||
key: "connected",
|
||||
render: slave => slave["connected"] && "Yes",
|
||||
render: slave => <Tag
|
||||
color={slave["connected"] ? "#389e0d" : "#cf1322"}
|
||||
>
|
||||
{slave["connected"] ? "Connected" : "Disconnected"}
|
||||
</Tag>,
|
||||
sorter: (a, b) => a["connected"] - b["connected"],
|
||||
},
|
||||
]}
|
||||
dataSource={slaveList}
|
||||
rowKey={slave => slave["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/slaves/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<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") {
|
||||
|
||||
@@ -1,52 +1,45 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
|
||||
import {
|
||||
Button, Col, 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;
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
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 => {
|
||||
@@ -56,28 +49,80 @@ 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}>
|
||||
<h2>{userName}</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={userName}
|
||||
/>
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (user.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>User not found</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="User not found"
|
||||
/>
|
||||
<p>User with name {userName} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
let roleSelector = <Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
initialValue={user["roles"]}
|
||||
rules={[
|
||||
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
onChange={() => setRolesDirty(true)}
|
||||
loading={!roles}
|
||||
mode="multiple"
|
||||
showArrow={true}
|
||||
filterOption={(inputValue, option) => {
|
||||
let role = roles.get(option.value);
|
||||
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
|
||||
}}
|
||||
>
|
||||
{roles && [...roles.values()].map(r => <Select.Option
|
||||
key={r["id"]}
|
||||
value={r["id"]}
|
||||
>{r["name"]}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>;
|
||||
|
||||
function instanceName(id) {
|
||||
let instance = instanceList.find(i => i.id === id);
|
||||
return instance ? instance["name"] : id;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Row>
|
||||
<Col flex="auto">
|
||||
<h2>{userName}</h2>
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
<Popconfirm
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
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={<>
|
||||
Delete user account and all data associated with it?
|
||||
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
|
||||
@@ -97,40 +142,38 @@ export default function UserViewPage() {
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Col>
|
||||
</Row>
|
||||
<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">
|
||||
<Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
initialValue={user["roles"]}
|
||||
rules={[
|
||||
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
onChange={() => setRolesDirty(true)}
|
||||
loading={!roles}
|
||||
mode="multiple"
|
||||
showArrow={true}
|
||||
filterOption={(inputValue, option) => {
|
||||
let role = roles.get(option.value);
|
||||
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
|
||||
}}
|
||||
>
|
||||
{roles && [...roles.values()].map(r => <Select.Option
|
||||
key={r["id"]}
|
||||
value={r["id"]}
|
||||
>{r["name"]}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{account.hasPermission("core.user.update_roles")
|
||||
? roleSelector
|
||||
: [...user["roles"]].map(id => <Tag key={id}>{
|
||||
roles ? (roles.get(id) || { name: id })["name"] : id
|
||||
}</Tag>)
|
||||
}
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
{account.hasPermission("core.user.update_roles") && <Col flex="0 0 auto">
|
||||
<Button
|
||||
type={"primary"}
|
||||
disabled={!rolesDirty}
|
||||
@@ -152,10 +195,10 @@ export default function UserViewPage() {
|
||||
});
|
||||
}}
|
||||
>Apply</Button>
|
||||
</Col>
|
||||
</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"]}
|
||||
onClick={() => {
|
||||
@@ -163,13 +206,11 @@ export default function UserViewPage() {
|
||||
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"]}
|
||||
onClick={() => {
|
||||
@@ -177,70 +218,122 @@ export default function UserViewPage() {
|
||||
name: userName,
|
||||
whitelisted: !user["is_whitelisted"],
|
||||
create: false,
|
||||
}).then(() => {
|
||||
updateUser();
|
||||
}).catch(notifyErrorHandler("Error toggling user whitelisted status"));
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Banned">
|
||||
<Space>
|
||||
{user["is_banned"] ? "Yes" : "No"}
|
||||
<Popover
|
||||
title="Ban user"
|
||||
visible={banUserPopover}
|
||||
trigger="click"
|
||||
onVisibleChange={() => {
|
||||
if (!user["is_banned"]) {
|
||||
setBanUserPopover(!banUserPopover);
|
||||
}
|
||||
}}
|
||||
content=<Form.Item label="Reason">
|
||||
<Row gutter={8}>
|
||||
<Col flex="auto">
|
||||
<Form.Item noStyle name="reason">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
</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>;
|
||||
}
|
||||
@@ -1,68 +1,129 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Spin, Tag } from "antd";
|
||||
import { Button, Form, Input, Modal, PageHeader, Space, Table, Tag } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import DataTable from "./data-table";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { 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;
|
||||
|
||||
|
||||
export default function UsersPage() {
|
||||
function CreateUserButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
|
||||
let [roles, setRoles] = useState(null);
|
||||
async function createUser() {
|
||||
let values = form.getFieldsValue();
|
||||
if (!values.userName) {
|
||||
form.setFields([{ name: "userName", errors: ["Name is required"] }]);
|
||||
return;
|
||||
}
|
||||
|
||||
await libLink.messages.createUser.send(control, { name: values.userName });
|
||||
setVisible(false);
|
||||
history.push(`/users/${values.userName}/view`);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setVisible(true); }}
|
||||
>Create</Button>
|
||||
<Modal
|
||||
title="Create User"
|
||||
okText="Create"
|
||||
visible={visible}
|
||||
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
||||
onCancel={() => { setVisible(false); }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="userName" label="Name">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [userList] = useUserList();
|
||||
|
||||
let [roles, setRoles] = useState(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
libLink.messages.listRoles.send(control).then(result => {
|
||||
setRoles(new Map(result["list"].map(role => [role["id"], role])));
|
||||
}).catch(() => {
|
||||
setRoles(new Map());
|
||||
// ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function listUsers() {
|
||||
let result = await libLink.messages.listUsers.send(control);
|
||||
return result["list"].map(item => ({
|
||||
key: item["name"],
|
||||
"Name": item["name"],
|
||||
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
|
||||
"Admin": item["is_admin"] && "Yes",
|
||||
"Whitelisted": item["is_whitelisted"] && "Yes",
|
||||
"Banned": item["is_banned"] && "Yes",
|
||||
}));
|
||||
}
|
||||
|
||||
// DataTable does not make it possible to update the rendering function
|
||||
if (roles === null) {
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<h2>Users</h2>
|
||||
<Spin/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<h2>Users</h2>
|
||||
<DataTable
|
||||
DataFunction={listUsers}
|
||||
AddRecord={{
|
||||
fields: [{
|
||||
dataIndex: "name",
|
||||
title: "Name",
|
||||
}],
|
||||
insert: async args => {
|
||||
await libLink.messages.createUser.send(control, { name: args.name });
|
||||
},
|
||||
}}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/users/${record.key}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Users"
|
||||
extra={account.hasPermission("core.user.create") && <CreateUserButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "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"]),
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
|
||||
},
|
||||
{
|
||||
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: "Last seen",
|
||||
key: "lastSeen",
|
||||
render: user => formatLastSeen(user),
|
||||
sorter: sortLastSeen,
|
||||
responsive: ["lg"],
|
||||
},
|
||||
]}
|
||||
dataSource={userList}
|
||||
pagination={false}
|
||||
rowKey={user => user["name"]}
|
||||
onRow={(user, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/users/${user["name"]}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<PluginExtra component="UsersPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import notify from "../util/notify";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
/**
|
||||
* Display data in table. Allows for editing data by clicking rows in the table.
|
||||
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
|
||||
*
|
||||
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
|
||||
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
|
||||
* Editable - Boolean - Allow editing
|
||||
* AddRecord - Boolean - Allow adding new records
|
||||
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
|
||||
* RecordIdField - String -
|
||||
* By default we assume the column "Id" is a unique identifier for a record. If
|
||||
* that isn't the case, supply the column name here
|
||||
*/
|
||||
class DataTable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!this.props.DataFunction) {
|
||||
throw new Error("DataFunction not defined");
|
||||
}
|
||||
this.state = {
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
current: 1,
|
||||
},
|
||||
filters: this.props.Filters,
|
||||
sorter: {},
|
||||
tableData: [],
|
||||
showEditModal: false,
|
||||
record: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async getData(pagination, filters, sorter) {
|
||||
// pagination = {
|
||||
// pageSize: 10, // items per page
|
||||
// current: 1, // page number
|
||||
// total: 123, // Total amount of items
|
||||
// }
|
||||
// filters = {
|
||||
// ReportId: 123
|
||||
// }
|
||||
// sorter = {}
|
||||
pagination = pagination || this.state.pagination;
|
||||
filters = filters || this.state.filters;
|
||||
sorter = sorter || this.state.sorter;
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
let data = await this.props.DataFunction();
|
||||
data = data || {};
|
||||
this.setState({
|
||||
// eslint-disable-next-line new-cap
|
||||
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
|
||||
pagination: { ...pagination, ...data.pagination },
|
||||
sorter,
|
||||
filters,
|
||||
});
|
||||
}
|
||||
|
||||
showEditModal(record, index) {
|
||||
if (!this.state.showEditModal) {
|
||||
this.setState({
|
||||
record: record,
|
||||
showEditModal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideEditModal() {
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
}
|
||||
|
||||
async onEditFinish(values) {
|
||||
if (this.state.inserting) { // Create new record
|
||||
await this.props.AddRecord.insert(values);
|
||||
} else { // Edit existing record, capable of doing sparse updates
|
||||
await this.props.Editable.insert(values);
|
||||
}
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async deleteRecord(record) {
|
||||
delete record.key;
|
||||
// Delete "null" values as they seem to cause deletes to fail silently when
|
||||
// the value is null and field type is float
|
||||
for (let key in record) {
|
||||
if (record[key] === null) { delete record[key]; }
|
||||
}
|
||||
let status = {};
|
||||
// TODO: Delete data here
|
||||
|
||||
if (status.ok) { notify("success", status.msg, "success"); }
|
||||
if (!status.ok) { notify("error", status.msg, "error"); }
|
||||
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
render() {
|
||||
let tableProps = {
|
||||
...this.props.TableProps || {},
|
||||
onRow: (record, rowIndex) => {
|
||||
// To allow for extending functionality, we take a TableProps argument
|
||||
// from props and pass it through to the Table component. Since we
|
||||
// are also using onRow internaly here, we handle the externally
|
||||
// provided events ourselves by passing along the event object. This
|
||||
// way the end user isn't affected by our event handlers.
|
||||
let externalOnRow = {
|
||||
...(
|
||||
this.props.TableProps
|
||||
&& this.props.TableProps.onRow
|
||||
&& this.props.TableProps.onRow(record, rowIndex)
|
||||
),
|
||||
};
|
||||
return {
|
||||
onClick: event => {
|
||||
if (event.target.type !== "button") {
|
||||
if (this.props.Editable) { this.showEditModal(record, rowIndex); }
|
||||
if (externalOnRow.onClick) { externalOnRow.onClick(event); }
|
||||
}
|
||||
},
|
||||
onDoubleClick: event => {
|
||||
if (externalOnRow.onDoubleClick) { externalOnRow.onDoubleClick(event); }
|
||||
},
|
||||
onContextMenu: event => {
|
||||
if (externalOnRow.onContextMenu) { externalOnRow.onContextMenu(event); }
|
||||
},
|
||||
onMouseEnter: event => {
|
||||
if (externalOnRow.onMouseEnter) { externalOnRow.onMouseEnter(event); }
|
||||
},
|
||||
onMouseLeave: event => {
|
||||
if (externalOnRow.onMouseLeave) { externalOnRow.onMouseLeave(event); }
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
let columns = this.props.columns || this.inferColumns(this.state.tableData);
|
||||
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) {
|
||||
columns.push({
|
||||
// Add delete button on the right
|
||||
dataIndex: "notInUse",
|
||||
title: "",
|
||||
render: (text, record) => <Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => {
|
||||
this.deleteRecord(record);
|
||||
}}
|
||||
>
|
||||
Slett permanent
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>,
|
||||
});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<style>
|
||||
{`
|
||||
.editableTable .ant-table-tbody > tr {
|
||||
cursor:pointer
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Table
|
||||
className={this.props.Editable && "editableTable"}
|
||||
{...tableProps}
|
||||
columns={columns.map(x => ({
|
||||
...x, // Add a 4th parameter to the stock render function with all of our table data
|
||||
render: x.render
|
||||
? (...originalParams) => x.render(...originalParams, this.state.tableData)
|
||||
: undefined,
|
||||
}))}
|
||||
dataSource={this.state.tableData}
|
||||
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
|
||||
onChange={this.getData.bind(this)}
|
||||
/>
|
||||
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
|
||||
showEditModal: true,
|
||||
inserting: true,
|
||||
})}>
|
||||
<PlusOutlined />Add
|
||||
</Button>}
|
||||
</Col>
|
||||
<Modal
|
||||
visible={this.state.showEditModal}
|
||||
onCancel={this.hideEditModal.bind(this)}
|
||||
footer={null}
|
||||
title={
|
||||
<h2>{this.state.inserting ? "Add" : "Edit"}</h2>
|
||||
}
|
||||
>
|
||||
{this.state.showEditModal && <Form
|
||||
labelCol={{
|
||||
span: 6,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 12,
|
||||
}}
|
||||
layout="horizontal"
|
||||
onFinish={this.onEditFinish.bind(this)}
|
||||
initialValues={this.state.record}
|
||||
>
|
||||
{(
|
||||
// Use externally supplied form input fields
|
||||
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
|
||||
// Generate input fields from external columns
|
||||
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
|
||||
// Generate input fields from autogenerated columns (only works with existing data)
|
||||
|| this.inferColumns(this.state.tableData)
|
||||
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
|
||||
{col.renderEdit && col.renderEdit(
|
||||
this.state.record[col.dataIndex],
|
||||
this.state.record,
|
||||
undefined /* Non spec compliant, is supposed to be index of record */,
|
||||
this.state.tableData
|
||||
) || <Input placeholder={this.state.record[col.dataIndex]} />}
|
||||
</Form.Item>)}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{this.state.inserting ? "Save" : "Save"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</Modal>
|
||||
</Row>
|
||||
</>;
|
||||
}
|
||||
|
||||
inferColumns(data) {
|
||||
// Look at the data and find columns and how to display them. Can be
|
||||
// overridden by props.Columns if more customization is needed
|
||||
let columns = [];
|
||||
data.forEach(row => {
|
||||
Object.keys(row).filter(x => x !== "key").forEach(key => {
|
||||
let existing = columns.find(col => col.key === key);
|
||||
if (!existing) {
|
||||
columns.push({
|
||||
key: key,
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from "./model/instance.jsx";
|
||||
export * from "./model/slave.jsx";
|
||||
export * from "./model/locale.jsx";
|
||||
export * from "./model/item_metadata.jsx";
|
||||
export * from "./model/account.jsx";
|
||||
|
||||
|
||||
export { default as ControlContext } from "./components/ControlContext";
|
||||
@@ -13,9 +14,14 @@ export { default as BaseConfigTree } from "./components/BaseConfigTree";
|
||||
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
|
||||
export { default as MasterConfigTree } from "./components/MasterConfigTree";
|
||||
|
||||
export { default as LogConsole } from "./components/LogConsole";
|
||||
|
||||
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
|
||||
export { default as InstanceConsole } from "./components/InstanceConsole";
|
||||
export { default as InstanceList } from "./components/InstanceList";
|
||||
export { default as InstanceRcon } from "./components/InstanceRcon";
|
||||
export { default as InstanceStatusTag } from "./components/InstanceStatusTag";
|
||||
export { default as StartStopInstanceButton } from "./components/StartStopInstanceButton";
|
||||
export { statusColors } from "./components/InstanceStatusTag";
|
||||
|
||||
export { default as PageLayout } from "./components/PageLayout";
|
||||
export { default as SavesList } from "./components/SavesList";
|
||||
|
||||
@@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext";
|
||||
/**
|
||||
* @typedef {Object} Account
|
||||
* @property {?string} name - Name of the currently logged in account.
|
||||
* @property {function()} logout - Logs out of the web interface.
|
||||
* @property {?Array<object>} roles - Roles of the corrently logged in account.
|
||||
* @property {function(string)} hasPermission -
|
||||
* Check if the currently logged in account has the given permission.
|
||||
* @property {function()} logOut - Logs out of the web interface.
|
||||
*/
|
||||
|
||||
export function useAccount() {
|
||||
let control = useContext(ControlContext);
|
||||
let [name, setName] = useState(control.accountName);
|
||||
let [roles, setRoles] = useState(control.accountRoles);
|
||||
|
||||
useEffect(() => {
|
||||
function onConnect(data) {
|
||||
setName(data.account.name);
|
||||
function onAccountUpdate(account) {
|
||||
setName(account.name);
|
||||
setRoles(account.roles);
|
||||
}
|
||||
function onClose() {
|
||||
setName(null);
|
||||
}
|
||||
control.connector.on("connect", onConnect);
|
||||
control.connector.on("close", onClose);
|
||||
control.onAccountUpdate(onAccountUpdate);
|
||||
return () => {
|
||||
control.connector.off("connect", onConnect);
|
||||
control.connector.off("close", onClose);
|
||||
control.offAccountUpdate(onAccountUpdate);
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
function hasPermission(permission) {
|
||||
for (let role of roles) {
|
||||
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
roles,
|
||||
hasPermission(permission) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
return hasPermission(permission);
|
||||
},
|
||||
hasAnyPermission(...permissions) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
for (let permission of permissions) {
|
||||
if (hasPermission(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasAllPermission(...permissions) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
for (let permission of permissions) {
|
||||
if (!hasPermission(permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
logOut() {
|
||||
control.loggingOut = true;
|
||||
control.shutdown(notifyErrorHandler("Error logging out"));
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
@@ -14,10 +14,15 @@ export function useSaves(instanceId) {
|
||||
setSaves(result.list);
|
||||
}).catch(err => {
|
||||
logger.error(`Failed to list instance saves: ${err}`);
|
||||
setSaves([]);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isInteger(instanceId)) {
|
||||
setSaves([]);
|
||||
return undefined;
|
||||
}
|
||||
updateSaves();
|
||||
|
||||
function updateHandler(data) {
|
||||
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user