Compare commits

...
Author SHA1 Message Date
Hornwitser 93fa12200f Fix installer asking for remoteNpm even when given 2025-10-18 18:43:55 +02:00
Hornwitser 35f769ad25 Release hotfix of installer 2025-10-18 18:27:41 +02:00
Cooldude2606 681e78967f Webpack Build Improvements (#791)
* Bump @swc/core to 1.13.3

* Switch to using --mode production rather than --env

Closes #792

* Add cache
2025-08-17 15:54:37 +01:00
Daniel d2bd1382ba Filtered subscriptions (#788)
* Add filtered subscriptions

* Add tests for subscription filters

* Stricter assertions

* Un-nest if statements
2025-08-13 11:55:49 +01:00
Daniel c0ec9a3890 Send inventory on interval instead of event to ensure time gets updated (#790)
This means inventory updates in low-volume clusters might be delayed up to 1 second more, but ensures we always have the unix time signal available, even in clusters that don't have anything in the inventory yet. Resolves #610
2025-08-10 22:25:32 +01:00
Daniel 4221b96431 Fix rendered more hooks error when refreshing hosts page (#789)
* Fix rendered more hooks error when refreshing hosts page

* Fix conditional render on controllerPage

Doesn't cause any issues that I know off right now, but changes to antd internal code could change that so it feels better to be sure.
2025-08-10 22:17:14 +01:00
Daniel 3ddbe2ddb3 Merge pull request #787 from Cooldude2606/fix/plugin-template
Plugin templates no longer depend on a dev install of Clusterio
2025-08-10 21:20:24 +02:00
Cooldude2606 7ceb786143 Add onExit to instance template 2025-08-08 21:41:49 +01:00
Cooldude2606 174d9d5b07 Bug Fixes 2025-08-07 19:14:23 +01:00
Cooldude2606 a1118d08dd Add ^ to clusterio version 2025-08-07 17:44:55 +01:00
Cooldude2606 379d2575a0 Improve comments 2025-08-07 17:42:52 +01:00
Cooldude2606 e30dcc6fff Fix tsconfigs 2025-08-07 17:32:54 +01:00
Cooldude2606 281a5ed97e Update common files 2025-08-07 13:17:41 +01:00
Daniel 6004f861fe Merge pull request #783 from clusterio/cursor/configure-master-listen-address-53c7
Exclude instance from "start all" function
2025-08-04 20:50:47 +02:00
Danielv123 174c798d4b Run integration test instead of unit test 2025-08-04 18:46:13 +00:00
Danielv123 14b8b5ac61 ctl instance stop-all test 2025-08-04 18:28:25 +00:00
Danielv123 89dec572bb Add test for ctl instance start-all 2025-08-04 18:10:48 +00:00
Danielv123 f662880c8d Fix zlib update breaking our types 2025-08-04 17:36:18 +00:00
Danielv123 90cd2770b3 Add start-all command to cli 2025-08-04 17:36:18 +00:00
Cursor Agentanddanielv 69240e7917 Add option to exclude instances from bulk start operation
Co-authored-by: danielv <danielv@danielv.no>
2025-08-04 17:36:18 +00:00
Daniel e1d9c1e414 Merge pull request #785 from clusterio/dependency-updates
Update dependencies
2025-08-04 19:03:49 +02:00
Danielv123 376eaeba52 Revert save writing delay during tests 2025-08-04 16:28:35 +00:00
Danielv123 80983662aa Wait for promise to resolve before returning 2025-08-04 16:14:07 +00:00
Danielv123 9840f22688 Wait for savefile to be moved 2025-08-04 15:36:27 +00:00
Danielv123 b9e2acaaa5 eslint needs to be ecmascript module 2025-08-01 23:29:41 +00:00
Danielv123 7ac8cd3781 Fix mocha arguments leaking into yargs parser 2025-08-01 23:26:47 +00:00
Danielv123 7e2f10e842 Fix linting issues with new rules 2025-08-01 22:26:25 +00:00
Danielv123 256407b9fd Update eslint config file for eslint 9 2025-08-01 22:26:07 +00:00
Danielv123 7979dfaa16 Fix missing import 2025-08-01 21:25:13 +00:00
Danielv123 10a87d47da Convert ArrayBufferLike to Buffer 2025-08-01 21:19:58 +00:00
Danielv123 59709280da Update dependencies in package.json 2025-08-01 21:12:46 +00:00
Daniel 81b656f0ab Merge pull request #782 from clusterio/cursor/implement-factorio-1-1-support-a0fd
Online player list on instance page
2025-08-01 21:59:34 +02:00
Daniel f966c5b965 Merge pull request #780 from Cooldude2606/feature/instance-restart
Add instance restart
2025-07-31 00:18:41 +02:00
Danielv123 56a26848e6 Paginate users on instance page, better search ux 2025-07-31 00:13:02 +02:00
Cooldude2606 28f21bfb70 Fix mutating actions prop 2025-07-30 16:16:29 +01:00
Danielv123 7b4724845f Fix linter error 2025-07-24 22:36:24 +02:00
Danielv123 2053423864 Add proper filters 2025-07-23 15:00:51 +02:00
Danielv123 e7519975ec Use same component for all user tables 2025-07-23 14:21:33 +02:00
Cursor Agentanddanielv 7b7389526b Add online players list component to instance view page
Co-authored-by: danielv <danielv@danielv.no>
2025-07-21 17:41:13 +00:00
Cooldude2606 a464b61d39 Remove redundant status check 2025-07-16 01:41:23 +01:00
Cooldude2606 88ca4563a8 Add tests 2025-07-16 01:25:27 +01:00
Cooldude2606 449bd68fab Add instance restart to ctl 2025-07-16 00:06:44 +01:00
Cooldude2606 59256e77d1 Make the control buttons consistent 2025-07-15 00:49:33 +01:00
Cooldude2606 f11d91bd6b Add missing space to instances and users list 2025-07-14 22:38:06 +01:00
Cooldude2606 e0278d0331 Add instance restart button 2025-07-14 22:34:41 +01:00
Cooldude2606 2baa2e50ac Add InstanceRestartRequest 2025-07-14 21:48:12 +01:00
98 changed files with 1820 additions and 2413 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
dist/
+4 -1
View File
@@ -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", {
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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>();
+2 -3
View File
@@ -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 });
+1 -1
View File
@@ -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);
+1
View File
@@ -45,6 +45,7 @@ export default class InstanceInfo {
this.status,
this.factorioVersion,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
+1 -1
View File
@@ -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: {
+3 -2
View File
@@ -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",
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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"
+15 -7
View File
@@ -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 -2
View File
@@ -1,3 +1,2 @@
dist/
node_modules/
package-lock.json
dist/
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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,
);
+5 -1
View File
@@ -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
View File
@@ -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);
});
});
+108 -3
View File
@@ -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}`);
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
}
}
+9
View File
@@ -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();
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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) {
+7 -7
View File
@@ -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>;
+6
View File
@@ -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,
+3 -3
View File
@@ -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([
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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");
+3 -3
View File
@@ -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 -1
View File
@@ -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");
+2 -2
View File
@@ -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;
+1
View File
@@ -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,
+2 -2
View File
@@ -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
+5
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
+1 -1
View File
@@ -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}`);
+67 -12
View File
@@ -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);
+1 -1
View File
@@ -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>}
+60 -43
View File
@@ -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>;
}
+13 -104
View File
@@ -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>;
}
+1 -1
View File
@@ -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";
+7 -8
View File
@@ -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,
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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: {
+7 -7
View File
@@ -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];
+26 -12
View File
@@ -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;
}
}
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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: {
+2
View File
@@ -4,3 +4,5 @@ packages:
- "external_plugins/*"
- "external_plugins/*/*"
- "test/"
linkWorkspacePackages: true
+1 -1
View File
@@ -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 };
+94 -57
View File
@@ -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");
+4 -4
View File
@@ -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.");
}
+149
View File
@@ -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");
});
});
});
+4 -4
View File
@@ -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
+1 -2
View File
@@ -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() {
+1 -1
View File
@@ -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;
+181
View File
@@ -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);
});
});
});
});
+1 -1
View File
@@ -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;