mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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,51 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x, 14.x, 16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm i --no-optional
|
||||
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- run: npx lerna bootstrap --hoist
|
||||
- run: 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
|
||||
- run: npx lerna run build
|
||||
- name: Run tests
|
||||
if: ${{ matrix.node-version != '12.x' }}
|
||||
run: npm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.node-version == '12.x' }}
|
||||
run: npm 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: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.x
|
||||
- run: npm i --no-optional
|
||||
- run: npx lerna bootstrap --hoist
|
||||
- run: npm run lint
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
||||
-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
-1
@@ -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
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ Otherwise see below for OS specific instructions.
|
||||
|
||||
**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.
|
||||
|
||||
<!--
|
||||
|
||||
@@ -183,11 +183,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
|
||||
|
||||
@@ -10,13 +10,76 @@ 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.
|
||||
|
||||
|
||||
### 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 +137,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
|
||||
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"plugins/*",
|
||||
"test"
|
||||
],
|
||||
"version": "2.0.0-alpha.10"
|
||||
"version": "2.0.0-alpha.12"
|
||||
}
|
||||
+24
-13
@@ -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) {
|
||||
@@ -165,7 +170,7 @@ async function validateInstallDir() {
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.writeFile("package.json", JSON.stringify({
|
||||
await safeOutputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -213,7 +218,7 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
@@ -223,17 +228,17 @@ async function installClusterio(mode, plugins) {
|
||||
async function writeScripts(mode) {
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriomaster.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Master
|
||||
@@ -254,17 +259,17 @@ WantedBy=multi-user.target
|
||||
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusterioslave.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Slave
|
||||
@@ -427,7 +432,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",
|
||||
@@ -470,7 +479,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 {
|
||||
@@ -521,7 +530,9 @@ async function main() {
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
});
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args = args
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.10.b",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
+293
-23
@@ -14,6 +14,10 @@ const path = require("path");
|
||||
const winston = require("winston");
|
||||
const setBlocking = require("set-blocking");
|
||||
const phin = require("phin");
|
||||
const os = require("os");
|
||||
const child_process = require("child_process");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
@@ -28,12 +32,68 @@ const libHelpers = require("@clusterio/lib/helpers");
|
||||
const libFactorio = require("@clusterio/lib/factorio");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
function print(...content) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
|
||||
async function getEditor(argsEditor) {
|
||||
// eslint-disable-next-line
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || -1
|
||||
// needed for the process.env statements to not be flagged by eslint
|
||||
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
|
||||
}
|
||||
|
||||
async function configToKeyVal(data) {
|
||||
let final = {};
|
||||
let splitData = data.split(/\r?\n/);
|
||||
// split on newlines
|
||||
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
|
||||
// the last filter removes empty elements left by the first. Not done on one line due to readability.
|
||||
for (let index in filtered) {
|
||||
if (index in filtered) {
|
||||
filtered[index] = filtered[index].split("=");
|
||||
let finalIndex = filtered[index][0].trim();
|
||||
// split on the = we added earlier, giving us both value and key
|
||||
let part = "";
|
||||
try {
|
||||
part = filtered[index][1].trim();
|
||||
// it's a string if we can read it
|
||||
} catch (err) {
|
||||
// if we can't read it, it's a empty field and therefor null
|
||||
part = "";
|
||||
}
|
||||
final[finalIndex] = part;
|
||||
}
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
async function serializedConfigToString(serializedConfig, configGroup, disallowedList) {
|
||||
let allConfigElements = "";
|
||||
for (let group of serializedConfig.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
if (`${group.name}.${name}` in disallowedList) {
|
||||
continue;
|
||||
}
|
||||
let desc = "";
|
||||
try {
|
||||
desc += configGroup.groups.get(group.name)._definitions.get(name).description;
|
||||
} catch (err) {
|
||||
desc += "No description found";
|
||||
}
|
||||
// split onto two lines for readability and es-lint
|
||||
if (String(value) === "null") {
|
||||
value = "";
|
||||
}
|
||||
allConfigElements += `${group.name}.${name} = ${value}\n\n`;
|
||||
}
|
||||
}
|
||||
return allConfigElements;
|
||||
}
|
||||
|
||||
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
|
||||
const masterConfigCommands = new libCommand.CommandTree({
|
||||
@@ -104,6 +164,66 @@ masterConfigCommands.add(new libCommand.Command({
|
||||
await libLink.messages.setMasterConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
masterConfigCommands.add(new libCommand.Command({
|
||||
definition: ["edit [editor]", "Edit master configuration", (yargs) => {
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.getMasterConfig.send(control);
|
||||
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === -1) {
|
||||
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl master config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {});
|
||||
await fs.writeFile(tmpFile, allConfigElements, (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await libLink.messages.setMasterConfigField.send(control, {
|
||||
field: index,
|
||||
value: final[index],
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
print(err);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
await fs.unlink(tmpFile, (err) => {
|
||||
if (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
masterCommands.add(masterConfigCommands);
|
||||
|
||||
|
||||
@@ -207,7 +327,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
let serialized_config = instanceConfig.serialize("master");
|
||||
let response = await libLink.messages.createInstance.send(control, { serialized_config });
|
||||
await libLink.messages.createInstance.send(control, { serialized_config });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -300,6 +420,79 @@ instanceConfigCommands.add(new libCommand.Command({
|
||||
await libLink.messages.setInstanceConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
|
||||
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === -1) {
|
||||
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl master config edit <editor of choice>"`);
|
||||
}
|
||||
let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0};
|
||||
let allConfigElements = await serializedConfigToString(
|
||||
response.serialized_config,
|
||||
libConfig.InstanceConfig,
|
||||
disallowedList
|
||||
);
|
||||
await fs.writeFile(tmpFile, allConfigElements, (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await libLink.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: index,
|
||||
value: final[index],
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
await fs.unlink(tmpFile, (err) => {
|
||||
if (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
@@ -340,8 +533,11 @@ async function loadMapSettings(args) {
|
||||
};
|
||||
}
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
|
||||
const instanceSaveCommands = new libCommand.CommandTree({
|
||||
name: "save", alias: ["s"], description: "Instance save management",
|
||||
});
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["list <instance>", "list saves on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
@@ -355,8 +551,8 @@ instanceCommands.add(new libCommand.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["create <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({
|
||||
@@ -370,7 +566,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
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, {
|
||||
await libLink.messages.createSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
name: args.name,
|
||||
seed,
|
||||
@@ -380,8 +576,40 @@ instanceCommands.add(new libCommand.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
|
||||
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
|
||||
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.renameSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
old_name: args.oldName,
|
||||
new_name: args.newName,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
|
||||
yargs.positional("source", { describe: "Save to copy.", type: "string" });
|
||||
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.copySave.send(control, {
|
||||
instance_id: instanceId,
|
||||
source: args.source,
|
||||
destination: args.destination,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["upload <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({
|
||||
@@ -418,7 +646,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
}
|
||||
|
||||
for (let requestError of result.body.request_errors || []) {
|
||||
logger.error(error);
|
||||
logger.error(requestError);
|
||||
}
|
||||
|
||||
if (result.body.saves && result.body.saves.length) {
|
||||
@@ -431,8 +659,36 @@ instanceCommands.add(new libCommand.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["download-save <instance> <save>", "Download a save from an instance", (yargs) => {
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: [
|
||||
"transfer <source-instance> <source-save> <target-instance> [target-save]",
|
||||
"Transfer a save between instances",
|
||||
(yargs) => {
|
||||
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
|
||||
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
|
||||
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
|
||||
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
|
||||
yargs.options({
|
||||
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
|
||||
});
|
||||
},
|
||||
],
|
||||
handler: async function(args, control) {
|
||||
let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance);
|
||||
let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance);
|
||||
let result = await libLink.messages.transferSave.send(control, {
|
||||
instance_id: sourceInstanceId,
|
||||
source_save: args.sourceSave,
|
||||
target_instance_id: targetInstanceId,
|
||||
target_save: args.targetSave || args.sourceSave,
|
||||
copy: args.copy,
|
||||
});
|
||||
print(`Transferred as ${result.save} to ${args.targetInstance}.`);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["download <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" });
|
||||
}],
|
||||
@@ -451,12 +707,12 @@ instanceCommands.add(new libCommand.Command({
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let stream;
|
||||
let writeStream;
|
||||
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
while (true) {
|
||||
try {
|
||||
stream = fs.createWriteStream(tempFilename, { flags: "wx" });
|
||||
await events.once(stream, "open");
|
||||
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
@@ -466,8 +722,8 @@ instanceCommands.add(new libCommand.Command({
|
||||
}
|
||||
}
|
||||
}
|
||||
response.pipe(stream);
|
||||
await events.once(stream, "finish");
|
||||
response.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
let filename = await libFileOps.findUnusedName(".", args.save, ".zip");
|
||||
await fs.rename(tempFilename, filename);
|
||||
@@ -476,8 +732,8 @@ instanceCommands.add(new libCommand.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["delete-save <instance> <save>", "Delete a save from an instance", (yargs) => {
|
||||
instanceSaveCommands.add(new libCommand.Command({
|
||||
definition: ["delete <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" });
|
||||
}],
|
||||
@@ -489,6 +745,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
});
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceSaveCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
|
||||
@@ -497,7 +754,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
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, {
|
||||
await libLink.messages.exportData.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
@@ -514,7 +771,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
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, {
|
||||
await libLink.messages.startInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save || null,
|
||||
});
|
||||
@@ -538,7 +795,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
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, {
|
||||
await libLink.messages.loadScenario.send(control, {
|
||||
instance_id: instanceId,
|
||||
scenario: args.scenario,
|
||||
seed,
|
||||
@@ -556,7 +813,20 @@ instanceCommands.add(new libCommand.Command({
|
||||
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, {
|
||||
await libLink.messages.stopInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["kill <instance>", "Kill instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instance_ids: [instanceId] });
|
||||
await libLink.messages.killInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
@@ -567,7 +837,7 @@ instanceCommands.add(new libCommand.Command({
|
||||
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.deleteInstance.send(control, {
|
||||
await libLink.messages.deleteInstance.send(control, {
|
||||
instance_id: await libCommand.resolveInstance(control, args.instance),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"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.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"as-table": "^1.0.31",
|
||||
"fs-extra": "^8.1.0",
|
||||
"phin": "^3.5.1",
|
||||
|
||||
@@ -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,
|
||||
@@ -376,9 +376,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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+44
-107
@@ -6,12 +6,14 @@ const path = require("path");
|
||||
const events = require("events");
|
||||
const util = require("util");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const libIni = 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
|
||||
@@ -561,9 +488,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 +501,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 +616,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 +687,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 +797,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}`);
|
||||
}
|
||||
@@ -1078,12 +1010,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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1173,17 +1111,17 @@ class FactorioServer extends events.EventEmitter {
|
||||
"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 +1138,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,
|
||||
};
|
||||
@@ -445,7 +445,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
return;
|
||||
}
|
||||
|
||||
let { seq, type, data } = message;
|
||||
let { type, data } = message;
|
||||
if (type === "hello") {
|
||||
logger.verbose(`Connector | received hello from master version ${data.version}`);
|
||||
this.emit("hello", data);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -337,6 +337,7 @@ let slaveProperties = {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"connected": { type: "boolean" },
|
||||
"public_address": { type: ["null", "string"] },
|
||||
};
|
||||
|
||||
messages.listSlaves = new Request({
|
||||
@@ -372,6 +373,7 @@ let instanceProperties = {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"assigned_slave": { type: ["null", "integer"] },
|
||||
"game_port": { type: ["null", "integer"] },
|
||||
"status": { enum: [
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
@@ -548,6 +550,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 +595,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"],
|
||||
@@ -634,6 +674,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"],
|
||||
@@ -1145,11 +1192,13 @@ messages.instanceInitialized = new Event({
|
||||
messages.instanceStatusChanged = new Event({
|
||||
type: "instance_status_changed",
|
||||
links: ["instance-slave", "slave-master"],
|
||||
eventRequired: ["instance_id", "status"],
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"status": { enum: [
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]},
|
||||
"game_port": { type: ["null", "integer"] },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,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.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"browser": "browser.js",
|
||||
|
||||
+66
-5
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const helpers = require("./helpers");
|
||||
|
||||
/**
|
||||
* Conceptual base for master and instance plugins.
|
||||
@@ -77,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.
|
||||
*/
|
||||
@@ -354,6 +355,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.
|
||||
*
|
||||
@@ -362,7 +376,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.
|
||||
*/
|
||||
@@ -574,11 +588,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.
|
||||
@@ -639,6 +656,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 = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -733,8 +786,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) {
|
||||
|
||||
@@ -178,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,
|
||||
};
|
||||
+25
-5
@@ -379,7 +379,7 @@ definePermission({
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list",
|
||||
title: "List saves on instance",
|
||||
title: "List saves",
|
||||
description: "List the saves currently on the instance.",
|
||||
});
|
||||
definePermission({
|
||||
@@ -389,22 +389,37 @@ definePermission({
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.create",
|
||||
title: "Create new instance save",
|
||||
title: "Create new save",
|
||||
description: "Create new savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.rename",
|
||||
title: "Rename save",
|
||||
description: "Rename savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.copy",
|
||||
title: "Copy save",
|
||||
description: "Create copies of savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.delete",
|
||||
title: "Delete instance save",
|
||||
title: "Delete save",
|
||||
description: "Delete savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.upload",
|
||||
title: "Upload save to instance",
|
||||
title: "Upload save",
|
||||
description: "Upload savegames to instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.transfer",
|
||||
title: "Transfer save",
|
||||
description: "Transfer savegames between instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.download",
|
||||
title: "Download save from instance",
|
||||
title: "Download save",
|
||||
description: "Download savegames from instances.",
|
||||
});
|
||||
definePermission({
|
||||
@@ -427,6 +442,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",
|
||||
|
||||
+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,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio master server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"main": "master.js",
|
||||
"scripts": {
|
||||
"build": "webpack-cli --env production",
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"busboy": "^0.3.1",
|
||||
"compression": "^1.7.2",
|
||||
"express": "^4.16.2",
|
||||
@@ -28,11 +28,12 @@
|
||||
"jszip": "^3.2.2",
|
||||
"set-blocking": "^2.0.0",
|
||||
"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.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -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");
|
||||
@@ -106,6 +105,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 +160,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 +178,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 +198,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 +247,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 +260,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 +280,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 +295,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 +303,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 +364,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 +374,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 +465,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 +474,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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +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");
|
||||
@@ -95,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}`);
|
||||
}
|
||||
|
||||
@@ -116,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");
|
||||
@@ -152,6 +162,9 @@ class Master {
|
||||
this.userManager = new UserManager(this.config);
|
||||
await this.userManager.load(path.join(databaseDirectory, "users.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);
|
||||
}
|
||||
@@ -239,9 +252,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); });
|
||||
@@ -297,7 +314,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) {
|
||||
@@ -329,7 +346,34 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -367,6 +411,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),
|
||||
};
|
||||
|
||||
@@ -379,6 +424,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") {
|
||||
@@ -563,11 +623,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");
|
||||
|
||||
@@ -28,6 +28,7 @@ class SlaveConnection extends BaseConnection {
|
||||
id: this._id,
|
||||
name: this._name,
|
||||
version: this._version,
|
||||
public_address: registerData.public_address,
|
||||
plugins: registerData.plugins,
|
||||
});
|
||||
|
||||
@@ -87,6 +88,7 @@ class SlaveConnection extends BaseConnection {
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = message.data.status;
|
||||
instance.game_port = message.data.game_port;
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
this._master.instanceUpdated(instance);
|
||||
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -176,7 +176,7 @@ ${err.stack}`
|
||||
return;
|
||||
}
|
||||
|
||||
let { seq, type, data } = message;
|
||||
let { type, data } = message;
|
||||
|
||||
if (type === "resume") {
|
||||
let connector;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
const Busboy = require("busboy");
|
||||
const crypto = require("crypto");
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const JSZip = require("jszip");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const path = require("path");
|
||||
const util = require("util");
|
||||
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
@@ -187,7 +187,7 @@ async function uploadExport(req, res) {
|
||||
}
|
||||
|
||||
let name = path.posix.basename(filePath);
|
||||
await fs.outputFile(path.join("static", "export", name), await file.async("nodebuffer"));
|
||||
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
|
||||
@@ -25,6 +25,8 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
"react-dom": { singleton: true },
|
||||
"react-router": { singleton: true },
|
||||
"react-router-dom": { singleton: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio slave server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"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.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"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": {
|
||||
|
||||
+178
-54
@@ -20,9 +20,11 @@ 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");
|
||||
const version = require("./package").version;
|
||||
const winston = require("winston");
|
||||
require("winston-daily-rotate-file");
|
||||
|
||||
// internal libraries
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
@@ -39,6 +41,8 @@ const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
const libHelpers = require("@clusterio/lib/helpers");
|
||||
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
const instanceRconCommandDuration = new libPrometheus.Histogram(
|
||||
"clusterio_instance_rcon_command_duration_seconds",
|
||||
@@ -182,7 +186,11 @@ class Instance extends libLink.Link {
|
||||
});
|
||||
|
||||
this.server.on("error", err => {
|
||||
this.logger.error(`Error in instance ${this.name}:\n${err.stack}`);
|
||||
if (err instanceof libErrors.EnvironmentError) {
|
||||
this.logger.error(err.message);
|
||||
} else {
|
||||
this.logger.error(`${this.name}:\n${err.stack}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on("autosave-finished", name => {
|
||||
@@ -191,6 +199,12 @@ class Instance extends libLink.Link {
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on("save-finished", () => {
|
||||
this.sendSaveListUpdate().catch(err => {
|
||||
this.logger.error(`Error handling save-finished in instance ${this.name}:\n${err.stack}`);
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on("ipc-player_event", event => {
|
||||
libLink.messages.playerEvent.send(this, {
|
||||
instance_id: this.id,
|
||||
@@ -260,7 +274,9 @@ class Instance extends libLink.Link {
|
||||
notifyStatus(status) {
|
||||
this._status = status;
|
||||
libLink.messages.instanceStatusChanged.send(this, {
|
||||
instance_id: this.id, status,
|
||||
instance_id: this.id,
|
||||
status,
|
||||
game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,7 +392,7 @@ class Instance extends libLink.Link {
|
||||
*/
|
||||
async writeServerSettings() {
|
||||
let serverSettings = await this.resolveServerSettings(this.config.get("factorio.settings"));
|
||||
await fs.writeFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-settings.json"),
|
||||
JSON.stringify(serverSettings, null, 4)
|
||||
);
|
||||
@@ -410,7 +426,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist")) {
|
||||
this.logger.verbose("Writing server-adminlist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-adminlist.json"),
|
||||
JSON.stringify([...this._slave.adminlist], null, 4)
|
||||
);
|
||||
@@ -418,7 +434,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_banlist")) {
|
||||
this.logger.verbose("Writing server-banlist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-banlist.json"),
|
||||
JSON.stringify([...this._slave.banlist].map(
|
||||
([username, reason]) => ({ username, reason })
|
||||
@@ -428,7 +444,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_whitelist")) {
|
||||
this.logger.verbose("Writing server-whitelist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-whitelist.json"),
|
||||
JSON.stringify([...this._slave.whitelist], null, 4)
|
||||
);
|
||||
@@ -691,6 +707,13 @@ class Instance extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async kill() {
|
||||
if (this._status === "stopped") {
|
||||
return;
|
||||
}
|
||||
await this.server.kill(true);
|
||||
}
|
||||
|
||||
async masterConnectionEventEventHandler(message) {
|
||||
await libPlugin.invokeHook(this.plugins, "onMasterConnectionEvent", message.data.event);
|
||||
}
|
||||
@@ -827,6 +850,10 @@ class Instance extends libLink.Link {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async killInstanceRequestHandler() {
|
||||
await this.kill();
|
||||
}
|
||||
|
||||
async sendRconRequestHandler(message) {
|
||||
let result = await this.sendRcon(message.data.command);
|
||||
return { result };
|
||||
@@ -886,6 +913,11 @@ async function discoverInstances(instancesDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
|
||||
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let instancePath = path.join(instancesDir, entry.name);
|
||||
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
instanceInfos.set(instanceConfig.get("instance.id"), {
|
||||
@@ -999,6 +1031,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
|
||||
version,
|
||||
id: this.slaveConfig.get("slave.id"),
|
||||
name: this.slaveConfig.get("slave.name"),
|
||||
public_address: this.slaveConfig.get("slave.public_address"),
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
@@ -1044,6 +1077,15 @@ function checkFilename(name) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkRequestSaveName(name) {
|
||||
try {
|
||||
checkFilename(name);
|
||||
} catch (err) {
|
||||
throw new libErrors.RequestError(`Save name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles running the slave
|
||||
*
|
||||
@@ -1092,6 +1134,10 @@ class Slave extends libLink.Link {
|
||||
}
|
||||
|
||||
this.updateInstances().catch((err) => {
|
||||
if (err instanceof libErrors.SessionLost) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
|
||||
return this.shutdown();
|
||||
});
|
||||
@@ -1294,7 +1340,7 @@ class Slave extends libLink.Link {
|
||||
_warning: "Changes to this file will be overwritten by the master server's copy.",
|
||||
...instanceInfo.config.serialize(),
|
||||
};
|
||||
await fs.outputFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
path.join(instanceInfo.path, "instance.json"),
|
||||
JSON.stringify(warnedOutput, null, 4)
|
||||
);
|
||||
@@ -1314,6 +1360,14 @@ class Slave extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
@@ -1321,11 +1375,7 @@ class Slave extends libLink.Link {
|
||||
* @returns {module:slave/slave~InstanceConnection} connection to instance.
|
||||
*/
|
||||
async _connectInstance(instanceId) {
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
@@ -1387,11 +1437,7 @@ class Slave extends libLink.Link {
|
||||
return await request.send(instanceConnection, message.data);
|
||||
}
|
||||
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
return {
|
||||
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
|
||||
};
|
||||
@@ -1403,6 +1449,85 @@ class Slave extends libLink.Link {
|
||||
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;
|
||||
@@ -1417,15 +1542,8 @@ class Slave extends libLink.Link {
|
||||
|
||||
async deleteSaveRequestHandler(message) {
|
||||
let { instance_id, save } = message.data;
|
||||
try {
|
||||
checkFilename(save);
|
||||
} catch (err) {
|
||||
throw new libErrors.RequestError(`Save name ${err.message}`);
|
||||
}
|
||||
let instanceInfo = this.instanceInfos.get(instance_id);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
|
||||
}
|
||||
checkRequestSaveName(save);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instance_id);
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(instanceInfo.path, "saves", save));
|
||||
@@ -1440,15 +1558,8 @@ class Slave extends libLink.Link {
|
||||
|
||||
async pullSaveRequestHandler(message) {
|
||||
let { instance_id, stream_id, filename } = message.data;
|
||||
try {
|
||||
checkFilename(filename);
|
||||
} catch (err) {
|
||||
throw new libErrors.RequestError(`Save name ${err.message}`);
|
||||
}
|
||||
let instanceInfo = this.instanceInfos.get(instance_id);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
|
||||
}
|
||||
checkRequestSaveName(filename);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instance_id);
|
||||
|
||||
let url = new URL(this.config.get("slave.master_url"));
|
||||
url.pathname += `api/stream/${stream_id}`;
|
||||
@@ -1465,11 +1576,11 @@ class Slave extends libLink.Link {
|
||||
|
||||
let savesDir = path.join(instanceInfo.path, "saves");
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
let stream;
|
||||
let writeStream;
|
||||
while (true) {
|
||||
try {
|
||||
stream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
|
||||
await events.once(stream, "open");
|
||||
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
@@ -1479,8 +1590,8 @@ class Slave extends libLink.Link {
|
||||
}
|
||||
}
|
||||
}
|
||||
response.pipe(stream);
|
||||
await events.once(stream, "finish");
|
||||
response.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
filename = await libFileOps.findUnusedName(savesDir, filename, ".zip");
|
||||
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename));
|
||||
@@ -1491,15 +1602,8 @@ class Slave extends libLink.Link {
|
||||
|
||||
async pushSaveRequestHandler(message) {
|
||||
let { instance_id, stream_id, save } = message.data;
|
||||
try {
|
||||
checkFilename(save);
|
||||
} catch (err) {
|
||||
throw new libErrors.RequestError(`Save name ${err.message}`);
|
||||
}
|
||||
let instanceInfo = this.instanceInfos.get(instance_id);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
|
||||
}
|
||||
checkRequestSaveName(save);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instance_id);
|
||||
|
||||
let content;
|
||||
try {
|
||||
@@ -1722,6 +1826,12 @@ async function startSlave() {
|
||||
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: "slave config file to use",
|
||||
@@ -1742,11 +1852,25 @@ async function startSlave() {
|
||||
.argv
|
||||
;
|
||||
|
||||
logger.add(new winston.transports.File({
|
||||
format: winston.format.combine(
|
||||
winston.format.json(),
|
||||
),
|
||||
filename: "slave.log",
|
||||
{
|
||||
// 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 slaveLogDirectory = path.join(args.logDirectory, "slave");
|
||||
if (!await fs.pathExists(slaveLogDirectory) && await fs.pathExists("slave.log")) {
|
||||
console.log("Migrating slave log...");
|
||||
await fs.ensureDir(slaveLogDirectory);
|
||||
await libLoggingUtils.migrateLogs("slave.log", slaveLogDirectory, "slave-%DATE%.log");
|
||||
console.log("Migration complete, you should delete slave.log now");
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
format: winston.format.json(),
|
||||
filename: "slave-%DATE%.log",
|
||||
utc: true,
|
||||
dirname: path.join(args.logDirectory, "slave"),
|
||||
}));
|
||||
if (args.logLevel !== "none") {
|
||||
logger.add(new ConsoleTransport({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"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.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"antd": "^4.16.0",
|
||||
"assert": "^2.0.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
|
||||
@@ -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,104 @@
|
||||
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 { 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 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"]),
|
||||
},
|
||||
{
|
||||
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,15 +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";
|
||||
@@ -18,10 +20,41 @@ 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);
|
||||
@@ -40,23 +73,28 @@ 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>
|
||||
{
|
||||
account.hasAnyPermission("core.instance.start", "core.instance.stop")
|
||||
&& <StartStopInstanceButton instance={instance} />
|
||||
}
|
||||
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
|
||||
{account.hasPermission("core.instance.export_data") && <Button
|
||||
loading={exportingData}
|
||||
disabled={instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
let instanceButtonsMenu = <Menu
|
||||
onClick={({ key }) => {
|
||||
if (key === "export") {
|
||||
setExportingData(true);
|
||||
libLink.messages.exportData.send(
|
||||
control, { instance_id: instanceId }
|
||||
@@ -65,59 +103,69 @@ export default function InstanceViewPage(props) {
|
||||
).finally(() => {
|
||||
setExportingData(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Export data
|
||||
</Button>}
|
||||
{account.hasPermission("core.instance.delete") && <Popconfirm
|
||||
title="Permanently delete instance and server saves?"
|
||||
okText="Delete"
|
||||
placement="bottomRight"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteInstance.send(
|
||||
|
||||
} else if (key === "kill") {
|
||||
libLink.messages.killInstance.send(
|
||||
control, { instance_id: instanceId }
|
||||
).then(() => {
|
||||
history.push("/instances");
|
||||
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
).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"));
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{account.hasPermission("core.instance.export_data") && <Menu.Item
|
||||
disabled={exportingData || instance.status !== "stopped"}
|
||||
key="export"
|
||||
>Export data</Menu.Item>}
|
||||
{account.hasPermission("core.instance.kill") && <Menu.Item
|
||||
disabled={["unknown", "unassigned", "stopped"].includes(instance["status"])}
|
||||
key="kill"
|
||||
>Kill process</Menu.Item>}
|
||||
{account.hasPermission("core.instance.delete") && <>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>}
|
||||
danger
|
||||
key="delete"
|
||||
icon={<DeleteOutlined />}
|
||||
>Delete</Menu.Item>
|
||||
</>}
|
||||
</Menu>;
|
||||
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.kill",
|
||||
"core.instance.delete",
|
||||
) && <Dropdown placement="bottomRight" trigger={["click"]} overlay={instanceButtonsMenu}>
|
||||
<Button>More <DownOutlined /></Button>
|
||||
</Dropdown>}
|
||||
</Space>;
|
||||
|
||||
let assigned = instance["assigned_slave"] !== null;
|
||||
return <PageLayout nav={nav}>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={instance["name"]}
|
||||
extra={instanceButtons}
|
||||
>
|
||||
<Descriptions.Item label="Slave">
|
||||
{!assigned
|
||||
? <em>Unassigned</em>
|
||||
: slave["name"] || instance["assigned_slave"]
|
||||
}
|
||||
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
|
||||
id={instanceId}
|
||||
slaveId={instance["assigned_slave"]}
|
||||
buttonProps={{
|
||||
size: "small",
|
||||
style: { float: "Right" },
|
||||
type: assigned ? "default" : "primary",
|
||||
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
|
||||
}}
|
||||
buttonContent={assigned ? "Reassign" : "Assign"}
|
||||
/>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
/>
|
||||
<InstanceDescription slave={slave} instance={instance} />
|
||||
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
|
||||
@@ -127,12 +175,14 @@ export default function InstanceViewPage(props) {
|
||||
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
|
||||
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
}
|
||||
{account.hasPermission("core.log.follow") && <InstanceConsole id={instanceId} />}
|
||||
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
|
||||
{
|
||||
account.hasPermission("core.instance.send_rcon")
|
||||
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
}
|
||||
|
||||
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
|
||||
|
||||
<PluginExtra component="InstanceViewPage" instance={instance} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,18 +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";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
function CreateInstanceButton(props) {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -31,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`);
|
||||
}
|
||||
@@ -53,7 +51,7 @@ function CreateInstanceButton(props) {
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
<Input/>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -61,54 +59,42 @@ function CreateInstanceButton(props) {
|
||||
}
|
||||
|
||||
export default function InstancesPage() {
|
||||
let history = useHistory();
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [slaveList] = useSlaveList();
|
||||
let [instanceList] = useInstanceList();
|
||||
|
||||
function slaveName(slaveId) {
|
||||
let slave = slaveList.find(s => s.id === slaveId);
|
||||
if (slave) {
|
||||
return slave.name;
|
||||
}
|
||||
return String(slaveId);
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Instances"
|
||||
extra={account.hasPermission("core.instance.create") && <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",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
},
|
||||
{
|
||||
title: "Assigned Slave",
|
||||
key: "assigned_slave",
|
||||
render: instance => slaveName(instance["assigned_slave"]),
|
||||
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
sorter: (a, b) => strcmp(a["status"], b["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";
|
||||
|
||||
|
||||
+13
-8
@@ -49,7 +49,7 @@ 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();
|
||||
@@ -57,16 +57,21 @@ export default function InstanceConsole(props) {
|
||||
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;
|
||||
|
||||
if (account.hasPermission("core.log.query")) {
|
||||
setPastLines([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
libLink.messages.queryLog.send(control, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [props.id],
|
||||
...logFilter,
|
||||
max_level: null,
|
||||
limit: 400,
|
||||
order: "desc",
|
||||
@@ -85,11 +90,11 @@ export default function InstanceConsole(props) {
|
||||
));
|
||||
}
|
||||
|
||||
control.onInstanceLog(props.id, logHandler);
|
||||
control.onLog(logFilter, logHandler);
|
||||
return () => {
|
||||
control.offInstanceLog(props.id, logHandler);
|
||||
control.offLog(logFilter, logHandler);
|
||||
};
|
||||
}, [props.id]);
|
||||
}, [props.all, props.master, (props.slaves || []).join(), (props.instances || []).join()]);
|
||||
|
||||
return <>
|
||||
<Paragraph code className="instance-console">
|
||||
@@ -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,15 +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 SlavesPage() {
|
||||
|
||||
export default function MasterPage() {
|
||||
let account = useAccount();
|
||||
|
||||
return <PageLayout nav={[{ name: "Master" }]}>
|
||||
<h2>Master</h2>
|
||||
{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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { libLink, libUsers } from "@clusterio/lib";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
@@ -149,5 +150,6 @@ export default function RoleViewPage() {
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
<PluginExtra component="RoleViewPage" role={role} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
@@ -99,5 +100,6 @@ export default function RolesPage() {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<PluginExtra component="RolesPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button, List, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd";
|
||||
import {
|
||||
message, Button, Checkbox, Form, Input, List, Modal,
|
||||
Popconfirm, Progress, Select, Space, Table, Tooltip, Upload,
|
||||
} from "antd";
|
||||
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
|
||||
import LeftOutlined from "@ant-design/icons/LeftOutlined";
|
||||
|
||||
@@ -9,10 +12,173 @@ import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import CreateSaveModal from "./CreateSaveModal";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
import { useSaves } from "../model/saves";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
function RenameModal(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
|
||||
return <>
|
||||
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Rename</Button>
|
||||
<Modal
|
||||
title="Rename save"
|
||||
okText="Rename"
|
||||
visible={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ newName: props.save.name }}
|
||||
onFinish={values => {
|
||||
libLink.messages.renameSave.send(control,
|
||||
{ instance_id: props.instanceId, old_name: props.save.name, new_name: values.newName }
|
||||
).then(() => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
}).catch(notifyErrorHandler("Error renaming save"));
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="newName"
|
||||
label="New name"
|
||||
rules={[{ required: true, message: "New name is required" }]}
|
||||
>
|
||||
<Input autoFocus />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function CopyModal(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
|
||||
return <>
|
||||
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Copy</Button>
|
||||
<Modal
|
||||
title="Copy save"
|
||||
okText="Copy"
|
||||
visible={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ newName: props.save.name }}
|
||||
onFinish={values => {
|
||||
libLink.messages.copySave.send(control,
|
||||
{ instance_id: props.instanceId, source: props.save.name, destination: values.newName }
|
||||
).then(() => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
}).catch(notifyErrorHandler("Error copying save"));
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="newName"
|
||||
label="New name"
|
||||
rules={[{ required: true, message: "New name is required" }]}
|
||||
>
|
||||
<Input autoFocus />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function TransferModal(props) {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
let [instanceList] = useInstanceList();
|
||||
|
||||
return <>
|
||||
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Transfer</Button>
|
||||
<Modal
|
||||
title="Transfer save"
|
||||
okText="Transfer"
|
||||
visible={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
initialValues={{ transferredName: props.save.name, copy: false }}
|
||||
onFinish={values => {
|
||||
setVisible(false);
|
||||
let hide = message.loading("Transferring save...", 0);
|
||||
libLink.messages.transferSave.send(control, {
|
||||
instance_id: props.instanceId,
|
||||
source_save: props.save.name,
|
||||
target_instance_id: values.targetInstanceId,
|
||||
target_save: values.transferredName || props.save.name,
|
||||
copy: values.copy,
|
||||
}).then(() => {
|
||||
message.success("Transfer complete");
|
||||
form.resetFields();
|
||||
}).catch(
|
||||
notifyErrorHandler("Error transfering save")
|
||||
).finally(() => hide());
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="targetInstanceId"
|
||||
label="Target Instance"
|
||||
rules={[{ required: true, message: "Target Instance is required" }]}
|
||||
>
|
||||
<Select
|
||||
autoFocus
|
||||
showSearch
|
||||
filterOption={(input, option) => option.title.toLowerCase().indexOf(input.toLowerCase()) >= 0}
|
||||
>
|
||||
{instanceList.filter(
|
||||
instance => instance["id"] !== props.instanceId
|
||||
).map((instance) => <Select.Option
|
||||
key={instance["id"]}
|
||||
value={instance["id"]}
|
||||
title={instance["name"]}
|
||||
disabled={["unassigned", "unknown"].includes(instance["status"])}
|
||||
>
|
||||
{instance["name"]}
|
||||
{instance["status"] === "unassigned" && " (unassigned)"}
|
||||
{instance["status"] === "unknown" && " (offline)"}
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="transferredName"
|
||||
label="Transferred name"
|
||||
disabled={!account.hasAnyPermission("core.instance.save.rename", "core.instance.save.copy")}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="copy"
|
||||
valuePropName="checked"
|
||||
label="Copy"
|
||||
tooltip="Copy instead of moving the save to the new instance."
|
||||
disabled={!account.hasPermission("core.instance.save.copy")}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
@@ -81,6 +247,12 @@ export default function SavesList(props) {
|
||||
);
|
||||
}}
|
||||
>Load save</Button>}
|
||||
{account.hasPermission("core.instance.save.rename") && <RenameModal
|
||||
disabled={slaveOffline} instanceId={props.instance.id} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.copy") && <CopyModal
|
||||
disabled={slaveOffline} instanceId={props.instance.id} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.download") && <Button
|
||||
disabled={slaveOffline}
|
||||
onClick={() => {
|
||||
@@ -95,6 +267,9 @@ export default function SavesList(props) {
|
||||
);
|
||||
}}
|
||||
>Download</Button>}
|
||||
{account.hasPermission("core.instance.save.transfer") && <TransferModal
|
||||
disabled={slaveOffline} instanceId={props.instance.id} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.delete") && <Popconfirm
|
||||
title="Permanently delete save?"
|
||||
okText="Delete"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
export default function SectionHeader(props) {
|
||||
return <div className="section-header">
|
||||
|
||||
@@ -37,6 +37,27 @@ export default function SiteLayout(props) {
|
||||
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(<Menu.Item key={path}>{sidebarName}</Menu.Item>);
|
||||
} else {
|
||||
menuItems.push(<Menu.Item key={path}>{sidebarName}</Menu.Item>);
|
||||
}
|
||||
}
|
||||
for (let [name, group] of menuGroups) {
|
||||
menuItems.push(<Menu.ItemGroup key={name} title={name}>{group}</Menu.ItemGroup>);
|
||||
}
|
||||
|
||||
return <Layout style={{ minHeight: "100vh" }}>
|
||||
<Header className="header">
|
||||
<div className="site-logo" />
|
||||
@@ -65,16 +86,12 @@ export default function SiteLayout(props) {
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultOpenKeys={[...menuGroups.keys()]}
|
||||
selectedKeys={[currentSidebarPath]}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
onClick={({ key }) => history.push(key)}
|
||||
>
|
||||
{combinedPages.map(({sidebarName, permission, path}) => {
|
||||
if (!sidebarName || permission && !account.hasPermission(permission)) {
|
||||
return null;
|
||||
}
|
||||
return <Menu.Item key={path}>{sidebarName}</Menu.Item>;
|
||||
})}
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout className="site-layout-content-container">
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
import React, { useContext, useRef, useState } from "react";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { useSlaveList } from "../model/slave";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
@@ -75,6 +77,7 @@ function GenerateSlaveTokenButton(props) {
|
||||
|
||||
export default function SlavesPage() {
|
||||
let account = useAccount();
|
||||
let history = useHistory();
|
||||
let [slaveList] = useSlaveList();
|
||||
|
||||
return <PageLayout nav={[{ name: "Slaves" }]}>
|
||||
@@ -102,16 +105,31 @@ export default function SlavesPage() {
|
||||
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") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { libLink } 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";
|
||||
|
||||
|
||||
@@ -258,5 +259,6 @@ export default function UserViewPage() {
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<PluginExtra component="UserViewPage" user={user} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
@@ -121,5 +122,6 @@ export default function UsersPage() {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<PluginExtra component="UsersPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
let itemMetadataCache = null;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
|
||||
import MasterPage from "./components/MasterPage";
|
||||
import SlavesPage from "./components/SlavesPage";
|
||||
import SlaveViewPage from "./components/SlaveViewPage";
|
||||
import InstancesPage from "./components/InstancesPage";
|
||||
import InstanceViewPage from "./components/InstanceViewPage";
|
||||
import UsersPage from "./components/UsersPage";
|
||||
@@ -24,6 +25,11 @@ export const pages = [
|
||||
permission: "core.slave.list",
|
||||
content: <SlavesPage />,
|
||||
},
|
||||
{
|
||||
path: "/slaves/:id/view",
|
||||
sidebarPath: "/slaves",
|
||||
content: <SlaveViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/instances",
|
||||
sidebarName: "Instances",
|
||||
|
||||
@@ -27,6 +27,22 @@ export class ControlConnector extends libLink.WebSocketClientConnector {
|
||||
}
|
||||
}
|
||||
|
||||
function logFilter({ all, master, slave_ids, instance_ids }, info) {
|
||||
if (all) {
|
||||
return true;
|
||||
}
|
||||
if (master && info.slave_id === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (info.slave_id && slave_ids.includes(info.slave_id)) {
|
||||
return true;
|
||||
}
|
||||
if (info.instance_id && instance_ids.includes(info.instance_id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
@@ -70,7 +86,7 @@ export class Control extends libLink.Link {
|
||||
this.slaveUpdateHandlers = new Map();
|
||||
this.instanceUpdateHandlers = new Map();
|
||||
this.saveListUpdateHandlers = new Map();
|
||||
this.instanceLogHandlers = new Map();
|
||||
this.logHandlers = new Map();
|
||||
|
||||
this.connector.on("connect", data => {
|
||||
this.accountName = data.account.name;
|
||||
@@ -302,23 +318,20 @@ export class Control extends libLink.Link {
|
||||
async logMessageEventHandler(message) {
|
||||
let info = message.data.info;
|
||||
|
||||
if (info.instance_id !== undefined) {
|
||||
let instanceHandlers = this.instanceLogHandlers.get(info.instance_id);
|
||||
for (let handler of instanceHandlers || []) {
|
||||
handler(info);
|
||||
for (let [filter, handlers] of this.logHandlers) {
|
||||
if (logFilter(filter, info)) {
|
||||
for (let handler of handlers) {
|
||||
handler(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onInstanceLog(id, handler) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("Invalid instance id");
|
||||
}
|
||||
|
||||
let handlers = this.instanceLogHandlers.get(id);
|
||||
async onLog(filter, handler) {
|
||||
let handlers = this.logHandlers.get(filter);
|
||||
if (!handlers) {
|
||||
handlers = [];
|
||||
this.instanceLogHandlers.set(id, handlers);
|
||||
this.logHandlers.set(filter, handlers);
|
||||
}
|
||||
|
||||
handlers.push(handler);
|
||||
@@ -328,15 +341,15 @@ export class Control extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async offInstanceLog(id, handler) {
|
||||
let handlers = this.instanceLogHandlers.get(id);
|
||||
async offLog(filter, handler) {
|
||||
let handlers = this.logHandlers.get(filter);
|
||||
if (!handlers || !handlers.length) {
|
||||
throw new Error(`No handlers for instance ${id} exist`);
|
||||
throw new Error("No handlers for the given filter exists");
|
||||
}
|
||||
|
||||
let index = handlers.lastIndexOf(handler);
|
||||
if (index === -1) {
|
||||
throw new Error(`Given handler is not registered for instance ${id}`);
|
||||
throw new Error("Given handler is not registered for the filter");
|
||||
}
|
||||
|
||||
handlers.splice(index, 1);
|
||||
@@ -350,13 +363,29 @@ export class Control extends libLink.Link {
|
||||
return;
|
||||
}
|
||||
|
||||
await libLink.messages.setLogSubscriptions.send(this, {
|
||||
let combinedFilter = {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [...this.instanceLogHandlers.keys()],
|
||||
max_level: null,
|
||||
});
|
||||
instance_ids: [],
|
||||
};
|
||||
|
||||
for (let filter of this.logHandlers.keys()) {
|
||||
if (filter.all) { combinedFilter.all = true; }
|
||||
if (filter.master) { combinedFilter.master = true; }
|
||||
for (let slaveId of filter.slave_ids || []) {
|
||||
if (!combinedFilter.slave_ids.includes(slaveId)) {
|
||||
combinedFilter.slave_ids.push(slaveId);
|
||||
}
|
||||
}
|
||||
for (let instanceId of filter.instance_ids || []) {
|
||||
if (!combinedFilter.instance_ids.includes(instanceId)) {
|
||||
combinedFilter.instance_ids.push(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await libLink.messages.setLogSubscriptions.send(this, { ...combinedFilter, max_level: null });
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,8 +20,8 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"webpack": "^5.36.2",
|
||||
"webpack-cli": "^4.7.0",
|
||||
"webpack-merge": "^5.2.0"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
async function loadDatabase(config, logger) {
|
||||
let itemsPath = path.resolve(config.get("master.database_directory"), "inventories.json");
|
||||
@@ -26,7 +26,7 @@ async function saveDatabase(masterConfig, playerDatastore, logger) {
|
||||
let file = path.resolve(masterConfig.get("master.database_directory"), "inventories.json");
|
||||
logger.verbose(`writing ${file}`);
|
||||
let content = JSON.stringify(Array.from(playerDatastore));
|
||||
await fs.outputFile(file, content);
|
||||
await libFileOps.safeOutputFile(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-inventory_sync",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Danielv <danielv@danielv.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -23,8 +23,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"antd": "^4.16.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
"use strict";
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
|
||||
class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
|
||||
@@ -78,7 +78,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let [player, entry] of this.players) {
|
||||
for (let entry of this.players.values()) {
|
||||
if (entry.playerCode === playerCode && entry.expires > Date.now()) {
|
||||
let verifyCode = await generateCode(this.master.config.get("player_auth.code_length"));
|
||||
let secret = Buffer.from(this.master.config.get("master.auth_secret"), "base64");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-player_auth",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin authenticating logged in players to the web interface",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -24,8 +24,8 @@
|
||||
"jsonwebtoken": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"antd": "^4.16.0",
|
||||
"phin": "^3.5.1",
|
||||
"react": "^16.13.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { libPlugin } from "@clusterio/lib";
|
||||
import { notify, notifyErrorHandler } from "@clusterio/web_ui";
|
||||
import { notifyErrorHandler } from "@clusterio/web_ui";
|
||||
import { Button, Form, Input, Spin, Typography } from "antd";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
@@ -43,7 +43,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
|
||||
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
|
||||
return;
|
||||
}
|
||||
let { name, level, researched } = message.data;
|
||||
let { name, level } = message.data;
|
||||
await this.sendOrderedRcon(
|
||||
`/sc research_sync.research_technology("${libLuaTools.escapeString(name)}", ${level})`, true
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const RateLimiter = require("@clusterio/lib/RateLimiter");
|
||||
|
||||
@@ -25,7 +26,7 @@ async function loadTechnologies(masterConfig, logger) {
|
||||
async function saveTechnologies(masterConfig, technologies, logger) {
|
||||
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
|
||||
logger.verbose(`writing ${filePath}`);
|
||||
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
|
||||
}
|
||||
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -23,8 +23,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"webpack": "^5.36.2",
|
||||
"webpack-cli": "^4.7.0",
|
||||
"webpack-merge": "^5.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,8 +20,8 @@
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"webpack": "^5.36.2",
|
||||
"webpack-cli": "^4.7.0",
|
||||
"webpack-merge": "^5.2.0"
|
||||
|
||||
@@ -62,7 +62,6 @@ function dose(numreq, instore, store_last_tick, dole, carry, prev_req, numreq_to
|
||||
debt,
|
||||
]; // 5..12
|
||||
let inputs = inp1.concat(inp2);
|
||||
let internal = new Array(20).fill(0);
|
||||
let outputs = [0, 0, 0, 0];
|
||||
// NN: Please don't touch :), code is generated
|
||||
|
||||
@@ -78,7 +77,7 @@ function dose(numreq, instore, store_last_tick, dole, carry, prev_req, numreq_to
|
||||
outputs[0]=1.0;
|
||||
}
|
||||
|
||||
let res1 = Number(outputs[0]) * numreq+outputs[3] + debt;
|
||||
let res1 = Number(outputs[0]) * numreq + outputs[3] + debt;
|
||||
let res_capped = Math.round(res1);
|
||||
|
||||
if (res_capped > numreq) { res_capped = numreq; }
|
||||
@@ -88,7 +87,7 @@ function dose(numreq, instore, store_last_tick, dole, carry, prev_req, numreq_to
|
||||
res1 = res1 || 0;
|
||||
res_capped = res_capped || 0; // Safeguard against wrong values
|
||||
debt = res1 - res_capped;
|
||||
debt_cap = Math.round(numreq * 1.5) + 5;
|
||||
let debt_cap = Math.round(numreq * 1.5) + 5;
|
||||
if (debt > debt_cap) {
|
||||
debt = debt_cap;
|
||||
}
|
||||
@@ -129,7 +128,6 @@ function tick(instore, dole, store_last_tick, numreq_total_adj) {
|
||||
store_last_tick,
|
||||
numreq_total_adj,
|
||||
];
|
||||
let internal = new Array(10).fill(0);
|
||||
let outputs = [0, 0];
|
||||
// NN: Please don't touch :), code is generated
|
||||
|
||||
@@ -139,7 +137,7 @@ function tick(instore, dole, store_last_tick, numreq_total_adj) {
|
||||
outputs[1] = outputs[0] * 1.828422785;
|
||||
outputs[1] = sigmoid3(outputs[1]);
|
||||
|
||||
avg = outputs[1];
|
||||
let avg = outputs[1];
|
||||
if (numreq_total_adj < 0.11) {
|
||||
// When no item's are requested, result is 0.1; In that case there is
|
||||
// no deficit, so return 1 = 100% demand fulfilled
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libDatabase = require("@clusterio/lib/database");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const { Counter, Gauge } = require("@clusterio/lib/prometheus");
|
||||
const RateLimiter = require("@clusterio/lib/RateLimiter");
|
||||
@@ -49,7 +50,7 @@ async function saveDatabase(masterConfig, items, logger) {
|
||||
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
|
||||
logger.verbose(`writing ${file}`);
|
||||
let content = JSON.stringify(items.serialize());
|
||||
await fs.outputFile(file, content);
|
||||
await libFileOps.safeOutputFile(file, content);
|
||||
} else if (items) {
|
||||
logger.error(`Item database too large, not saving (${items.size})`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-subspace_storage",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.12",
|
||||
"description": "Clusterio plugin for sharing storage between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -23,8 +23,8 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.10",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.10",
|
||||
"@clusterio/lib": "^2.0.0-alpha.12",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.12",
|
||||
"antd": "^4.16.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
|
||||
|
||||
function addApiRoutes(app, items, endpointHitCounter) {
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
This file should be excluded by queries and the index
|
||||
@@ -0,0 +1,9 @@
|
||||
{"timestamp":"2021-12-10T10:00:00.000Z","level":"verbose","message":"Master start"}
|
||||
{"timestamp":"2021-12-10T10:00:01.000Z","level":"verbose","message":"Loading"}
|
||||
{"timestamp":"2021-12-10T10:00:03.000Z","level":"info","message":"Ready"}
|
||||
{"timestamp":"2021-12-10T10:00:10.000Z","slave_id":1,"level":"verbose","message":"Slave connected"}
|
||||
{"timestamp":"2021-12-10T10:00:11.000Z","slave_id":1,"level":"verbose","message":"Found instance 10"}
|
||||
{"timestamp":"2021-12-10T10:00:13.000Z","slave_id":1,"instance_id":10,"level":"server","message":"Starting up"}
|
||||
{"timestamp":"2021-12-10T10:00:14.000Z","slave_id":1,"instance_id":10,"level":"server","message":"Loading map"}
|
||||
{"timestamp":"2021-12-10T10:00:15.000Z","slave_id":1,"instance_id":10,"level":"server","message":"Game ready"}
|
||||
{"timestamp":"2021-12-10T10:01:00.000Z","slave_id":1,"instance_id":10,"level":"error","message":"Unexpected crash"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"timestamp":"2021-12-11T10:00:00.000Z","level":"verbose","message":"Master start"}
|
||||
{"timestamp":"2021-12-11T10:00:01.000Z","level":"verbose","message":"Loading"}
|
||||
{"timestamp":"2021-12-11T10:00:03.000Z","level":"fatal","message":"An error occured"}
|
||||
@@ -0,0 +1 @@
|
||||
{"timestamp":"2021-12-12T10:00:00.000Z","leve
|
||||
Whitespace-only changes.
+242
-33
@@ -3,9 +3,11 @@ const assert = require("assert").strict;
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const events = require("events");
|
||||
const phin = require("phin");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libUsers = require("@clusterio/lib/users");
|
||||
const { wait } = require("@clusterio/lib/helpers");
|
||||
|
||||
const testStrings = require("../lib/factorio/test_strings");
|
||||
const {
|
||||
@@ -24,6 +26,24 @@ async function checkInstanceStatus(id, status) {
|
||||
assert.equal(instances.get(44).status, status, "incorrect instance status");
|
||||
}
|
||||
|
||||
async function uploadSave(instanceId, name, content) {
|
||||
return await phin({
|
||||
url: `https://localhost:4443/api/upload-save?instance_id=${instanceId}&filename=${name}`,
|
||||
method: "POST",
|
||||
core: { rejectUnauthorized: false },
|
||||
headers: {
|
||||
"X-Access-Token": controlToken,
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(content.length),
|
||||
},
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSave(instanceId, save) {
|
||||
await libLink.messages.deleteSave.send(getControl(), { instance_id: instanceId, save });
|
||||
}
|
||||
|
||||
describe("Integration of Clusterio", function() {
|
||||
describe("clusteriomaster", function() {
|
||||
describe("bootstrap generate-user-token", function() {
|
||||
@@ -114,10 +134,14 @@ describe("Integration of Clusterio", function() {
|
||||
let config = "alt-slave-config.json";
|
||||
let configPath = path.join("temp", "test", config);
|
||||
await fs.remove(configPath);
|
||||
await fs.remove(path.join("temp", "test", "alt-instances"));
|
||||
await execCtl(`slave create-config --id 5 --name alt-slave --generate-token --output ${config}`);
|
||||
await exec(
|
||||
`node ../../packages/slave --config ${config} config set slave.tls_ca ../../test/file/tls/cert.pem`
|
||||
);
|
||||
await exec(
|
||||
`node ../../packages/slave --config ${config} config set slave.instances_directory alt-instances`
|
||||
);
|
||||
|
||||
let slaveProcess;
|
||||
try {
|
||||
@@ -249,10 +273,11 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance create-save", function() {
|
||||
describe("instance save create", function() {
|
||||
it("creates a save", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance create-save test");
|
||||
getControl().saveListUpdates = [];
|
||||
await execCtl("instance save create test");
|
||||
await checkInstanceStatus(44, "stopped");
|
||||
});
|
||||
});
|
||||
@@ -264,10 +289,10 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance list-saves", function() {
|
||||
describe("instance save list", function() {
|
||||
it("lists the created save", async function() {
|
||||
slowTest(this);
|
||||
let result = await execCtl("instance list-saves test");
|
||||
let result = await execCtl("instance save list test");
|
||||
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
|
||||
});
|
||||
});
|
||||
@@ -325,8 +350,32 @@ describe("Integration of Clusterio", function() {
|
||||
describe("instance send-rcon", function() {
|
||||
it("sends the command", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance send-rcon test test");
|
||||
// TODO check that the command was received
|
||||
await execCtl("instance send-rcon test technobabble");
|
||||
let { log } = await libLink.messages.queryLog.send(getControl(), {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [44],
|
||||
max_level: null,
|
||||
limit: 10,
|
||||
order: "desc",
|
||||
});
|
||||
assert(log.some(info => /technobabble/.test(info.message)), "Command was not sent");
|
||||
});
|
||||
|
||||
it("should trigger saveListUpdate on save", async function() {
|
||||
slowTest(this);
|
||||
getControl().saveListUpdates = [];
|
||||
await execCtl("instance send-rcon test /server-save");
|
||||
let received = false;
|
||||
for (let x = 0; x < 10; x++) {
|
||||
if (getControl().saveListUpdates.length) {
|
||||
received = true;
|
||||
break;
|
||||
}
|
||||
await wait(100);
|
||||
}
|
||||
assert(received, "saveListUpdate not sent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -481,29 +530,33 @@ describe("Integration of Clusterio", function() {
|
||||
await execCtl("instance config set 44 research_sync.load_plugin false");
|
||||
await execCtl("instance config set 44 statistics_exporter.load_plugin false");
|
||||
await execCtl("instance config set 44 subspace_storage.load_plugin false");
|
||||
try {
|
||||
let exchangeString = testStrings.modified.replace(/[\n\r]+/g, "");
|
||||
let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
|
||||
await execCtl(`instance load-scenario test ${args}`);
|
||||
await checkInstanceStatus(44, "running");
|
||||
await sendRcon(44, '/c game.print("disable achievements")');
|
||||
await sendRcon(44, '/c game.print("disable achievements")');
|
||||
assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n");
|
||||
assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n");
|
||||
assert.equal(
|
||||
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
|
||||
);
|
||||
|
||||
} finally {
|
||||
await execCtl("instance stop test");
|
||||
}
|
||||
let exchangeString = testStrings.modified.replace(/[\n\r]+/g, "");
|
||||
let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
|
||||
await execCtl(`instance load-scenario test ${args}`);
|
||||
await checkInstanceStatus(44, "running");
|
||||
await sendRcon(44, '/c game.print("disable achievements")');
|
||||
await sendRcon(44, '/c game.print("disable achievements")');
|
||||
assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n");
|
||||
assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n");
|
||||
assert.equal(
|
||||
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance upload-save", function() {
|
||||
describe("instance kill", function() {
|
||||
it("kills the instance", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance kill test");
|
||||
await checkInstanceStatus(44, "stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance save upload", function() {
|
||||
it("should upload a zip file", async function() {
|
||||
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
|
||||
await execCtl("instance upload-save 44 upload.zip");
|
||||
await execCtl("instance save upload 44 upload.zip");
|
||||
assert(
|
||||
await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
|
||||
"file not uploaded to saves directory"
|
||||
@@ -511,42 +564,198 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
it("should reject non-zip files", async function() {
|
||||
await fs.outputFile(path.join("temp", "test", "invalid"), "a test");
|
||||
await assert.rejects(execCtl("instance upload-save 44 invalid"));
|
||||
await assert.rejects(execCtl("instance save upload 44 invalid"));
|
||||
});
|
||||
it("should reject path traversal attacks", async function() {
|
||||
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
|
||||
await assert.rejects(execCtl("instance upload-save 44 upload.zip --name ../traversal.zip"));
|
||||
await assert.rejects(execCtl("instance save upload 44 upload.zip --name ../traversal.zip"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance download-save", function() {
|
||||
describe("instance save download", function() {
|
||||
it("should download a save", async function() {
|
||||
await fs.remove(path.join("temp", "test", "upload.zip"));
|
||||
await execCtl("instance download-save 44 upload.zip");
|
||||
await execCtl("instance save download 44 upload.zip");
|
||||
assert(await fs.pathExists(path.join("temp", "test", "upload.zip")));
|
||||
});
|
||||
it("should error if save does not exist", async function() {
|
||||
await assert.rejects(execCtl("instance download-save 44 invalid"));
|
||||
await assert.rejects(execCtl("instance save download 44 invalid"));
|
||||
});
|
||||
it("should error on path traversal attacks", async function() {
|
||||
await assert.rejects(execCtl("instance download-save 44 ../factorio-current.log"));
|
||||
await assert.rejects(execCtl("instance save download 44 ../factorio-current.log"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance delete-save", function() {
|
||||
describe("instance save copy", function() {
|
||||
it("copy a save file", async function() {
|
||||
await execCtl("instance save copy 44 upload.zip copy.zip");
|
||||
assert(await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "copy.zip")));
|
||||
});
|
||||
it("should error if destination exist", async function() {
|
||||
await assert.rejects(execCtl("instance save copy 44 upload.zip copy.zip"));
|
||||
});
|
||||
it("should error if source does not exist", async function() {
|
||||
await assert.rejects(execCtl("instance save copy 44 not-here.zip invalid.zip"));
|
||||
});
|
||||
it("should reject path traversal attacks", async function() {
|
||||
await assert.rejects(execCtl("instance save copy 44 upload.zip ../traversal.zip"));
|
||||
await assert.rejects(execCtl("instance save copy 44 ../saves/upload.zip traversal.zip "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance save rename", function() {
|
||||
it("rename a save file", async function() {
|
||||
await execCtl("instance save rename 44 copy.zip rename.zip");
|
||||
assert(!await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "copy.zip")));
|
||||
assert(await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "rename.zip")));
|
||||
});
|
||||
it("should error if new name exist", async function() {
|
||||
await assert.rejects(execCtl("instance save rename 44 upload.zip rename.zip"));
|
||||
});
|
||||
it("should error if old name does not exist", async function() {
|
||||
await assert.rejects(execCtl("instance save rename 44 not-here.zip invalid.zip"));
|
||||
});
|
||||
it("should reject path traversal attacks", async function() {
|
||||
await assert.rejects(execCtl("instance save rename 44 upload.zip ../traversal.zip"));
|
||||
await assert.rejects(execCtl("instance save rename 44 ../saves/upload.zip traversal.zip "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance save transfer", function() {
|
||||
before(async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance create spam --id 66");
|
||||
await execCtl("instance assign spam 4");
|
||||
await execCtl("instance create unassign --id 77");
|
||||
});
|
||||
after(async function() {
|
||||
await execCtl("instance delete spam");
|
||||
});
|
||||
for (let remote of [false, true]) {
|
||||
let pri = 44;
|
||||
let sec = remote ? 99 : 66;
|
||||
let priSaves = path.join("temp", "test", "instances", "test", "saves");
|
||||
let secSaves = remote
|
||||
? path.join("temp", "test", "alt-instances", "alt-test", "saves")
|
||||
: path.join("temp", "test", "instances", "spam", "saves")
|
||||
;
|
||||
describe(remote ? "remote" : "local", function() {
|
||||
if (remote) {
|
||||
let slaveProcess;
|
||||
before(async function() {
|
||||
// On windows there's currently no way to automate graceful shutdown of the slave
|
||||
// process as CTRL+C is some weird terminal thing and SIGINT isn't a thing.
|
||||
if (process.platform === "win32") {
|
||||
this.skip();
|
||||
}
|
||||
slowTest(this);
|
||||
// Reuse from the clusterioslave test
|
||||
let config = "alt-slave-config.json";
|
||||
slaveProcess = await spawn(
|
||||
"alt-slave:", `node ../../packages/slave run --config ${config}`, /Started slave/
|
||||
);
|
||||
});
|
||||
after(async function() {
|
||||
if (slaveProcess) {
|
||||
slaveProcess.kill("SIGINT");
|
||||
await events.once(slaveProcess, "exit");
|
||||
}
|
||||
});
|
||||
}
|
||||
it("should transfers a save", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await execCtl(`instance save transfer ${pri} transfer.zip ${sec}`);
|
||||
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
|
||||
assert(await fs.pathExists(path.join(secSaves, "transfer.zip")), "save not at sec");
|
||||
await deleteSave(sec, "transfer.zip");
|
||||
});
|
||||
it("should support rename", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await execCtl(`instance save transfer ${pri} transfer.zip ${sec} rename.zip`);
|
||||
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
|
||||
assert(await fs.pathExists(path.join(secSaves, "rename.zip")), "save not at sec");
|
||||
await deleteSave(sec, "rename.zip");
|
||||
});
|
||||
it("should auto-rename if target exists", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await uploadSave(sec, "transfer.zip", "transfer.zip");
|
||||
await execCtl(`instance save transfer ${pri} transfer.zip ${sec}`);
|
||||
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
|
||||
assert(await fs.pathExists(path.join(secSaves, "transfer-2.zip")), "save not at sec");
|
||||
await deleteSave(sec, "transfer.zip");
|
||||
await deleteSave(sec, "transfer-2.zip");
|
||||
});
|
||||
it("should copy when using --copy", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await execCtl(`instance save transfer ${pri} transfer.zip ${sec} --copy`);
|
||||
assert(await fs.pathExists(path.join(priSaves, "transfer.zip")), "save not at pri");
|
||||
assert(await fs.pathExists(path.join(secSaves, "transfer.zip")), "save not at sec");
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
await deleteSave(sec, "transfer.zip");
|
||||
});
|
||||
it("should fail if save does not exist", async function() {
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} not-here.zip ${sec}`));
|
||||
});
|
||||
it("should fail if source save name is invalid", async function() {
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} nul 111`));
|
||||
});
|
||||
it("should fail if target save name is invalid", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111 nul`));
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
});
|
||||
it("should reject path traversal of source save", async function() {
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} ../saves/transfer.zip 111`));
|
||||
});
|
||||
it("should reject path traversal of target save", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await assert.rejects(
|
||||
execCtl(`instance save transfer ${pri} transfer.zip 111 ../saves/transfer.zip`)
|
||||
);
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
});
|
||||
if (!remote) {
|
||||
it("should fail if source and target instance is the same instance", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip ${pri}`));
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
});
|
||||
it("should fail if source instance does not exist", async function() {
|
||||
await assert.rejects(execCtl(`instance save transfer 111 not-here.zip ${sec}`));
|
||||
});
|
||||
it("should fail if target instance does not exist", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111`));
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
});
|
||||
it("should fail if source instance is not assigned", async function() {
|
||||
await assert.rejects(execCtl(`instance save transfer 77 transfer.zip ${pri}`));
|
||||
});
|
||||
it("should fail if target instance is not assigned", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 77`));
|
||||
await deleteSave(pri, "transfer.zip");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
describe("instance save delete", function() {
|
||||
it("should delete a save", async function() {
|
||||
await execCtl("instance delete-save 44 upload.zip");
|
||||
await execCtl("instance save delete 44 upload.zip");
|
||||
assert(
|
||||
!await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
|
||||
"file not deleted"
|
||||
);
|
||||
});
|
||||
it("should error if save does not exist", async function() {
|
||||
await assert.rejects(execCtl("instance delete-save 44 upload.zip"));
|
||||
await assert.rejects(execCtl("instance save delete 44 upload.zip"));
|
||||
});
|
||||
it("should error on path traversal attacks", async function() {
|
||||
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
|
||||
await assert.rejects(execCtl("instance delete-save 44 ../../upload.zip"));
|
||||
await assert.rejects(execCtl("instance save delete 44 ../../upload.zip"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const util = require("util");
|
||||
const events = require("events");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const server = require("@clusterio/lib/factorio/server");
|
||||
const { LineSplitter } = require("@clusterio/lib/stream");
|
||||
const { ConsoleTransport, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
|
||||
@@ -109,7 +109,8 @@ let slaveConfigPath = path.join("temp", "test", "config-slave.json");
|
||||
let controlConfigPath = path.join("temp", "test", "config-control.json");
|
||||
|
||||
async function exec(command, options = {}) {
|
||||
console.log(command);
|
||||
// Uncomment to show commands run in tests
|
||||
// console.log(command);
|
||||
options = { cwd: path.join("temp", "test"), ...options };
|
||||
return await util.promisify(child_process.exec)(command, options);
|
||||
}
|
||||
@@ -133,18 +134,18 @@ function spawn(name, cmd, waitFor) {
|
||||
console.log(cmd);
|
||||
let parts = cmd.split(" ");
|
||||
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
|
||||
let stdout = new server._LineSplitter((line) => {
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
stdout.on("data", line => {
|
||||
line = line.toString("utf8");
|
||||
if (waitFor.test(line)) {
|
||||
resolve(process);
|
||||
}
|
||||
console.log(name, line);
|
||||
});
|
||||
let stderr = new server._LineSplitter((line) => { console.log(name, line.toString("utf8")); });
|
||||
process.stdout.on("data", chunk => { stdout.data(chunk); });
|
||||
process.stdout.on("close", () => { stdout.end(); });
|
||||
process.stderr.on("data", chunk => { stderr.data(chunk); });
|
||||
process.stderr.on("close", () => { stderr.end(); });
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
stderr.on("data", line => { console.log(name, line.toString("utf8")); });
|
||||
process.stdout.pipe(stdout);
|
||||
process.stderr.pipe(stderr);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user