mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,39 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x, 14.x, 16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm i --no-optional
|
||||
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- run: npx lerna bootstrap --hoist
|
||||
- run: npx lerna run build
|
||||
- run: npm test
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.x
|
||||
- run: npm i --no-optional
|
||||
- run: npx lerna bootstrap --hoist
|
||||
- run: npm run lint
|
||||
+22
-10
@@ -85,6 +85,12 @@ class LineSplitter extends stream.Transform {
|
||||
class InstallError extends Error { }
|
||||
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let temporary = `${file}.tmp`;
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
const asyncExec = util.promisify(child_process.execFile);
|
||||
@@ -165,7 +171,7 @@ async function validateInstallDir() {
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.writeFile("package.json", JSON.stringify({
|
||||
await safeOutputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -213,7 +219,7 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
@@ -223,17 +229,17 @@ async function installClusterio(mode, plugins) {
|
||||
async function writeScripts(mode) {
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-master.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriomaster.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Master
|
||||
@@ -254,17 +260,17 @@ WantedBy=multi-user.target
|
||||
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
await safeOutputFile(
|
||||
"run-slave.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusterioslave.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Slave
|
||||
@@ -427,7 +433,11 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) { answers.plugins = []; }
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
@@ -521,7 +531,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.10.c",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
@@ -14,6 +14,8 @@ const path = require("path");
|
||||
const winston = require("winston");
|
||||
const setBlocking = require("set-blocking");
|
||||
const phin = require("phin");
|
||||
const os = require("os");
|
||||
const child_process = require("child_process");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
@@ -34,6 +36,60 @@ function print(...content) {
|
||||
console.log(...content);
|
||||
}
|
||||
|
||||
async function getEditor(argsEditor) {
|
||||
// eslint-disable-next-line
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || -1
|
||||
// needed for the process.env statements to not be flagged by eslint
|
||||
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
|
||||
}
|
||||
|
||||
async function configToKeyVal(data) {
|
||||
let final = {};
|
||||
let splitData = data.split(/\r?\n/);
|
||||
// split on newlines
|
||||
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
|
||||
// the last filter removes empty elements left by the first. Not done on one line due to readability.
|
||||
for (let index in filtered) {
|
||||
if (index in filtered) {
|
||||
filtered[index] = filtered[index].split("=");
|
||||
let finalIndex = filtered[index][0].trim();
|
||||
// split on the = we added earlier, giving us both value and key
|
||||
let part = "";
|
||||
try {
|
||||
part = filtered[index][1].trim();
|
||||
// it's a string if we can read it
|
||||
} catch (err) {
|
||||
// if we can't read it, it's a empty field and therefor null
|
||||
part = "";
|
||||
}
|
||||
final[finalIndex] = part;
|
||||
}
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
async function serializedConfigToString(serializedConfig, configGroup, disallowedList) {
|
||||
let allConfigElements = "";
|
||||
for (let group of serializedConfig.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
if (`${group.name}.${name}` in disallowedList) {
|
||||
continue;
|
||||
}
|
||||
let desc = "";
|
||||
try {
|
||||
desc += configGroup.groups.get(group.name)._definitions.get(name).description;
|
||||
} catch (err) {
|
||||
desc += "No description found";
|
||||
}
|
||||
// split onto two lines for readability and es-lint
|
||||
if (String(value) === "null") {
|
||||
value = "";
|
||||
}
|
||||
allConfigElements += `${group.name}.${name} = ${value}\n\n`;
|
||||
}
|
||||
}
|
||||
return allConfigElements;
|
||||
}
|
||||
|
||||
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
|
||||
const masterConfigCommands = new libCommand.CommandTree({
|
||||
@@ -104,6 +160,66 @@ masterConfigCommands.add(new libCommand.Command({
|
||||
await libLink.messages.setMasterConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
masterConfigCommands.add(new libCommand.Command({
|
||||
definition: ["edit [editor]", "Edit master configuration", (yargs) => {
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.getMasterConfig.send(control);
|
||||
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === -1) {
|
||||
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl master config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {});
|
||||
await fs.writeFile(tmpFile, allConfigElements, (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await libLink.messages.setMasterConfigField.send(control, {
|
||||
field: index,
|
||||
value: final[index],
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
print(err);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
await fs.unlink(tmpFile, (err) => {
|
||||
if (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
masterCommands.add(masterConfigCommands);
|
||||
|
||||
|
||||
@@ -300,6 +416,79 @@ instanceConfigCommands.add(new libCommand.Command({
|
||||
await libLink.messages.setInstanceConfigProp.send(control, request);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
|
||||
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === -1) {
|
||||
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl master config edit <editor of choice>"`);
|
||||
}
|
||||
let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0};
|
||||
let allConfigElements = await serializedConfigToString(
|
||||
response.serialized_config,
|
||||
libConfig.InstanceConfig,
|
||||
disallowedList
|
||||
);
|
||||
await fs.writeFile(tmpFile, allConfigElements, (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await libLink.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: index,
|
||||
value: final[index],
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
await fs.unlink(tmpFile, (err) => {
|
||||
if (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -6,10 +6,11 @@ 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");
|
||||
|
||||
@@ -689,7 +690,7 @@ class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
|
||||
let config = {
|
||||
host: "localhost",
|
||||
host: "127.0.0.1", // localhost may resolve to ::1 while Factorio listen to only IPv4 by default.
|
||||
port: this.rconPort,
|
||||
password: this.rconPassword,
|
||||
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
|
||||
@@ -1173,17 +1174,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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
@@ -1145,11 +1147,13 @@ messages.instanceInitialized = new Event({
|
||||
messages.instanceStatusChanged = new Event({
|
||||
type: "instance_status_changed",
|
||||
links: ["instance-slave", "slave-master"],
|
||||
eventRequired: ["instance_id", "status"],
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"status": { enum: [
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]},
|
||||
"game_port": { type: ["null", "integer"] },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+11
-2
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const helpers = require("./helpers");
|
||||
|
||||
/**
|
||||
* Conceptual base for master and instance plugins.
|
||||
@@ -733,8 +734,16 @@ async function invokeHook(plugins, hook, ...args) {
|
||||
let results = [];
|
||||
for (let [name, plugin] of plugins) {
|
||||
try {
|
||||
let result = await plugin[hook](...args);
|
||||
if (result !== undefined) {
|
||||
// Use an object to detect if the hook failed on timeout or tried to return a value looking like an error
|
||||
const timeout = {};
|
||||
let result = await helpers.timeout(
|
||||
plugin[hook](...args),
|
||||
15000,
|
||||
timeout
|
||||
);
|
||||
if (result === timeout) {
|
||||
throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`);
|
||||
} else if (result !== undefined) {
|
||||
results.push(result);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -7,6 +7,7 @@ const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libConfig = require("./config");
|
||||
const libFileOps = require("./file_ops");
|
||||
const { logger } = require("./logging");
|
||||
const libHelpers = require("./helpers");
|
||||
|
||||
@@ -70,7 +71,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
|
||||
}
|
||||
|
||||
pluginList.set(pluginInfo.name, pluginPath);
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
print(`Added ${pluginInfo.name}`);
|
||||
|
||||
} else if (command === "remove") {
|
||||
@@ -80,7 +81,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
|
||||
print(`Removed ${args.name}`);
|
||||
|
||||
} else if (command === "list") {
|
||||
@@ -155,7 +156,7 @@ async function handleConfigCommand(args, instance, configPath) {
|
||||
|
||||
try {
|
||||
instance.set(args.field, args.value);
|
||||
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
|
||||
logger.error(err.message);
|
||||
|
||||
@@ -26,6 +26,7 @@ const jwt = require("jsonwebtoken");
|
||||
|
||||
// homebrew modules
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
const libPrometheus = require("@clusterio/lib/prometheus");
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
@@ -162,7 +163,7 @@ async function handleBootstrapCommand(args, masterConfig) {
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await fs.outputFile(args.output, content);
|
||||
await libFileOps.safeOutputFile(args.output, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +295,9 @@ async function initialize() {
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let bytes = await asyncRandomBytes(256);
|
||||
parameters.masterConfig.set("master.auth_secret", bytes.toString("base64"));
|
||||
await fs.outputFile(parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(
|
||||
parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4)
|
||||
);
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
|
||||
@@ -106,6 +106,7 @@ class ControlConnection extends BaseConnection {
|
||||
version: slave.version,
|
||||
id: slave.id,
|
||||
name: slave.name,
|
||||
public_address: slave.public_address || null,
|
||||
connected: this._master.wsServer.slaveConnections.has(slave.id),
|
||||
});
|
||||
}
|
||||
@@ -169,6 +170,7 @@ class ControlConnection extends BaseConnection {
|
||||
id,
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +182,7 @@ class ControlConnection extends BaseConnection {
|
||||
id: instance.config.get("instance.id"),
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
});
|
||||
}
|
||||
@@ -199,6 +202,7 @@ class ControlConnection extends BaseConnection {
|
||||
id: instance.config.get("instance.id"),
|
||||
name: instance.config.get("instance.name"),
|
||||
assigned_slave: instance.config.get("instance.assigned_slave"),
|
||||
game_port: instance.game_port || null,
|
||||
status: instance.status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const path = require("path");
|
||||
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
@@ -241,7 +242,7 @@ class Master {
|
||||
logger.info("Stopping master");
|
||||
|
||||
logger.info("Saving config");
|
||||
await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
|
||||
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
|
||||
|
||||
if (this.devMiddleware) {
|
||||
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
|
||||
@@ -297,7 +298,7 @@ class Master {
|
||||
}
|
||||
|
||||
static async saveSlaves(filePath, slaves) {
|
||||
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
|
||||
}
|
||||
|
||||
static async loadInstances(filePath) {
|
||||
@@ -329,7 +330,7 @@ class Master {
|
||||
serialized.push(instance.config.serialize());
|
||||
}
|
||||
|
||||
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
|
||||
}
|
||||
|
||||
static addAppRoutes(app, pluginInfos) {
|
||||
@@ -367,6 +368,7 @@ class Master {
|
||||
version: slave.version,
|
||||
id: slave.id,
|
||||
name: slave.name,
|
||||
public_address: slave.public_address || null,
|
||||
connected: this.wsServer.slaveConnections.has(slave.id),
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
+18
-6
@@ -260,7 +260,9 @@ class Instance extends libLink.Link {
|
||||
notifyStatus(status) {
|
||||
this._status = status;
|
||||
libLink.messages.instanceStatusChanged.send(this, {
|
||||
instance_id: this.id, status,
|
||||
instance_id: this.id,
|
||||
status,
|
||||
game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,7 +378,7 @@ class Instance extends libLink.Link {
|
||||
*/
|
||||
async writeServerSettings() {
|
||||
let serverSettings = await this.resolveServerSettings(this.config.get("factorio.settings"));
|
||||
await fs.writeFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-settings.json"),
|
||||
JSON.stringify(serverSettings, null, 4)
|
||||
);
|
||||
@@ -410,7 +412,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist")) {
|
||||
this.logger.verbose("Writing server-adminlist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-adminlist.json"),
|
||||
JSON.stringify([...this._slave.adminlist], null, 4)
|
||||
);
|
||||
@@ -418,7 +420,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_banlist")) {
|
||||
this.logger.verbose("Writing server-banlist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-banlist.json"),
|
||||
JSON.stringify([...this._slave.banlist].map(
|
||||
([username, reason]) => ({ username, reason })
|
||||
@@ -428,7 +430,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
if (this.config.get("factorio.sync_whitelist")) {
|
||||
this.logger.verbose("Writing server-whitelist.json");
|
||||
fs.outputFile(
|
||||
libFileOps.safeOutputFile(
|
||||
this.server.writePath("server-whitelist.json"),
|
||||
JSON.stringify([...this._slave.whitelist], null, 4)
|
||||
);
|
||||
@@ -886,6 +888,11 @@ async function discoverInstances(instancesDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
|
||||
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let instancePath = path.join(instancesDir, entry.name);
|
||||
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
instanceInfos.set(instanceConfig.get("instance.id"), {
|
||||
@@ -999,6 +1006,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
|
||||
version,
|
||||
id: this.slaveConfig.get("slave.id"),
|
||||
name: this.slaveConfig.get("slave.name"),
|
||||
public_address: this.slaveConfig.get("slave.public_address"),
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
@@ -1092,6 +1100,10 @@ class Slave extends libLink.Link {
|
||||
}
|
||||
|
||||
this.updateInstances().catch((err) => {
|
||||
if (err instanceof libErrors.SessionLost) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
|
||||
return this.shutdown();
|
||||
});
|
||||
@@ -1294,7 +1306,7 @@ class Slave extends libLink.Link {
|
||||
_warning: "Changes to this file will be overwritten by the master server's copy.",
|
||||
...instanceInfo.config.serialize(),
|
||||
};
|
||||
await fs.outputFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
path.join(instanceInfo.path, "instance.json"),
|
||||
JSON.stringify(warnedOutput, null, 4)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,30 @@ import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Ty
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function getInitialValues(config, props) {
|
||||
let initialValues = {};
|
||||
for (let [name, GroupClass] of props.ConfigClass.groups) {
|
||||
let group = config.group(name);
|
||||
for (let def of GroupClass.definitions.values()) {
|
||||
if (!group.canAccess(def.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = group.get(def.name);
|
||||
let fieldName = `${group.name}.${def.name}`;
|
||||
|
||||
if (def.type === "object") {
|
||||
for (let prop of Object.keys(value)) {
|
||||
let propPath = `${group.name}.${def.name}.${prop}`;
|
||||
initialValues[propPath] = JSON.stringify(value[prop]);
|
||||
}
|
||||
} else {
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
export default function BaseConfigTree(props) {
|
||||
let [config, setConfig] = useState(null);
|
||||
@@ -16,8 +40,13 @@ export default function BaseConfigTree(props) {
|
||||
let newConfig = new props.ConfigClass("control");
|
||||
await newConfig.load(serializedConfig);
|
||||
setConfig(newConfig);
|
||||
return newConfig;
|
||||
}
|
||||
useEffect(() => { updateConfig(); }, [props.id]);
|
||||
useEffect(() => {
|
||||
updateConfig().then(updatedConfig => {
|
||||
form.setFieldsValue(getInitialValues(updatedConfig, props));
|
||||
});
|
||||
}, [props.id]);
|
||||
|
||||
function renderInput(def) {
|
||||
if (def.type === "boolean") {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { message, Button, Table } from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
|
||||
import { useSlaveList } from "../model/slave";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
export default function InstanceList(props) {
|
||||
let history = useHistory();
|
||||
let [slaveList] = useSlaveList();
|
||||
|
||||
function slaveName(slaveId) {
|
||||
let slave = slaveList.find(s => s.id === slaveId);
|
||||
if (slave) {
|
||||
return slave.name;
|
||||
}
|
||||
return String(slaveId);
|
||||
}
|
||||
|
||||
function instancePublicAddress(instance) {
|
||||
let slave = slaveList.find(s => s.id === instance.assigned_slave);
|
||||
if (slave && slave.public_address) {
|
||||
if (instance.game_port) {
|
||||
return `${slave.public_address}:${instance.game_port}`;
|
||||
}
|
||||
return slave.public_address;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
},
|
||||
{
|
||||
title: "Assigned Slave",
|
||||
key: "assigned_slave",
|
||||
render: instance => slaveName(instance["assigned_slave"]),
|
||||
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
|
||||
},
|
||||
{
|
||||
title: "Public address",
|
||||
key: "public_address",
|
||||
render: instance => {
|
||||
let public_address = instancePublicAddress(instance);
|
||||
return public_address ? <>
|
||||
{public_address}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined/>}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(public_address);
|
||||
message.success("Copied public address!");
|
||||
}}
|
||||
/>
|
||||
</> : "";
|
||||
},
|
||||
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
render: status => <InstanceStatusTag status={status} />,
|
||||
sorter: (a, b) => strcmp(a["status"], b["status"]),
|
||||
},
|
||||
];
|
||||
|
||||
if (props.hideAssignedSlave) {
|
||||
columns.splice(1, 1);
|
||||
}
|
||||
|
||||
return <Table
|
||||
size={props.size || "large"}
|
||||
columns={columns}
|
||||
dataSource={props.instances}
|
||||
rowKey={instance => instance["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/instances/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Tag } from "antd";
|
||||
import React from "react";
|
||||
|
||||
export const statusColors = {
|
||||
unknown: "#8c8c8c",
|
||||
unassigned: "#eb2f96",
|
||||
stopped: "#cf1322",
|
||||
starting: "#ad8b00",
|
||||
running: "#389e0d",
|
||||
stopping: "#d48806",
|
||||
creating_save: "#096dd9",
|
||||
exporting_data: "#08979c",
|
||||
deleted: "#262626",
|
||||
};
|
||||
|
||||
|
||||
export default function InstanceStatusTag(props) {
|
||||
return <Tag color={statusColors[props.status]}>
|
||||
{props.status.replace("_", " ")}
|
||||
</Tag>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
|
||||
import { Alert, Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
@@ -9,7 +9,7 @@ import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import InstanceConfigTree from "./InstanceConfigTree";
|
||||
import InstanceConsole from "./InstanceConsole";
|
||||
import LogConsole from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
import AssignInstanceModal from "./AssignInstanceModal";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
@@ -18,6 +18,7 @@ import SavesList from "./SavesList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useSlave } from "../model/slave";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
@@ -40,10 +41,22 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -116,7 +129,7 @@ export default function InstanceViewPage(props) {
|
||||
buttonContent={assigned ? "Reassign" : "Assign"}
|
||||
/>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{
|
||||
@@ -127,7 +140,7 @@ 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"} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -8,11 +8,8 @@ import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
import InstanceList from "./InstanceList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useSlaveList } from "../model/slave";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
function CreateInstanceButton(props) {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -53,7 +50,7 @@ function CreateInstanceButton(props) {
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
<Input/>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -61,54 +58,41 @@ 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} />
|
||||
</PageLayout>;
|
||||
}
|
||||
+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, props.instances]);
|
||||
|
||||
return <>
|
||||
<Paragraph code className="instance-console">
|
||||
@@ -1,15 +1,23 @@
|
||||
import React, { useContext } from "react";
|
||||
import { Typography } from "antd";
|
||||
|
||||
import LogConsole from "./LogConsole";
|
||||
import { useAccount } from "../model/account";
|
||||
import MasterConfigTree from "./MasterConfigTree";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
export default function SlavesPage() {
|
||||
let account = useAccount();
|
||||
|
||||
return <PageLayout nav={[{ name: "Master" }]}>
|
||||
<h2>Master</h2>
|
||||
{account.hasPermission("core.log.follow") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<LogConsole master={true} />
|
||||
</>}
|
||||
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
|
||||
</PageLayout>;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Descriptions, PageHeader, Spin, Tag, Typography } from "antd";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import InstanceList from "./InstanceList";
|
||||
import LogConsole from "./LogConsole";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
import { useSlave } from "../model/slave";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
export default function SlaveViewPage(props) {
|
||||
let params = useParams();
|
||||
let slaveId = Number(params.id);
|
||||
let account = useAccount();
|
||||
let [instanceList] = useInstanceList();
|
||||
let [slave] = useSlave(slaveId);
|
||||
|
||||
instanceList = instanceList.filter(instance => instance["assigned_slave"] === slaveId);
|
||||
|
||||
let nav = [{ name: "Slaves", path: "/slaves" }, { name: slave.name || slaveId }];
|
||||
if (slave.loading) {
|
||||
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
if (slave.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={slaveId}
|
||||
/>
|
||||
<p>Slave with id {slaveId} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={slave.name || slaveId}
|
||||
/>
|
||||
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
|
||||
<Descriptions.Item label="Name">{slave["name"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Connected">
|
||||
<Tag color={slave["connected"] ? "#389e0d" : "#cf1322"}>
|
||||
{slave["connected"] ? "Connected" : "Disconnected"}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Agent">{slave["agent"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Version">{slave["version"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{account.hasPermission("core.instance.list") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
|
||||
<InstanceList instances={instanceList} size="small" hideAssignedSlave />
|
||||
</>}
|
||||
{account.hasPermission("core.log.follow") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<LogConsole slaves={[slaveId]} />
|
||||
</>}
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -75,6 +76,7 @@ function GenerateSlaveTokenButton(props) {
|
||||
|
||||
export default function SlavesPage() {
|
||||
let account = useAccount();
|
||||
let history = useHistory();
|
||||
let [slaveList] = useSlaveList();
|
||||
|
||||
return <PageLayout nav={[{ name: "Slaves" }]}>
|
||||
@@ -102,16 +104,30 @@ 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`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
@@ -26,7 +27,7 @@ async function saveDatabase(masterConfig, playerDatastore, logger) {
|
||||
let file = path.resolve(masterConfig.get("master.database_directory"), "inventories.json");
|
||||
logger.verbose(`writing ${file}`);
|
||||
let content = JSON.stringify(Array.from(playerDatastore));
|
||||
await fs.outputFile(file, content);
|
||||
await libFileOps.safeOutputFile(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const libFileOps = require("@clusterio/lib/file_ops");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const RateLimiter = require("@clusterio/lib/RateLimiter");
|
||||
|
||||
@@ -25,7 +26,7 @@ async function loadTechnologies(masterConfig, logger) {
|
||||
async function saveTechnologies(masterConfig, technologies, logger) {
|
||||
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
|
||||
logger.verbose(`writing ${filePath}`);
|
||||
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
|
||||
}
|
||||
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
|
||||
@@ -3,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})`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ describe("lib/file_ops", function() {
|
||||
async function setupTestingEnv() {
|
||||
await fs.ensureDir(path.join(baseDir, "test", "folder"));
|
||||
await fs.ensureDir(path.join(baseDir, "test", "another folder"));
|
||||
await fs.remove(path.join(baseDir, "safe"));
|
||||
await fs.ensureDir(path.join(baseDir, "safe"));
|
||||
|
||||
await fs.outputFile(path.join(baseDir, "test", "file.txt"), "contents");
|
||||
await fs.outputFile(path.join(baseDir, "test", "another file.txt"), "more contents");
|
||||
@@ -66,4 +68,20 @@ describe("lib/file_ops", function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeOutputFile()", function() {
|
||||
it("should write new target file", async function() {
|
||||
let target = path.join(baseDir, "safe", "simple.txt");
|
||||
await libFileOps.safeOutputFile(target, "a text file", "utf8");
|
||||
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
|
||||
assert.equal(await fs.readFile(target, "utf8"), "a text file");
|
||||
});
|
||||
it("should overwrite existing target file", async function() {
|
||||
let target = path.join(baseDir, "safe", "exists.txt");
|
||||
await fs.outputFile(target, "previous", "utf8");
|
||||
await libFileOps.safeOutputFile(target, "current", "utf8");
|
||||
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
|
||||
assert.equal(await fs.readFile(target, "utf8"), "current");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user