mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fa12200f | ||
|
|
35f769ad25 | ||
|
|
681e78967f | ||
|
|
d2bd1382ba | ||
|
|
c0ec9a3890 | ||
|
|
4221b96431 | ||
|
|
3ddbe2ddb3 | ||
|
|
7ceb786143 | ||
|
|
174d9d5b07 | ||
|
|
a1118d08dd | ||
|
|
379d2575a0 | ||
|
|
e30dcc6fff | ||
|
|
281a5ed97e | ||
|
|
6004f861fe | ||
|
|
174c798d4b | ||
|
|
14b8b5ac61 | ||
|
|
89dec572bb | ||
|
|
f662880c8d | ||
|
|
90cd2770b3 | ||
|
|
69240e7917 | ||
|
|
e1d9c1e414 | ||
|
|
376eaeba52 | ||
|
|
80983662aa | ||
|
|
9840f22688 | ||
|
|
b9e2acaaa5 | ||
|
|
7ac8cd3781 | ||
|
|
7e2f10e842 | ||
|
|
256407b9fd | ||
|
|
7979dfaa16 | ||
|
|
10a87d47da | ||
|
|
59709280da | ||
|
|
81b656f0ab | ||
|
|
f966c5b965 | ||
|
|
56a26848e6 | ||
|
|
28f21bfb70 | ||
|
|
7b4724845f | ||
|
|
2053423864 | ||
|
|
e7519975ec | ||
|
|
7b7389526b | ||
|
|
a464b61d39 | ||
|
|
88ca4563a8 | ||
|
|
449bd68fab | ||
|
|
59256e77d1 | ||
|
|
f11d91bd6b | ||
|
|
e0278d0331 | ||
|
|
2baa2e50ac |
No files matched your search
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -47,6 +47,9 @@ async function removeBuildArtifacts() {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +84,7 @@ async function removeExecutionArtifacts() {
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs
|
||||
await yargs(process.argv.slice(2))
|
||||
.scriptName("clean")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("dry", {
|
||||
|
||||
@@ -239,6 +239,14 @@ Does not affect start or stop of the instance while the host is running.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
This allows you to prevent certain instances from being started when using the bulk start operation.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
|
||||
+275
-186
@@ -1,118 +1,47 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"node",
|
||||
],
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es2022": true,
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["{packages,plugins}/**/*.ts?(x)"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"parserOptions": { sourceType: "commonjs" },
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "off",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{t,j}s?(x)",
|
||||
],
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
@@ -120,26 +49,34 @@ module.exports = {
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "error",
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
@@ -148,27 +85,36 @@ module.exports = {
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||
|
||||
"dot-notation": ["error", {
|
||||
"allowPattern": "^[a-z]+(_[a-z]+)*$",
|
||||
}],
|
||||
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
|
||||
"func-style": ["error", "declaration", {
|
||||
// Allow arrow functions as it better expresses referencing this
|
||||
// of the containing scope, which is unweilding in plain functions.
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
@@ -178,10 +124,18 @@ module.exports = {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
|
||||
"lines-between-class-members": ["error", "always", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
}],
|
||||
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
@@ -190,10 +144,12 @@ module.exports = {
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
@@ -207,7 +163,11 @@ module.exports = {
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
|
||||
"no-constant-condition": ["error", {
|
||||
"checkLoops": false,
|
||||
}],
|
||||
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
@@ -232,16 +192,15 @@ module.exports = {
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
@@ -256,7 +215,11 @@ module.exports = {
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
@@ -268,9 +231,15 @@ module.exports = {
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
@@ -289,7 +258,11 @@ module.exports = {
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
@@ -299,6 +272,7 @@ module.exports = {
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
@@ -308,6 +282,7 @@ module.exports = {
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
@@ -324,9 +299,13 @@ module.exports = {
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
@@ -342,73 +321,183 @@ module.exports = {
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
"strict": "error",
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
"valid-jsdoc": ["error", {
|
||||
"requireReturn": false,
|
||||
"requireReturnType": false,
|
||||
"requireParamType": false,
|
||||
}],
|
||||
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": ["error", "never", { "exceptRange": true }],
|
||||
|
||||
"yoda": ["error", "never", {
|
||||
"exceptRange": true,
|
||||
}],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
// Node.js callback passing style of (err, value) is not used in this project
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": ["error", {
|
||||
"tryExtensions": [".js", ".ts"],
|
||||
}],
|
||||
"node/no-missing-require": "error",
|
||||
"node/no-mixed-requires": "error",
|
||||
"node/no-new-require": "error",
|
||||
"node/no-path-concat": "error",
|
||||
"node/no-process-env": "error",
|
||||
"node/no-process-exit": "error",
|
||||
|
||||
"node/no-missing-import": "off",
|
||||
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "error",
|
||||
"node/no-unpublished-import": "error",
|
||||
"node/no-unpublished-require": [
|
||||
"error",
|
||||
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
|
||||
],
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
|
||||
"node/no-unpublished-require": "off",
|
||||
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "error",
|
||||
"node/prefer-global/console": "error",
|
||||
"node/prefer-global/process": "error",
|
||||
"node/prefer-global/text-decoder": "error",
|
||||
"node/prefer-global/text-encoder": "error",
|
||||
"node/prefer-global/url-search-params": "error",
|
||||
"node/prefer-global/url": "error",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": ["error", {
|
||||
"convertPath": {
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
|
||||
},
|
||||
}],
|
||||
"node/process-exit-as-throw": "off",
|
||||
|
||||
"node/shebang": "off",
|
||||
},
|
||||
};
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "commonjs",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
+18
-14
@@ -41,21 +41,25 @@
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@stylistic/eslint-plugin": "^5.2.2",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^4.21.2",
|
||||
"form-data": "^4.0.2",
|
||||
"express": "^5.1.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.7.3",
|
||||
"yargs": "^17.7.2"
|
||||
"globals": "^16.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"mocha": "^11.7.1",
|
||||
"nyc": "^17.1.0",
|
||||
"phin": "^3.7.1",
|
||||
"typedoc": "^0.28.9",
|
||||
"typescript": "^5.9.2",
|
||||
"yargs": "^18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -422,7 +422,7 @@ async function startup() {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ async function startup() {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"version": "2.0.0-alpha.21",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as routes from "./routes";
|
||||
* @alias module:controller/src/BaseConnection
|
||||
*/
|
||||
export default class BaseConnection extends lib.Link {
|
||||
declare ["connector"]: WsServerConnector;
|
||||
declare "connector": WsServerConnector;
|
||||
|
||||
constructor(
|
||||
connector: lib.WebSocketBaseConnector,
|
||||
|
||||
@@ -901,7 +901,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
// A large number of small if statements are unavoidable when reducing the number of updates sent
|
||||
// eslint-disable-next-line complexity
|
||||
|
||||
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
|
||||
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
|
||||
const updated = new Map<ControllerUser["id"], ControllerUser>();
|
||||
|
||||
@@ -399,13 +399,12 @@ export default class Controller {
|
||||
|
||||
async _startDevServer(args: ControllerArgs) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
const webpack = require("webpack");
|
||||
const webpackDevMiddleware = require("webpack-dev-middleware");
|
||||
const webpackConfigs = [];
|
||||
|
||||
if (args.dev) {
|
||||
// eslint-disable-next-line node/no-missing-require
|
||||
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
@@ -421,7 +420,7 @@ export default class Controller {
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
|
||||
const compiler = webpack(webpackConfigs);
|
||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
||||
|
||||
@@ -47,7 +47,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
this.connector.on(event, () => {
|
||||
for (let plugin of this._controller.plugins.values()) {
|
||||
plugin.onHostConnectionEvent(this, event);
|
||||
|
||||
@@ -45,6 +45,7 @@ export default class InstanceInfo {
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
|
||||
@@ -114,7 +114,7 @@ async function execFile(cmd, args) {
|
||||
escaped,
|
||||
{
|
||||
shell: true,
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
@@ -396,7 +396,7 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
@@ -689,6 +689,7 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
|
||||
@@ -50,7 +50,7 @@ function escapeArg(arg) {
|
||||
// For all other platforms the command is /bin/sh -c <command arg arg ...>
|
||||
// The command and its arguments are passed as a single argument to the shell.
|
||||
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.21c",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
@@ -3,6 +3,8 @@ const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const _package = require("./package.json");
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
|
||||
@@ -102,6 +104,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
prepare.push("tsc --build");
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
@@ -109,7 +112,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --env production");
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
@@ -139,7 +142,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files for lua modules
|
||||
if (templates.includes("module")) {
|
||||
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
|
||||
files.set("module/module.json", path.join(commonPath, "module/module.json"));
|
||||
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
@@ -147,7 +149,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +165,25 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
config: config,
|
||||
// Weather this is ts or js
|
||||
typescript: !javascriptOnly,
|
||||
ext: ext,
|
||||
// Which templates where requested
|
||||
multi_context: pluginContexts > 1,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
instance: templates.includes("instance"),
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
multi_context: pluginContexts > 1,
|
||||
plugin_name: pluginName,
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
node_version: _package.engines.node,
|
||||
prepare: prepare.join(" && "),
|
||||
ext: ext,
|
||||
plugin_name: pluginName,
|
||||
};
|
||||
|
||||
// Write all the template files
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
dist/
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,3 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
dist/web
|
||||
@@ -1,3 +1,14 @@
|
||||
--[[
|
||||
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
|
||||
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
|
||||
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
|
||||
Most of your code will be within this one file, but you can create more and require them
|
||||
|
||||
clusterio_api exposes functions to enable communication with your InstancePlugin
|
||||
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
|
||||
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
@@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat")
|
||||
local MyModule = {
|
||||
}
|
||||
|
||||
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
|
||||
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
|
||||
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
-- If you use any of these events and access globalData then make sure to call this function first
|
||||
--[[
|
||||
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
|
||||
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
|
||||
There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
If you use any of these events and access `script_data` then make sure to call this function first
|
||||
]]
|
||||
local script_data = {}
|
||||
local function setup_script_data()
|
||||
if compat.script_data["__plugin_name__"] == nil then
|
||||
@@ -28,12 +41,13 @@ end
|
||||
|
||||
--- Public methods should be available though your top level module table
|
||||
function MyModule.foo()
|
||||
game.print("foo")
|
||||
game.print("foo: " .. (script_data.counter or 0))
|
||||
end
|
||||
|
||||
--- Private methods should be local to the file, this will prevent others from calling it
|
||||
local function bar()
|
||||
game.print("bar")
|
||||
script_data.counter = (script_data.counter or 0) + 1
|
||||
game.print("bar: " .. script_data.counter)
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
|
||||
@@ -61,7 +75,7 @@ local function on_nth_tick_300()
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per module
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per file
|
||||
local e = defines.events
|
||||
local events = {
|
||||
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||
@@ -73,7 +87,7 @@ local on_nth_tick = {
|
||||
[300] = on_nth_tick_300,
|
||||
}
|
||||
|
||||
--- Always return the top level module table from control, this is how clusterio will access your event handlers
|
||||
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
|
||||
MyModule.events = events --- @package
|
||||
MyModule.on_nth_tick = on_nth_tick --- @package
|
||||
return MyModule
|
||||
@@ -1,3 +1,9 @@
|
||||
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
|
||||
-- luacheck: globals __plugin_name__
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
Typically this would be your control file as shown in the example below
|
||||
]]
|
||||
|
||||
-- Access using `/sc __plugin_name__.foo()`
|
||||
__plugin_name__ = require("modules/__plugin_name__/control")
|
||||
@@ -1,4 +1,8 @@
|
||||
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
|
||||
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
|
||||
-- Access the exports from other modules using require("modules/__plugin_name__")
|
||||
--[[
|
||||
It is typical for a module to have a control file as the entry point, although this is not a requirement
|
||||
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
|
||||
You are not required to specify any default export, but note that a module can still require yours directly
|
||||
]]
|
||||
|
||||
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
|
||||
return require("modules/__plugin_name__/control")
|
||||
@@ -12,10 +12,10 @@
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
@@ -32,13 +32,13 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if web | controller // The controller uses webpack even when there is no plugin code
|
||||
//%if webpack
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
@@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
//%if web
|
||||
"./web": "./web/index.__ext__x",
|
||||
//%endif
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} = require("./messages");
|
||||
//%endif
|
||||
|
||||
@@ -26,7 +26,8 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
@@ -70,6 +71,7 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
|
||||
@@ -7,12 +7,14 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -21,6 +23,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
@@ -29,6 +32,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
|
||||
@@ -40,7 +44,7 @@ const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
|
||||
@@ -24,18 +24,26 @@ class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Called once rcon becomes available
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
this.logger.info("instance::onExit");
|
||||
}
|
||||
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages";
|
||||
//%endif
|
||||
|
||||
@@ -25,7 +25,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
|
||||
@@ -6,12 +6,14 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -20,6 +22,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
@@ -28,6 +31,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if config
|
||||
|
||||
@@ -31,18 +31,25 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Called once rcon becomes available
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
this.logger.info("instance::onExit");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "es2023"],
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"noEmitOnError": true,
|
||||
"outDir": "${configDir}/dist/browser",
|
||||
},
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"target": "es2022",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "${configDir}/dist/node"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
+19
-9
@@ -5,7 +5,7 @@
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
@@ -156,7 +156,17 @@ export async function initialize(
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>,
|
||||
noLoggerTransport?: boolean,
|
||||
): Promise<InitializeParameters> {
|
||||
yargs
|
||||
// Build a fresh, isolated yargs parser each time this function is called.
|
||||
// If the currently loaded yargs object supports .reset() we use that (older versions),
|
||||
// otherwise we fall back to creating a brand-new parser instance by invoking the
|
||||
// yargs factory function directly with an empty argv array. This avoids Mocha’s
|
||||
// own CLI flags (or anything else that touched yargs earlier in the same process)
|
||||
// from leaking into subsequent parses inside the test runner.
|
||||
const parser: Argv = typeof (yargs as any).reset === "function"
|
||||
? (yargs as any).reset()
|
||||
: (yargs as any)([]);
|
||||
|
||||
parser
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
@@ -181,13 +191,13 @@ export async function initialize(
|
||||
})
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("control-config", "Manage Control config", lib.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.wrap(parser.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
// eslint-disable-next-line node/no-sync
|
||||
let args = yargs.parseSync(argv) as CtlArguments;
|
||||
let args = parser.parseSync(argv) as CtlArguments;
|
||||
|
||||
// Log stream for the ctl session. Skipped in testing.
|
||||
if (!noLoggerTransport) {
|
||||
@@ -217,8 +227,8 @@ export async function initialize(
|
||||
}
|
||||
|
||||
// Add all commands including from plugins and reparse with help and strict checking.
|
||||
const rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
args = yargs
|
||||
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
|
||||
args = parser
|
||||
.help()
|
||||
.strict()
|
||||
.parse(argv) as CtlArguments
|
||||
@@ -241,8 +251,8 @@ export async function initialize(
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
parser.showHelp();
|
||||
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
@@ -310,7 +320,7 @@ async function startControl() {
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1038,17 +1038,87 @@ instanceCommands.add(new lib.Command({
|
||||
definition: ["start <instance>", "Start instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
|
||||
let instanceId = await lib.resolveInstance(control, args.instance);
|
||||
const instanceId = await lib.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["restart <instance>", "Restart instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
|
||||
const instanceId = await lib.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
|
||||
yargs.options({
|
||||
"force": {
|
||||
describe: "Start instances even if they have exclude_from_start_all set",
|
||||
nargs: 0,
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { force: boolean }, control: Control) {
|
||||
// Get all instances
|
||||
const instances = await control.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be started
|
||||
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
|
||||
// Only start instances that are currently stopped
|
||||
if (instance.status !== "stopped") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Respect exclude_from_start_all setting unless --force is used
|
||||
if (!args.force && instance.excludeFromStartAll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (instancesToStart.length === 0) {
|
||||
print("No instances to start.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Starting ${instancesToStart.length} instance(s):`);
|
||||
for (const instance of instancesToStart) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Start all filtered instances
|
||||
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
|
||||
print(`✓ Started ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to start ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(startPromises);
|
||||
print("Start all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
@@ -1095,6 +1165,41 @@ instanceCommands.add(new lib.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["stop-all", "Stop all running instances", () => {}],
|
||||
handler: async function(_args, control: Control) {
|
||||
// Get all instances
|
||||
const instances = await control.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be stopped
|
||||
const instancesToStop = instances
|
||||
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
|
||||
|
||||
if (instancesToStop.length === 0) {
|
||||
print("No instances to stop.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Stopping ${instancesToStop.length} instance(s):`);
|
||||
for (const instance of instancesToStop) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Stop all filtered instances
|
||||
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
|
||||
print(`✓ Stopped ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to stop ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
print("Stop all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["kill <instance>", "Kill instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
|
||||
@@ -2047,7 +2152,7 @@ userCommands.add(new lib.Command({
|
||||
exportType = "whitelist";
|
||||
}
|
||||
|
||||
fs.writeJSON(args.filepath, await control.send(
|
||||
await fs.writeJSON(args.filepath, await control.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), { spaces: 2 });
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
|
||||
@@ -243,7 +243,7 @@ async function startHost() {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ async function startHost() {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ export class HostRouter {
|
||||
* Connects to the controller over the WebSocket and manages intsances.
|
||||
*/
|
||||
export default class Host extends lib.Link {
|
||||
declare ["connector"]: HostConnector;
|
||||
declare "connector": HostConnector;
|
||||
|
||||
router = new HostRouter(this);
|
||||
/**
|
||||
@@ -1201,7 +1201,7 @@ export default class Host extends lib.Link {
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ export default class Instance extends lib.Link {
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
|
||||
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
|
||||
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
|
||||
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
|
||||
this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this));
|
||||
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
|
||||
@@ -1300,6 +1301,14 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this.stop();
|
||||
// Stopping invalidates this instance object, a new one must be created by the host
|
||||
// To do this we simulate a waking an instance and sending a start request
|
||||
const instanceConnection = await this._host._connectInstance(this.id);
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ async function loadLayeredIcon(
|
||||
iconLayer.scale(realScale);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
|
||||
x += xs;
|
||||
y += ys;
|
||||
|
||||
@@ -747,7 +747,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
this._rconClient.on("authenticated", () => {
|
||||
this._rconReady = true;
|
||||
this.emit("rcon-ready");
|
||||
});
|
||||
});
|
||||
this._rconClient.on("end", () => {
|
||||
this._rconClient = null;
|
||||
if (!this._rconReady) {
|
||||
|
||||
Vendored
+7
-7
@@ -1,10 +1,10 @@
|
||||
export interface BuildArgs {
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
};
|
||||
export function build(args: BuildArgs): Promise<void>;
|
||||
@@ -398,6 +398,7 @@ export interface InstanceConfigFields {
|
||||
"instance.id": number;
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
|
||||
"factorio.version": string;
|
||||
"factorio.executable_path": string | null;
|
||||
@@ -465,6 +466,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.exclude_from_start_all": {
|
||||
description: "Exclude this instance from the 'Start all' button operation",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
restartRequired: true,
|
||||
|
||||
@@ -161,7 +161,7 @@ export default class ModPack {
|
||||
if (json.name) { modPack.name = json.name; }
|
||||
if (json.description) { modPack.description = json.description; }
|
||||
if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map((m: ModRecord) => [m.name, m])); }
|
||||
if (json.settings) {
|
||||
modPack.settings = {
|
||||
"startup": new Map(Object.entries(json.settings["startup"])),
|
||||
@@ -218,7 +218,7 @@ export default class ModPack {
|
||||
let buf = Buffer.from(modPackString, "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed mod pack string: zlib inflate failed");
|
||||
@@ -291,7 +291,7 @@ export default class ModPack {
|
||||
const sizeBytes = uint32Bytes(entries.length);
|
||||
const itemBytes = entries.flatMap(([key, item]) => [
|
||||
immutableStringBytes(key),
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
propertyTreeBytes(item),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Note: Run compile_validator script after making changes to this file.
|
||||
import { TSchema, Type, Static } from "@sinclair/typebox";
|
||||
// eslint-disable-next-line node/no-missing-import
|
||||
|
||||
import messageValidate from "./message_validate"; // generated file
|
||||
import { StringEnum } from "./composites";
|
||||
|
||||
@@ -154,7 +154,7 @@ export class Message {
|
||||
static validate = messageValidate;
|
||||
|
||||
static fromJSON(json: any) {
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
if (json.type === "hello") { return MessageHello.fromJSON(json); }
|
||||
if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); }
|
||||
if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); }
|
||||
@@ -168,7 +168,7 @@ export class Message {
|
||||
if (json.type === "responseError") { return MessageResponseError.fromJSON(json); }
|
||||
if (json.type === "event") { return MessageEvent.fromJSON(json); }
|
||||
if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); }
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
||||
throw new Error(`Unrecognized message type ${json.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class InstanceDetails {
|
||||
public factorioVersion: string | undefined,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -58,6 +59,7 @@ export class InstanceDetails {
|
||||
]),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -69,6 +71,7 @@ export class InstanceDetails {
|
||||
json.status,
|
||||
json.factorioVersion,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +287,26 @@ export class InstanceStartRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceRestartRequest {
|
||||
declare ["constructor"]: typeof InstanceRestartRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.restart" as const;
|
||||
|
||||
constructor(
|
||||
public save?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"save": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.save);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveDetails {
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
|
||||
@@ -137,8 +137,8 @@ export abstract class Datastore<
|
||||
V extends DatastoreValue,
|
||||
U = V,
|
||||
> extends EventEmitter<{
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
private dirty = false;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -414,7 +414,7 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed map exchange string: zlib inflate failed");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable node/global-require */
|
||||
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
// eslint-disable-next-line node/no-extraneous-require
|
||||
|
||||
module.exports = require("fast-deep-equal/es6");
|
||||
} else {
|
||||
module.exports = require("util").isDeepStrictEqual;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
module.exports = process.env.APP_ENV === "browser" ? window.WebSocket : require("ws");
|
||||
@@ -271,7 +271,7 @@ export abstract class WebSocketBaseConnector<
|
||||
this._check("connected");
|
||||
if (Date.now() - this._lastHeartbeatMs! > 2000 * this._heartbeatInterval!) {
|
||||
logger.verbose("Connector | closing after heartbeat timed out");
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket!.close(ConnectionClosed.Timeout, "Heartbeat timeout");
|
||||
} else {
|
||||
@@ -544,7 +544,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector<We
|
||||
// Open WebSocket to controller
|
||||
logger.verbose(`Connector | connecting to ${url}`);
|
||||
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket = new WebSocket(url) as WebSocketClusterio;
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
instance.InstanceRestartRequest,
|
||||
instance.InstanceSaveDetailsListRequest,
|
||||
instance.InstanceSaveDetailsUpdatesEvent,
|
||||
instance.InstanceCreateSaveRequest,
|
||||
|
||||
@@ -519,7 +519,7 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
|
||||
}
|
||||
|
||||
export function handleUnhandledErrors() {
|
||||
/* eslint-disable node/no-process-exit */
|
||||
|
||||
process.on("uncaughtException", err => {
|
||||
logger.fatal(`Uncaught exception:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
@@ -528,7 +528,7 @@ export function handleUnhandledErrors() {
|
||||
logger.fatal(`Unhandled rejection:\n${reason.stack ? reason.stack : reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
/* eslint-enable node/no-process-exit */
|
||||
|
||||
}
|
||||
|
||||
// for testing only
|
||||
|
||||
@@ -307,6 +307,11 @@ definePermission({
|
||||
title: "Start instance",
|
||||
description: "Start instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.restart",
|
||||
title: "Restart instance",
|
||||
description: "Restart instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.load_scenario",
|
||||
title: "Load scenario",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
/**
|
||||
* Helpers for loading plugins in Node.js
|
||||
* @module lib/plugin_loader
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function handlePluginCommand(
|
||||
|
||||
let pluginInfo: { name: string };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
pluginInfo = require(pluginPath).plugin;
|
||||
} catch (err: any) {
|
||||
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
|
||||
|
||||
@@ -39,30 +39,45 @@ export class SubscriptionRequest {
|
||||
public eventName: string,
|
||||
public subscribe: boolean,
|
||||
public lastRequestTimeMs: number = 0,
|
||||
public filters: string | string[] | undefined = undefined,
|
||||
) {
|
||||
if (!Link._eventsByName.has(eventName)) {
|
||||
throw new Error(`Unregistered Event class ${eventName}`);
|
||||
}
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
static jsonSchema = Type.Union([
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
]),
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
Type.Union([Type.String(), Type.Array(Type.String())]),
|
||||
]),
|
||||
]);
|
||||
|
||||
toJSON() {
|
||||
if (this.filters !== undefined) {
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
|
||||
}
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
|
||||
return new this(...json);
|
||||
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
|
||||
return new this(eventName, subscribe, lastRequestTimeMs, filters);
|
||||
}
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
link: Link,
|
||||
dst: Address,
|
||||
/** Optional filters this subscriber is interested in. Undefined means all events */
|
||||
filters?: string[],
|
||||
}
|
||||
|
||||
type EventData = {
|
||||
@@ -77,6 +92,13 @@ type EventData = {
|
||||
export class SubscriptionController {
|
||||
_events = new Map<string, EventData>();
|
||||
|
||||
private static normalizeFilters(value?: string | string[]): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow clients to subscribe to an event by telling the subscription controller to accept them
|
||||
* Has an optional subscription update handler which is called when a client subscribes
|
||||
@@ -102,7 +124,7 @@ export class SubscriptionController {
|
||||
* Broadcast an event to all subscribers of that event
|
||||
* @param event - Event to broadcast.
|
||||
*/
|
||||
broadcast<T>(event: Event<T>) {
|
||||
broadcast<T>(event: Event<T>, filter?: string | string[]) {
|
||||
const entry = Link._eventsByClass.get(event.constructor);
|
||||
if (!entry) {
|
||||
throw new Error(`Unregistered Event class ${event.constructor.name}`);
|
||||
@@ -112,10 +134,19 @@ export class SubscriptionController {
|
||||
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
|
||||
}
|
||||
const toRemove: Subscriber[] = [];
|
||||
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
|
||||
|
||||
for (const subscriber of eventData.subscriptions) {
|
||||
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
|
||||
toRemove.push(subscriber);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
filtersToMatch === undefined
|
||||
|| subscriber.filters === undefined
|
||||
|| subscriber.filters.some(f => filtersToMatch.includes(f))
|
||||
) {
|
||||
subscriber.link.sendTo(subscriber.dst, event);
|
||||
}
|
||||
}
|
||||
@@ -164,13 +195,37 @@ export class SubscriptionController {
|
||||
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
|
||||
}
|
||||
if (event.subscribe === false) {
|
||||
const index = eventData.subscriptions.findLastIndex(subscriber => subscriber.dst.addressedTo(src));
|
||||
if (index >= 0) {
|
||||
eventData.subscriptions.splice(index, 1);
|
||||
// Unsubscribe logic
|
||||
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
if (!subscriber) {
|
||||
return false;
|
||||
}
|
||||
if (event.filters === undefined || subscriber.filters === undefined) {
|
||||
// Remove entire subscription for this address
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src));
|
||||
} else {
|
||||
const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters];
|
||||
subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f));
|
||||
if (subscriber.filters.length === 0) {
|
||||
// No filters left, remove subscription
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!eventData.subscriptions.some(subscriber => subscriber.dst.addressedTo(src))) {
|
||||
eventData.subscriptions.push({ link: link, dst: src });
|
||||
// Subscribe logic
|
||||
let subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
const newFilters = SubscriptionController.normalizeFilters(event.filters);
|
||||
if (!subscriber) {
|
||||
eventData.subscriptions.push({ link: link, dst: src, filters: newFilters });
|
||||
} else if (subscriber.filters === undefined || newFilters === undefined) {
|
||||
// Either was already subscribed to all or new request wants all
|
||||
subscriber.filters = undefined;
|
||||
} else {
|
||||
for (const f of newFilters) {
|
||||
if (!subscriber.filters.includes(f)) {
|
||||
subscriber.filters.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eventData.subscriptionUpdate) {
|
||||
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@swc/core": "^1.13.3",
|
||||
"antd": "^5.24.2",
|
||||
"assert": "^2.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
</>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
|
||||
function computeTreeData(
|
||||
control: Control,
|
||||
form: FormInstance<any>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import { Button, Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
|
||||
import { Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -19,60 +19,77 @@ import PageLayout from "./PageLayout";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import webUiPackage from "../../package.json";
|
||||
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type ControllerControlButtonProps = { canRestart?: boolean, restartRequired?: boolean };
|
||||
|
||||
function ControllerControlButton({ canRestart, restartRequired }: ControllerControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.controller.restart") && canRestart) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: (
|
||||
<Tooltip title={restartRequired ? "Restart Required" : null}>
|
||||
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
|
||||
</Tooltip>
|
||||
),
|
||||
onClick: () => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.controller.stop")) {
|
||||
actions.push({
|
||||
key: "stop",
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the controller?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.ControllerStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping controller"));
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
|
||||
export default function ControllerPage() {
|
||||
let account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [systems] = useSystems();
|
||||
const system = systems.get("controller");
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
|
||||
const canShowNpm = hasNpmButtonPermission(true);
|
||||
|
||||
return <PageLayout nav={[{ name: "Controller" }]}>
|
||||
<PageHeader
|
||||
title="Controller"
|
||||
extra={<Space>
|
||||
<ControllerControlButton canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.controller.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the controller?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.ControllerStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping controller"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.controller.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }}/>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
hasNpmButtonPermission(true)
|
||||
canShowNpm
|
||||
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
|
||||
}
|
||||
</Space>}
|
||||
|
||||
@@ -22,9 +22,64 @@ import {
|
||||
import { formatTimestamp } from "../util/time_format";
|
||||
import { useSystems } from "../model/system";
|
||||
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type HostControlButtonProps = { hostId: number, canRestart?: boolean, restartRequired?: boolean };
|
||||
|
||||
function HostControlButton({ hostId, canRestart, restartRequired }: HostControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.host.restart") && canRestart) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: (
|
||||
<Tooltip title={restartRequired ? "Restart Required" : null}>
|
||||
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
|
||||
</Tooltip>
|
||||
),
|
||||
onClick: () => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting host"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.host.stop")) {
|
||||
actions.push({
|
||||
key: "stop",
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the host?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping host"));
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
export default function HostViewPage() {
|
||||
let params = useParams();
|
||||
let hostId = Number(params.id);
|
||||
@@ -35,6 +90,7 @@ export default function HostViewPage() {
|
||||
const system = systems.get(hostId);
|
||||
const [host, synced] = useHost(hostId);
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
|
||||
const canShowNpm = hasNpmButtonPermission(false);
|
||||
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
|
||||
|
||||
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
|
||||
@@ -50,6 +106,7 @@ export default function HostViewPage() {
|
||||
}
|
||||
|
||||
let hostButtons = <Space>
|
||||
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.host.revoke_token")
|
||||
&& <Popconfirm
|
||||
@@ -68,49 +125,9 @@ export default function HostViewPage() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the host?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping host"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting host"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
hasNpmButtonPermission(false)
|
||||
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
}
|
||||
{canShowNpm && (
|
||||
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
)}
|
||||
</Space>;
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Popconfirm } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { useAccount } from "../model/account";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
type InstanceControlButtonProps = Omit<VariableDropdownButtonProps, "actions"> & {
|
||||
instance: lib.InstanceDetails;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const InstanceControlButtonPermissions = [
|
||||
"core.instance.start", "core.instance.restart", "core.instance.stop", "core.instance.kill",
|
||||
] as const;
|
||||
|
||||
export default function InstanceControlButton(props: InstanceControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [switching, setSwitching] = useState<boolean>(false);
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
const showStart = ["stopped", "stopping"].includes(instanceStatus);
|
||||
const showStop = ["running", "starting"].includes(instanceStatus);
|
||||
|
||||
function actionFinish() {
|
||||
setSwitching(false);
|
||||
if (props.onFinish) {
|
||||
props.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.instance.start") && showStart !== switching) {
|
||||
actions.push({
|
||||
key: "start",
|
||||
label: "Start",
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error starting instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
|
||||
} else if (account.hasPermission("core.instance.stop")) { // else if because it is stop or start
|
||||
actions.push({
|
||||
key: "stop",
|
||||
label: "Stop",
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceStopRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error stopping instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.instance.restart")) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: "Restart",
|
||||
disabled: instanceStatus !== "running",
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceRestartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error restarting instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
actions.push({
|
||||
key: "kill",
|
||||
danger: true,
|
||||
disabled: instanceStatus === "stopped",
|
||||
label: (<Popconfirm
|
||||
title={<>
|
||||
Killing this instance may leave you save and player data corrupted.
|
||||
Are you sure you want to kill the instance?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Kill"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error killing instance")
|
||||
).finally(actionFinish);
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
</Popconfirm>),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { ColumnsType } from "antd/es/table";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
@@ -98,13 +98,10 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
},
|
||||
];
|
||||
|
||||
if (account.hasAnyPermission("core.instance.start", "core.instance.stop")) {
|
||||
if (account.hasAnyPermission(...InstanceControlButtonPermissions)) {
|
||||
columns.push({
|
||||
key: "action",
|
||||
render: (_, instance) => <StartStopInstanceButton
|
||||
buttonProps={{ size: "small" }}
|
||||
instance={instance}
|
||||
/>,
|
||||
render: (_, instance) => <InstanceControlButton size="small" instance={instance} />,
|
||||
responsive: ["sm"],
|
||||
align: "right",
|
||||
width: 100,
|
||||
|
||||
@@ -15,9 +15,10 @@ import InstanceConfigTree from "./InstanceConfigTree";
|
||||
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
import AssignInstanceModal from "./AssignInstanceModal";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
|
||||
import LoadScenarioModal from "./LoadScenarioModal";
|
||||
import SavesList from "./SavesList";
|
||||
import OnlinePlayersList from "./OnlinePlayersList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useHost } from "../model/host";
|
||||
@@ -91,13 +92,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
label: "Extract players from save",
|
||||
});
|
||||
}
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
instanceButtonMenuItems.push({
|
||||
disabled: ["unknown", "unassigned", "stopped"].includes(instance.status!),
|
||||
key: "kill",
|
||||
label: "Kill process",
|
||||
});
|
||||
}
|
||||
if (account.hasPermission("core.instance.delete")) {
|
||||
if (instanceButtonMenuItems.length) {
|
||||
instanceButtonMenuItems.push({ type: "divider" });
|
||||
@@ -130,12 +124,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
new lib.InstanceExtractPlayersRequest(),
|
||||
).catch(notifyErrorHandler("Error extracting player data"));
|
||||
|
||||
} else if (key === "kill") {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(notifyErrorHandler("Error killing instance"));
|
||||
|
||||
} else if (key === "delete") {
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
@@ -155,8 +143,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
};
|
||||
return <Space>
|
||||
{
|
||||
account.hasAnyPermission("core.instance.start", "core.instance.stop")
|
||||
&& <StartStopInstanceButton instance={instance} />
|
||||
account.hasAnyPermission(...InstanceControlButtonPermissions)
|
||||
&& <InstanceControlButton instance={instance} />
|
||||
}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -173,7 +161,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
{account.hasAnyPermission(
|
||||
"core.instance.export_data",
|
||||
"core.instance.extract_players",
|
||||
"core.instance.kill",
|
||||
"core.instance.delete",
|
||||
)
|
||||
&& <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
|
||||
@@ -225,6 +212,8 @@ export default function InstanceViewPage() {
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, Select, Tooltip } from "antd";
|
||||
import { Button, Form, Input, Modal, Select, Space, Tooltip } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -82,11 +82,11 @@ export default function InstancesPage() {
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
title="Instances"
|
||||
extra={<>
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.instance.create") && <CreateInstanceButton instances={instances}/>}
|
||||
{account.hasPermission("core.instance.start")
|
||||
&& <Button onClick={e => instances.forEach(instance => {
|
||||
if (instance.status === "stopped") {
|
||||
if (instance.status === "stopped" && !instance.excludeFromStartAll) {
|
||||
control.sendTo(
|
||||
{ instanceId: instance.id },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
@@ -108,7 +108,7 @@ export default function InstancesPage() {
|
||||
}>
|
||||
Stop all
|
||||
</Button>}
|
||||
</>}
|
||||
</Space>}
|
||||
/>
|
||||
|
||||
<InstanceList instances={instances} />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
import { useUsers } from "../model/user";
|
||||
import UsersTable from "./UsersTable";
|
||||
|
||||
type OnlinePlayersListProps = {
|
||||
instanceId: number;
|
||||
};
|
||||
|
||||
export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) {
|
||||
const [users] = useUsers();
|
||||
|
||||
// Filter users to only show those online on this specific instance
|
||||
const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId));
|
||||
|
||||
if (onlineUsers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Online Players ({onlineUsers.length})</h4>
|
||||
<UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { ButtonProps } from "antd/es/button";
|
||||
|
||||
type StartStopInstanceButtonProps = {
|
||||
instance: lib.InstanceDetails;
|
||||
onFinish?: () => void;
|
||||
buttonProps?: ButtonProps;
|
||||
};
|
||||
export default function StartStopInstanceButton(props: StartStopInstanceButtonProps) {
|
||||
let control = useContext(ControlContext);
|
||||
let [switching, setSwitching] = useState<boolean>(false);
|
||||
|
||||
function onClick(event: React.MouseEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
setSwitching(true);
|
||||
let action;
|
||||
if (props.instance.status === "stopped") {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error starting instance")
|
||||
);
|
||||
|
||||
} else if (["starting", "running"].includes(props.instance.status)) {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStopRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error stopping instance")
|
||||
);
|
||||
|
||||
} else {
|
||||
setSwitching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
action.finally(() => {
|
||||
setSwitching(false);
|
||||
if (props.onFinish) {
|
||||
props.onFinish();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return <Button
|
||||
{...(props.buttonProps || {})}
|
||||
loading={switching}
|
||||
type="primary"
|
||||
disabled={!["starting", "running", "stopped"].includes(props.instance.status)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.instance.status === "stopped" ? "Start" : "Stop"}
|
||||
</Button>;
|
||||
}
|
||||
@@ -1,31 +1,24 @@
|
||||
import React, { useEffect, useContext, useState, useRef } from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
Button, Form, FormInstance, Input, InputRef, Modal,
|
||||
Radio, Space, Table, Tag, Upload, UploadProps,
|
||||
Button, Form, FormInstance, Input, Modal,
|
||||
Radio, Space, Upload, UploadProps,
|
||||
} from "antd";
|
||||
|
||||
import {
|
||||
InboxOutlined, SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import { saveJson } from "../util/save_file";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUsers } from "../model/user";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
import UsersTable from "./UsersTable";
|
||||
|
||||
function CreateUserButton() {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -321,7 +314,7 @@ function BulkUserActionButton() {
|
||||
const [formAction, setFormAction] = useState<string | undefined>(undefined);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
function onValuesChange({ action } : { action?: string }) {
|
||||
function onValuesChange({ action }: { action?: string }) {
|
||||
if (action) {
|
||||
setFormAction(action);
|
||||
}
|
||||
@@ -348,7 +341,7 @@ function BulkUserActionButton() {
|
||||
title="Bulk Actions"
|
||||
okText="Apply"
|
||||
open={open}
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
okButtonProps={{ disabled: formAction === undefined }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
@@ -364,9 +357,9 @@ function BulkUserActionButton() {
|
||||
? <Radio.Button value="restore">Restore</Radio.Button> : undefined}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{formAction === "import" ? <UserBulkActionImport {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "export" ? <UserBulkActionExport {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "restore" ? <UserBulkActionImport restore {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "import" ? <UserBulkActionImport {...{ setApplyAction, form }} /> : undefined}
|
||||
{formAction === "export" ? <UserBulkActionExport {...{ setApplyAction, form }} /> : undefined}
|
||||
{formAction === "restore" ? <UserBulkActionImport restore {...{ setApplyAction, form }} /> : undefined}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
@@ -374,101 +367,17 @@ function BulkUserActionButton() {
|
||||
|
||||
export default function UsersPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [users] = useUsers();
|
||||
const [roles] = useRoles();
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
extra={<>
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
|
||||
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
|
||||
? <BulkUserActionButton /> : undefined}
|
||||
</>}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, user) => <Space>
|
||||
{user.name}
|
||||
<span>
|
||||
{user.isAdmin && <Tag color="gold">Admin</Tag>}
|
||||
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
|
||||
{user.isBanned && <Tag color="red">Banned</Tag>}
|
||||
</span>
|
||||
</Space>,
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered) => <SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />,
|
||||
onFilter: (value, record) => record.name.toLowerCase().includes((value as string).toLowerCase()),
|
||||
filterDropdownProps: {
|
||||
onOpenChange: open => open && setTimeout(() => searchInput.current?.select(), 100),
|
||||
},
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
|
||||
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
ref={searchInput}
|
||||
placeholder={"Search username"}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys([e.target.value])}
|
||||
onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })}
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
render: (_, user) => (
|
||||
[...user.roleIds]
|
||||
.map(id => <Link key={id} to={`/roles/${id}/view`} onClick={e => e.stopPropagation()}>
|
||||
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
|
||||
</Link>)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Online time",
|
||||
key: "onlineTime",
|
||||
render: (_, user) => (user.playerStats?.onlineTimeMs
|
||||
? formatDuration(user.playerStats.onlineTimeMs) : null),
|
||||
sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) -
|
||||
(b.playerStats?.onlineTimeMs ?? 0),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "First seen",
|
||||
key: "firstSeen",
|
||||
render: (_, user) => formatFirstSeen(user),
|
||||
sorter: (a, b) => sortFirstSeen(a, b),
|
||||
},
|
||||
{
|
||||
title: "Last seen",
|
||||
key: "lastSeen",
|
||||
render: (_, user) => formatLastSeen(user),
|
||||
sorter: (a, b) => sortLastSeen(a, b),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
]}
|
||||
dataSource={[...users.values()]}
|
||||
pagination={{
|
||||
defaultPageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: total => `${total} Users`,
|
||||
}}
|
||||
rowKey={user => user.name}
|
||||
onRow={(user, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
</Space>}
|
||||
/>
|
||||
<UsersTable />
|
||||
<PluginExtra component="UsersPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Table, Tag, Space, Input, InputRef } from "antd";
|
||||
import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import {
|
||||
formatFirstSeen,
|
||||
formatLastSeen,
|
||||
sortFirstSeen,
|
||||
sortLastSeen,
|
||||
useUsers,
|
||||
} from "../model/user";
|
||||
import Link from "./Link";
|
||||
|
||||
export interface UsersTableProps {
|
||||
/**
|
||||
* Optional instance id.
|
||||
* – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown.
|
||||
* – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown.
|
||||
*/
|
||||
instanceId?: number;
|
||||
/** Show only players that are currently online, works with instanceId to show online of a particular instance */
|
||||
onlyOnline?: boolean;
|
||||
/** Ant Design pagination prop. Pass `false` to disable pagination. */
|
||||
pagination?: false | object;
|
||||
/** Ant Design size prop */
|
||||
size?: "small" | "middle" | "large";
|
||||
}
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
|
||||
const [roles] = useRoles();
|
||||
const navigate = useNavigate();
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
const [users] = useUsers();
|
||||
|
||||
const data = [...users.values()];
|
||||
|
||||
// Determine online predicate based on instanceId
|
||||
const isUserOnline = (user: lib.User) => {
|
||||
if (instanceId !== undefined) {
|
||||
return user.instances && user.instances.has(instanceId);
|
||||
}
|
||||
return user.instances && user.instances.size > 0;
|
||||
};
|
||||
|
||||
// Prepare filters for roles column
|
||||
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
|
||||
|
||||
const columns: any[] = [
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_: any, user: lib.User) => (
|
||||
<Space>
|
||||
{user.name}
|
||||
<span>
|
||||
{user.isAdmin && <Tag color="gold">Admin</Tag>}
|
||||
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
|
||||
{user.isBanned && <Tag color="red">Banned</Tag>}
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => record.name
|
||||
.toLowerCase()
|
||||
.includes((value as string).toLowerCase()),
|
||||
filterDropdownProps: {
|
||||
onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100),
|
||||
},
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: {
|
||||
selectedKeys: string[],
|
||||
setSelectedKeys: (keys: string[]) => void,
|
||||
confirm: FilterDropdownProps["confirm"],
|
||||
clearFilters: FilterDropdownProps["clearFilters"],
|
||||
}) => (
|
||||
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
ref={searchInput}
|
||||
placeholder={"Search username"}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys([e.target.value])}
|
||||
onSearch={() => confirm({ closeDropdown: false })}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
filters: roleFilters,
|
||||
filterMultiple: true,
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => record.roleIds.has(value as number),
|
||||
render: (_: any, user: lib.User) => (
|
||||
[...user.roleIds].map((id) => (
|
||||
<Link key={id} to={`/roles/${id}/view`} onClick={(e) => e.stopPropagation()}>
|
||||
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
|
||||
</Link>
|
||||
))
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Helper to get last seen timestamp
|
||||
function getLastSeenTimestamp(user: lib.User): number | undefined {
|
||||
const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats;
|
||||
if (!stats) { return undefined; }
|
||||
if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) {
|
||||
return stats.lastLeaveAt.getTime();
|
||||
}
|
||||
if (stats.lastJoinAt) {
|
||||
return stats.lastJoinAt.getTime();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (instanceId !== undefined) {
|
||||
columns.push(
|
||||
{
|
||||
title: "Play Time",
|
||||
key: "playTime",
|
||||
render: (_: any, user: lib.User) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Join Count",
|
||||
key: "joinCount",
|
||||
render: (_: any, user: lib.User) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.joinCount ?? 0;
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "First Seen",
|
||||
key: "firstSeen",
|
||||
render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId),
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0;
|
||||
const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0;
|
||||
return firstSeenA - firstSeenB;
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
columns.push(
|
||||
{
|
||||
title: "Online time",
|
||||
key: "onlineTime",
|
||||
render: (_: any, user: lib.User) => (user.playerStats?.onlineTimeMs
|
||||
? formatDuration(user.playerStats.onlineTimeMs)
|
||||
: null),
|
||||
// eslint-disable-next-line max-len
|
||||
sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "First seen",
|
||||
key: "firstSeen",
|
||||
render: (_: any, user: lib.User) => formatFirstSeen(user),
|
||||
sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b),
|
||||
},
|
||||
);
|
||||
}
|
||||
columns.push(
|
||||
{
|
||||
title: "Last seen",
|
||||
key: "lastSeen",
|
||||
filterMultiple: false,
|
||||
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
|
||||
filters: [
|
||||
{ text: "Online", value: "online" },
|
||||
{ text: "24h", value: "24h" },
|
||||
{ text: "7d", value: "7d" },
|
||||
{ text: "30d", value: "30d" },
|
||||
],
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => {
|
||||
const online = isUserOnline(record);
|
||||
if (value === "online") {
|
||||
return online;
|
||||
}
|
||||
// Online players should appear in all buckets
|
||||
if (online) {
|
||||
return true;
|
||||
}
|
||||
const ts = getLastSeenTimestamp(record);
|
||||
if (!ts) { return false; }
|
||||
const diff = Date.now() - ts;
|
||||
switch (value) {
|
||||
case "24h":
|
||||
return diff <= 24 * 60 * 60 * 1000;
|
||||
case "7d":
|
||||
return diff <= 7 * 24 * 60 * 60 * 1000;
|
||||
case "30d":
|
||||
return diff <= 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b, instanceId, instanceId),
|
||||
render: (_: any, user: lib.User) => formatLastSeen(user, instanceId),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
);
|
||||
|
||||
const defaultPagination = pagination === undefined
|
||||
? {
|
||||
defaultPageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}
|
||||
: pagination;
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey={(user) => user.name}
|
||||
pagination={defaultPagination}
|
||||
size={size}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Button, Dropdown } from "antd";
|
||||
|
||||
import { MenuItemType } from "antd/es/menu/interface";
|
||||
import { DropdownButtonProps } from "antd/es/dropdown";
|
||||
|
||||
export type VariableDropdownButtonProps = DropdownButtonProps & {
|
||||
actions: (MenuItemType & { onClick?: () => void })[],
|
||||
};
|
||||
|
||||
export default function VariableDropdownButton(props: VariableDropdownButtonProps) {
|
||||
const menuActions = [...props.actions];
|
||||
const mainAction = menuActions.shift();
|
||||
if (!mainAction) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const buttonProps: DropdownButtonProps = {
|
||||
...props,
|
||||
danger: mainAction.danger,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation();
|
||||
if (mainAction.onClick) {
|
||||
mainAction.onClick();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return menuActions.length === 0
|
||||
? <Button {...buttonProps}>
|
||||
{mainAction.label}
|
||||
</Button>
|
||||
: <Dropdown.Button
|
||||
{...buttonProps}
|
||||
// stopPropagation is needed to prevent propagation to table rows
|
||||
menu={{ items: menuActions, onClick: (e: any) => e.domEvent.stopPropagation() }}
|
||||
buttonsRender={([left, right]) => [
|
||||
left, React.cloneElement(right as any, { onClick: (e: any) => e.stopPropagation() }),
|
||||
]}
|
||||
>
|
||||
{mainAction.label}
|
||||
</Dropdown.Button>;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export { default as AssignInstanceModal } from "./components/AssignInstanceModal
|
||||
export { default as InstanceList } from "./components/InstanceList";
|
||||
export { default as InstanceRcon } from "./components/InstanceRcon";
|
||||
export { default as InstanceStatusTag, statusColors } from "./components/InstanceStatusTag";
|
||||
export { default as StartStopInstanceButton } from "./components/StartStopInstanceButton";
|
||||
export { default as InstanceControlButton, InstanceControlButtonPermissions } from "./components/InstanceControlButton";
|
||||
|
||||
export { default as PageHeader } from "./components/PageHeader";
|
||||
export { default as PageLayout } from "./components/PageLayout";
|
||||
|
||||
@@ -4,9 +4,10 @@ const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
|
||||
module.exports = (env = {}) => ({
|
||||
mode: env.production ? "production" : "development",
|
||||
devtool: env.production ? "source-map" : "eval-source-map",
|
||||
module.exports = (env = {}, argv = {}) => ({
|
||||
mode: argv.mode ?? "development",
|
||||
devtool: argv.mode === "production" ? "source-map" : "eval-source-map",
|
||||
cache: { type: "filesystem" },
|
||||
performance: {
|
||||
maxAssetSize: 2**21,
|
||||
maxEntrypointSize: 2**21,
|
||||
@@ -34,7 +35,7 @@ module.exports = (env = {}) => ({
|
||||
new webpack.NormalModuleReplacementPlugin(
|
||||
/@ant-design\/icons\/[A-Z]/,
|
||||
resource => {
|
||||
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
|
||||
resource.request = resource.request.replace("@ant-design\/icons", "$&/es/icons");
|
||||
}
|
||||
),
|
||||
],
|
||||
@@ -60,7 +61,7 @@ module.exports = (env = {}) => ({
|
||||
{
|
||||
loader: require.resolve("css-loader"),
|
||||
options: {
|
||||
sourceMap: !env.production,
|
||||
sourceMap: argv.mode !== "production",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -138,9 +139,7 @@ module.exports = (env = {}) => ({
|
||||
moduleIds: "deterministic",
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
// TerserPlugin.swcMinify spams log with "false" after bump to @swc/core@1.11.1
|
||||
// https://github.com/clusterio/clusterio/issues/737
|
||||
// minify: TerserPlugin.swcMinify,
|
||||
minify: TerserPlugin.swcMinify,
|
||||
terserOptions: {
|
||||
compress: {
|
||||
keep_classnames: true,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -61,13 +61,13 @@ export function dose(
|
||||
let inp1 = [numreq, instore_adj, store_last_tick / numreq, dole, carry]; // 0..4
|
||||
let inp2 = [
|
||||
prev_req / numreq,
|
||||
numreq / numreq_total_adj,
|
||||
store_last_tick / numreq_total_adj,
|
||||
prev_req/instore_adj,
|
||||
b,
|
||||
numreq_total_adj,
|
||||
store_last_tick,
|
||||
debt,
|
||||
numreq / numreq_total_adj,
|
||||
store_last_tick / numreq_total_adj,
|
||||
prev_req/instore_adj,
|
||||
b,
|
||||
numreq_total_adj,
|
||||
store_last_tick,
|
||||
debt,
|
||||
]; // 5..12
|
||||
let inputs = inp1.concat(inp2);
|
||||
let outputs = [0, 0, 0, 0];
|
||||
|
||||
@@ -14,6 +14,8 @@ type IpcItems = [name: string, count: number, quality: string][];
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
pendingTasks!: Set<any>;
|
||||
pingId?: ReturnType<typeof setTimeout>;
|
||||
timeUpdateId?: ReturnType<typeof setInterval>;
|
||||
cachedInventoryItems: Item[] = [];
|
||||
|
||||
unexpectedError(err: Error) {
|
||||
this.logger.error(`Unexpected error:\n${err.stack}`);
|
||||
@@ -54,18 +56,37 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}, 5000);
|
||||
|
||||
let items = await this.instance.sendTo("controller", new GetStorageRequest());
|
||||
// TODO Diff with dump of invdata produce minimal command to sync
|
||||
let itemsJson = lib.escapeString(JSON.stringify(items));
|
||||
await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}", true)`, true);
|
||||
|
||||
// Cache items for periodic time updates
|
||||
this.cachedInventoryItems = items;
|
||||
|
||||
// Ensure a payload with updated time is sent every second
|
||||
this.timeUpdateId = setInterval(() => {
|
||||
if (this.instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadItems = [
|
||||
...this.cachedInventoryItems,
|
||||
new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"),
|
||||
];
|
||||
const payloadJson = lib.escapeString(JSON.stringify(payloadItems));
|
||||
const task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true)
|
||||
.catch(err => this.unexpectedError(err));
|
||||
this.pendingTasks.add(task);
|
||||
task.finally(() => { this.pendingTasks.delete(task); });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
clearInterval(this.pingId);
|
||||
clearInterval(this.timeUpdateId);
|
||||
await Promise.all(this.pendingTasks);
|
||||
}
|
||||
|
||||
onExit() {
|
||||
clearInterval(this.pingId);
|
||||
clearInterval(this.timeUpdateId);
|
||||
}
|
||||
|
||||
// provide items --------------------------------------------------------------
|
||||
@@ -114,14 +135,7 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
if (this.instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
let items = event.items;
|
||||
|
||||
// XXX this should be moved to instance/clusterio api
|
||||
items.push(new Item("signal-unixtime", Math.floor(Date.now()/1000), "normal"));
|
||||
|
||||
let itemsJson = lib.escapeString(JSON.stringify(items));
|
||||
let task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
|
||||
this.pendingTasks.add(task);
|
||||
await task.finally(() => { this.pendingTasks.delete(task); });
|
||||
// Cache latest inventory from controller
|
||||
this.cachedInventoryItems = event.items;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
|
||||
@@ -4,3 +4,5 @@ packages:
|
||||
- "external_plugins/*"
|
||||
- "external_plugins/*/*"
|
||||
- "test/"
|
||||
|
||||
linkWorkspacePackages: true
|
||||
@@ -11,7 +11,7 @@ async function exec(file, args) {
|
||||
const { stdout, stderr } = await execFile(
|
||||
file,
|
||||
args.map(escapeArg),
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
{ shell: true, cwd: __dirname, env: { ...process.env, pct: "%"} }
|
||||
);
|
||||
return { stdout: JSON.parse(stdout), stderr };
|
||||
|
||||
@@ -529,56 +529,92 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance start", function() {
|
||||
it("should not hang if factorio version does not exist", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
await execCtl("instance config set 44 factorio.version 0.1.2");
|
||||
await assert.rejects(execCtl("instance start test"));
|
||||
for (let cmd of ["start", "restart"]) {
|
||||
describe(`instance ${cmd}`, function() {
|
||||
async function prepareToStart() {
|
||||
if (cmd === "restart") {
|
||||
await execCtl("instance start test");
|
||||
}
|
||||
}
|
||||
after(async function() {
|
||||
// It is expected that after these tests the instance is running, I dislike this dependency
|
||||
// So this is here to make sure the other tests can continue any of these ones fail
|
||||
const instances = await getInstances();
|
||||
if (instances.get(44).status !== "running") {
|
||||
await execCtl("instance start test");
|
||||
}
|
||||
});
|
||||
it("should not hang if factorio version does not exist", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
await execCtl("instance config set 44 factorio.version 0.1.2");
|
||||
await assert.rejects(execCtl(`instance ${cmd} test`));
|
||||
|
||||
} finally {
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
} finally {
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
}
|
||||
});
|
||||
it("should not leave the instance in the stopping state if it fails", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
await execCtl("instance config set 44 factorio.game_port 100000");
|
||||
await assert.rejects(execCtl(`instance ${cmd} test`));
|
||||
await checkInstanceStatus(44, "stopped");
|
||||
|
||||
} finally {
|
||||
await execCtl("instance config set 44 factorio.game_port");
|
||||
}
|
||||
});
|
||||
it("starts the given save", async function() {
|
||||
slowTest(this);
|
||||
await prepareToStart();
|
||||
await execCtl(`instance ${cmd} test --save world.zip`);
|
||||
await checkInstanceStatus(44, "running");
|
||||
});
|
||||
it("allows having a separate console log", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance stop 44");
|
||||
await execCtl("instance config set 44 factorio.console_logging true");
|
||||
await prepareToStart();
|
||||
await execCtl(`instance ${cmd} test`);
|
||||
await checkInstanceStatus(44, "running");
|
||||
const checkMessage = `check${Date.now()}`;
|
||||
await sendRcon(44, checkMessage);
|
||||
const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log"));
|
||||
assert(consoleLog.includes(checkMessage));
|
||||
await execCtl("instance config set 44 factorio.console_logging false");
|
||||
});
|
||||
it("copies the save if an autosave is the target", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance stop 44");
|
||||
let savesDir = path.join("temp", "test", "instances", "test", "saves");
|
||||
await fs.copy(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip"));
|
||||
await prepareToStart();
|
||||
await execCtl(`instance ${cmd} test`);
|
||||
let saves = await getControl().sendTo({ instanceId: 44 }, new lib.InstanceSaveDetailsListRequest());
|
||||
let running = saves.find(s => s.loaded);
|
||||
assert(running.name !== "_autosave1.zip");
|
||||
});
|
||||
if (cmd === "restart") {
|
||||
it("fails if the server is not running", async function() {
|
||||
await execCtl("instance stop test");
|
||||
await checkInstanceStatus(44, "stopped");
|
||||
await assert.rejects(
|
||||
execCtl("instance restart test"),
|
||||
/Instance is not running/,
|
||||
);
|
||||
await execCtl("instance start test");
|
||||
});
|
||||
} else {
|
||||
it("fails if the server is running", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("instance start test"),
|
||||
/Instance is already running./,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
it("should not leave the instance in the stopping state if it fails", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
await execCtl("instance config set 44 factorio.game_port 100000");
|
||||
await assert.rejects(execCtl("instance start test"));
|
||||
await checkInstanceStatus(44, "stopped");
|
||||
|
||||
} finally {
|
||||
await execCtl("instance config set 44 factorio.game_port");
|
||||
}
|
||||
});
|
||||
it("starts the given save", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance start test --save world.zip");
|
||||
await checkInstanceStatus(44, "running");
|
||||
});
|
||||
it("allows having a separate console log", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance stop 44");
|
||||
await execCtl("instance config set 44 factorio.console_logging true");
|
||||
await execCtl("instance start test");
|
||||
await checkInstanceStatus(44, "running");
|
||||
const checkMessage = `check${Date.now()}`;
|
||||
await sendRcon(44, checkMessage);
|
||||
const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log"));
|
||||
assert(consoleLog.includes(checkMessage));
|
||||
await execCtl("instance config set 44 factorio.console_logging false");
|
||||
});
|
||||
it("copies the save if an autosave is the target", async function() {
|
||||
slowTest(this);
|
||||
await execCtl("instance stop 44");
|
||||
let savesDir = path.join("temp", "test", "instances", "test", "saves");
|
||||
await fs.copy(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip"));
|
||||
await execCtl("instance start test");
|
||||
let saves = await getControl().sendTo({ instanceId: 44 }, new lib.InstanceSaveDetailsListRequest());
|
||||
let running = saves.find(s => s.loaded);
|
||||
assert(running.name !== "_autosave1.zip");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("instance send-rcon", function() {
|
||||
this.afterAll(async function() {
|
||||
@@ -1019,13 +1055,13 @@ describe("Integration of Clusterio", function() {
|
||||
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");
|
||||
await execCtlProcess("instance create spam --id 66");
|
||||
await execCtlProcess("instance assign spam 4");
|
||||
await execCtlProcess("instance create unassign --id 77");
|
||||
});
|
||||
after(async function() {
|
||||
try {
|
||||
await execCtl("instance delete spam");
|
||||
await execCtlProcess("instance delete spam");
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
@@ -1039,14 +1075,15 @@ describe("Integration of Clusterio", function() {
|
||||
: path.join("temp", "test", "instances", "spam", "saves")
|
||||
;
|
||||
describe(remote ? "remote" : "local", function() {
|
||||
this.timeout(10000);
|
||||
if (remote) {
|
||||
let hostProcess;
|
||||
before(async function() {
|
||||
slowTest(this);
|
||||
|
||||
hostProcess = await startAltHost();
|
||||
await execCtl("instance create save-test --id 88");
|
||||
await execCtl("instance assign save-test 5");
|
||||
await execCtlProcess("instance create save-test --id 88");
|
||||
await execCtlProcess("instance assign save-test 5");
|
||||
});
|
||||
after(async function() {
|
||||
await stopAltHost(hostProcess);
|
||||
@@ -1054,14 +1091,14 @@ describe("Integration of Clusterio", function() {
|
||||
}
|
||||
it("should transfers a save", async function() {
|
||||
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
||||
await execCtl(`instance save transfer ${pri} transfer.zip ${sec}`);
|
||||
await execCtlProcess(`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`);
|
||||
await execCtlProcess(`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");
|
||||
@@ -1069,7 +1106,7 @@ describe("Integration of Clusterio", function() {
|
||||
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}`);
|
||||
await execCtlProcess(`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");
|
||||
@@ -1077,7 +1114,7 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
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`);
|
||||
await execCtlProcess(`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");
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestHostConnector extends lib.WebSocketClientConnector {
|
||||
// Mark that this test takes a lot of time, or depeneds on a test
|
||||
// that takes a lot of time.
|
||||
function slowTest(test) {
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.FAST_TEST) {
|
||||
test.skip();
|
||||
}
|
||||
@@ -221,7 +221,7 @@ function getControl() {
|
||||
}
|
||||
|
||||
function spawn(name, cmd, waitFor) {
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
const silent = process.env.SILENT_TEST;
|
||||
const bootstrap = !controllerProcess || !hostProcess;
|
||||
function log(...args) {
|
||||
@@ -264,7 +264,7 @@ async function spawnNode(name, cmd, waitFor) {
|
||||
before(async function() {
|
||||
this.timeout(40000);
|
||||
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
const silent = process.env.SILENT_TEST;
|
||||
if (silent) {
|
||||
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
|
||||
@@ -278,7 +278,7 @@ before(async function() {
|
||||
}));
|
||||
|
||||
// If fast test is enabled then output that it is
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.FAST_TEST) {
|
||||
console.log("FAST_TEST is present in env, slow tests will be skipped.");
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ describe("Clusterio Instance", function() {
|
||||
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instName} 4`);
|
||||
// Exclude main test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
|
||||
|
||||
for (const plugin of [
|
||||
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "inventory_sync",
|
||||
@@ -47,6 +49,8 @@ describe("Clusterio Instance", function() {
|
||||
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instAltName} 4`);
|
||||
// Exclude alt test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
|
||||
await execCtl(`instance start ${instAltName}`);
|
||||
await sendRcon(instAltId, "/sc disable achievements");
|
||||
});
|
||||
@@ -162,3 +166,148 @@ describe("Clusterio Instance", function() {
|
||||
});
|
||||
}
|
||||
});
|
||||
describe("start-all and stop-all commands", function() {
|
||||
// Test instance IDs for start-all/stop-all tests
|
||||
const testInstId1 = 50;
|
||||
const testInstId2 = 51;
|
||||
const testInstId3 = 52;
|
||||
const testInstName1 = "StartAllTest1";
|
||||
const testInstName2 = "StartAllTest2";
|
||||
const testInstName3 = "StartAllTest3";
|
||||
|
||||
before(async function() {
|
||||
this.timeout(30000);
|
||||
|
||||
// Create test instances for start-all/stop-all testing
|
||||
await execCtl(`instance create ${testInstName1} --id ${testInstId1}`);
|
||||
await execCtl(`instance create ${testInstName2} --id ${testInstId2}`);
|
||||
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
|
||||
|
||||
// Configure instances
|
||||
await execCtl(`instance assign ${testInstName1} 4`);
|
||||
await execCtl(`instance assign ${testInstName2} 4`);
|
||||
await execCtl(`instance assign ${testInstName3} 4`);
|
||||
|
||||
// Exclude testInstName3 from start-all by default
|
||||
await execCtl(`instance config set ${testInstName3} instance.exclude_from_start_all true`);
|
||||
|
||||
// Disable plugins to speed up testing
|
||||
for (const instanceName of [testInstName1, testInstName2, testInstName3]) {
|
||||
for (const plugin of [
|
||||
"global_chat", "research_sync", "statistics_exporter",
|
||||
"subspace_storage", "player_auth", "inventory_sync",
|
||||
]) {
|
||||
await execCtl(`instance config set ${instanceName} ${plugin}.load_plugin false`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
this.timeout(30000);
|
||||
// Clean up test instances - ensure they are stopped first
|
||||
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
|
||||
|
||||
await execCtl(`instance delete ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance delete ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance delete ${testInstName3}`).catch(() => {});
|
||||
});
|
||||
|
||||
async function getInstanceStatus(id) {
|
||||
const instances = await getControl().send(new lib.InstanceDetailsListRequest());
|
||||
const instanceMap = new Map(instances.map(instance => [instance.id, instance]));
|
||||
return instanceMap.get(id)?.status;
|
||||
}
|
||||
|
||||
describe("start-all command", function() {
|
||||
beforeEach(async function() {
|
||||
this.timeout(15000);
|
||||
// Ensure all test instances are stopped before each test
|
||||
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
this.timeout(15000);
|
||||
// Clean up: stop any instances that were started during tests
|
||||
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
|
||||
});
|
||||
|
||||
it("starts only stopped instances that are not excluded by default", async function() {
|
||||
this.timeout(20000);
|
||||
// Start one instance to verify it's not started again
|
||||
await execCtl(`instance start ${testInstName2}`);
|
||||
|
||||
// Verify initial state
|
||||
assert.equal(await getInstanceStatus(testInstId1), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
|
||||
// Run start-all without force
|
||||
await execCtl("instance start-all");
|
||||
|
||||
// Verify results - only testInstName1 should be started
|
||||
// testInstName2 was already running, testInstName3 is excluded
|
||||
assert.equal(await getInstanceStatus(testInstId1), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
});
|
||||
|
||||
it("starts excluded instances when --force flag is provided", async function() {
|
||||
this.timeout(20000);
|
||||
// Verify initial state - all stopped
|
||||
assert.equal(await getInstanceStatus(testInstId1), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
|
||||
// Run start-all with force
|
||||
await execCtl("instance start-all --force");
|
||||
|
||||
// Verify results - all instances should be started including excluded one
|
||||
assert.equal(await getInstanceStatus(testInstId1), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop-all command", function() {
|
||||
beforeEach(async function() {
|
||||
this.timeout(15000);
|
||||
// Ensure a known state before each test
|
||||
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
this.timeout(15000);
|
||||
// Clean up: stop any instances that were started during tests
|
||||
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
|
||||
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
|
||||
});
|
||||
|
||||
it("stops only running or starting instances", async function() {
|
||||
this.timeout(20000);
|
||||
// Start some instances to test stop-all
|
||||
await execCtl(`instance start ${testInstName1}`);
|
||||
await execCtl(`instance start ${testInstName3}`);
|
||||
|
||||
// Verify initial state
|
||||
assert.equal(await getInstanceStatus(testInstId1), "running");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "running");
|
||||
|
||||
await execCtl("instance stop-all");
|
||||
|
||||
// Verify results - running instances should be stopped, stopped ones unchanged
|
||||
assert.equal(await getInstanceStatus(testInstId1), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId2), "stopped");
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -816,24 +816,24 @@ describe("lib/ModStore", function () {
|
||||
assert.equal(
|
||||
typeof firstMod.latest_release.version,
|
||||
"string",
|
||||
"latest_release version should be string" // eslint-disable-line max-len
|
||||
"latest_release version should be string"
|
||||
);
|
||||
assert.equal(
|
||||
typeof firstMod.latest_release.sha1,
|
||||
"string",
|
||||
"latest_release should have a sha1 string" // eslint-disable-line max-len
|
||||
"latest_release should have a sha1 string"
|
||||
);
|
||||
assert.ok(firstMod.latest_release.info_json, "latest_release should have info_json");
|
||||
assert.equal(
|
||||
typeof firstMod.latest_release.info_json.factorio_version,
|
||||
"string",
|
||||
"info_json should have factorio_version string" // eslint-disable-line max-len
|
||||
"info_json should have factorio_version string"
|
||||
);
|
||||
// Check for an expected field from the user example
|
||||
assert.equal(
|
||||
typeof firstMod.downloads_count,
|
||||
"number",
|
||||
"First mod should have downloads_count number" // eslint-disable-line max-len
|
||||
"First mod should have downloads_count number"
|
||||
);
|
||||
} else {
|
||||
// This should not happen for v1.1 but handles edge cases
|
||||
|
||||
@@ -303,7 +303,7 @@ describe("lib/data/ModPack", function() {
|
||||
Buffer.from(str),
|
||||
];
|
||||
}
|
||||
/* eslint-disable indent */
|
||||
/* eslint-disable @stylistic/indent */
|
||||
assert.deepEqual(
|
||||
pack.toModSettingsDat(),
|
||||
Buffer.concat([
|
||||
@@ -373,7 +373,6 @@ describe("lib/data/ModPack", function() {
|
||||
new Uint8Array(Float64Array.from([1]).buffer),
|
||||
])
|
||||
);
|
||||
/* eslint-enable indent */
|
||||
});
|
||||
});
|
||||
describe("defaultModPacks", function() {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("lib/plugin_loader", function() {
|
||||
it("should reject on broken plugin", async function() {
|
||||
let brokenMessage;
|
||||
try {
|
||||
// eslint-disable-next-line node/global-require
|
||||
|
||||
require(path.resolve(brokenPlugin));
|
||||
} catch (err) {
|
||||
brokenMessage = err.message;
|
||||
|
||||
@@ -88,6 +88,22 @@ describe("lib/subscriptions", function() {
|
||||
assert.deepEqual(reconstructed, request);
|
||||
});
|
||||
|
||||
it("should be round trip json serialisable with a single filter", function() {
|
||||
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true, 456, "foo");
|
||||
const json = JSON.stringify(request);
|
||||
assert.equal(typeof json, "string");
|
||||
const reconstructed = lib.SubscriptionRequest.fromJSON(JSON.parse(json));
|
||||
assert.deepEqual(reconstructed, request);
|
||||
});
|
||||
|
||||
it("should be round trip json serialisable with multiple filters", function() {
|
||||
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true, 789, ["foo", "bar"]);
|
||||
const json = JSON.stringify(request);
|
||||
assert.equal(typeof json, "string");
|
||||
const reconstructed = lib.SubscriptionRequest.fromJSON(JSON.parse(json));
|
||||
assert.deepEqual(reconstructed, request);
|
||||
});
|
||||
|
||||
it("should throw when given an unregistered event", function() {
|
||||
assert.throws(
|
||||
() => new lib.SubscriptionRequest(UnregisteredEvent.name, true),
|
||||
@@ -314,6 +330,171 @@ describe("lib/subscriptions", function() {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assertNoEvent(0);
|
||||
});
|
||||
|
||||
describe("filters()", function() {
|
||||
it("should notify only subscribers matching the broadcast filter", async function() {
|
||||
const reqFoo = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const reqBar = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "bar");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(1), reqBar, connectorSetupDate[1].dst, connectorSetupDate[1].src
|
||||
);
|
||||
|
||||
const before0 = getLink(0).connector.sentMessages.length;
|
||||
const before1 = getLink(1).connector.sentMessages.length;
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "foo");
|
||||
await onceConnectorSend(0);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 1);
|
||||
assert.equal(getLink(1).connector.sentMessages.length, before1);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "bar");
|
||||
await onceConnectorSend(1);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 1);
|
||||
assert.equal(getLink(1).connector.sentMessages.length, before1 + 1);
|
||||
|
||||
const beforeBaz0 = getLink(0).connector.sentMessages.length;
|
||||
const beforeBaz1 = getLink(1).connector.sentMessages.length;
|
||||
subscriptions.broadcast(new RegisteredEvent(), "baz");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(getLink(0).connector.sentMessages.length, beforeBaz0);
|
||||
assert.equal(getLink(1).connector.sentMessages.length, beforeBaz1);
|
||||
});
|
||||
|
||||
it("should notify subscribers with no filters for any broadcast filter", async function() {
|
||||
const reqAll = new lib.SubscriptionRequest(RegisteredEvent.name, true);
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqAll, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const reqBaz = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "baz");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(1), reqBaz, connectorSetupDate[1].dst, connectorSetupDate[1].src
|
||||
);
|
||||
|
||||
const before0 = getLink(0).connector.sentMessages.length;
|
||||
const before1 = getLink(1).connector.sentMessages.length;
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "foo");
|
||||
await onceConnectorSend(0);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 1);
|
||||
assert.equal(getLink(1).connector.sentMessages.length, before1);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "baz");
|
||||
await Promise.all([onceConnectorSend(0), onceConnectorSend(1)]);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 2);
|
||||
assert.equal(getLink(1).connector.sentMessages.length, before1 + 1);
|
||||
});
|
||||
|
||||
it("should merge multiple filter subscriptions for the same subscriber", async function() {
|
||||
const reqFoo = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "alpha");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const reqBar = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "beta");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqBar, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
const before0 = getLink(0).connector.sentMessages.length;
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "alpha");
|
||||
await onceConnectorSend(0);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 1);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "beta");
|
||||
await onceConnectorSend(0);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 2);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "gamma");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 2);
|
||||
});
|
||||
|
||||
it("should reset to all when subscribing with no filters after having filters", async function() {
|
||||
const reqFoo = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const reqAll = new lib.SubscriptionRequest(RegisteredEvent.name, true);
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqAll, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "baz");
|
||||
await onceConnectorSend(0);
|
||||
});
|
||||
|
||||
it("should remove only specified filters on unsubscribe with filters", async function() {
|
||||
const reqFilters = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, ["foo", "bar"]);
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFilters, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
const unsubFoo = new lib.SubscriptionRequest(RegisteredEvent.name, false, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), unsubFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
const before0 = getLink(0).connector.sentMessages.length;
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "foo");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "bar");
|
||||
await onceConnectorSend(0);
|
||||
assert.equal(getLink(0).connector.sentMessages.length, before0 + 1);
|
||||
});
|
||||
|
||||
it("should remove entire subscription when last filter is unsubscribed", async function() {
|
||||
const reqFoo = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
const unsubFoo = new lib.SubscriptionRequest(RegisteredEvent.name, false, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), unsubFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), "foo");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assertNoEvent(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it("should remove entire subscription when unsubscribing any filter from an all-subscription", async function() {
|
||||
const reqAll = new lib.SubscriptionRequest(RegisteredEvent.name, true);
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqAll, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const unsubAny = new lib.SubscriptionRequest(RegisteredEvent.name, false, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), unsubAny, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent());
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assertNoEvent(0);
|
||||
});
|
||||
|
||||
it("should match any of multiple broadcast filters", async function() {
|
||||
const reqFoo = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "foo");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(0), reqFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src
|
||||
);
|
||||
const reqBar = new lib.SubscriptionRequest(RegisteredEvent.name, true, 0, "bar");
|
||||
await subscriptions.handleRequest(
|
||||
getLink(1), reqBar, connectorSetupDate[1].dst, connectorSetupDate[1].src
|
||||
);
|
||||
|
||||
subscriptions.broadcast(new RegisteredEvent(), ["bar", "baz"]);
|
||||
await onceConnectorSend(1);
|
||||
assertNoEvent(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const lib = require("@clusterio/lib");
|
||||
|
||||
const { Host } = require("@clusterio/host");
|
||||
|
||||
describe("messages/controller", function() {
|
||||
describe("messages/host", function() {
|
||||
/** @type {Host} */
|
||||
let host;
|
||||
|
||||
|
||||
Reference in new issue
Block a user