Compare commits

...
Author SHA1 Message Date
Cooldude2606 eb77ca566c 2.0.0-alpha.26 2026-06-23 00:39:47 +01:00
Cooldude2606 9e727f9a13 Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
2026-06-13 15:18:22 +01:00
Cooldude2606 d35e8caa2b Merge pull request #914 from Cooldude2606/feature/view-changelog
Add viewable changelog
2026-06-12 16:14:19 +01:00
Cooldude2606 b9c073e26f Fix typo in ci publish workflow 2026-06-12 16:08:37 +01:00
Cooldude2606 b160fecac4 Make icons local to package 2026-06-12 16:04:39 +01:00
Cooldude2606 dab727f90f Fix import in AboutModal 2026-06-10 22:39:26 +01:00
Cooldude2606 0e36df8ae3 Add AboutModal 2026-06-10 22:35:00 +01:00
Cooldude2606 f7d3516c24 Remove internal div from changelog 2026-06-10 20:18:05 +01:00
Cooldude2606 e6665d0803 Fix incorrect use of path.parse within create template (#919) 2026-06-10 20:01:53 +01:00
Cooldude2606 6fd4654c21 Merge pull request #913 from Cooldude2606/refactor/subscriptions-2
Refactor subscription handling and add new filter classes
2026-06-02 22:51:13 +01:00
Cooldude2606 b5c99fad2a Fix incorrect doc comment 2026-06-02 22:50:41 +01:00
Cooldude2606 f588f7f8d4 Guard against div0 in cpuUsage (#918) 2026-06-02 22:49:45 +01:00
Cooldude2606 4fe83cbcbc Add duration filter to first seen column 2026-06-01 23:12:47 +01:00
Cooldude2606 069a050f39 Unify column for instance and no instance 2026-06-01 23:04:57 +01:00
Cooldude2606 577615803b Add loading={!synced} to table and inputs 2026-06-01 22:45:04 +01:00
Cooldude2606 680ade5975 Add instance filter to users page 2026-06-01 22:44:13 +01:00
Cooldude2606 6914645c19 Make Username reusable and fix active icon issue 2026-06-01 21:58:27 +01:00
Cooldude2606 1d0b6b9a62 Add user status filters 2026-06-01 21:47:33 +01:00
Cooldude2606 dd80d49989 Add viewable changelog 2026-05-31 03:52:42 +01:00
Cooldude2606 e3d5f434a4 Better toString and test coverage 2026-05-29 20:14:23 +01:00
Cooldude2606 11b8a0ff97 Fix bad snapshot initialisation 2026-05-29 19:15:27 +01:00
Cooldude2606 e54b292fce Fix tests relating to permissions 2026-05-29 18:38:07 +01:00
Cooldude2606 41222343e3 Add tests 2026-05-29 17:45:14 +01:00
Cooldude2606 4676f1c820 Add filteredHandler to EventSubscriber 2026-05-29 14:09:19 +01:00
Cooldude2606 e49b82842d Add ValueSubscriber and MapSubscriber 2026-05-28 01:12:05 +01:00
Cooldude2606 e0a39e5630 Add option for events to define get filters 2026-05-28 01:11:38 +01:00
Cooldude2606 21b050b220 Call subscriptionUpdate for all update cases 2026-05-28 00:38:15 +01:00
Cooldude2606 9bed1223fd Add option to replace filters 2026-05-28 00:33:45 +01:00
Cooldude2606 4735f0725d Add SubscriptionFilters class 2026-05-28 00:25:29 +01:00
Cooldude2606 1f27617a27 Use addressIndex and a map for subscriptions 2026-05-27 23:35:31 +01:00
Cooldude2606 ee460d677d Rename libAddress.index to requestIndex and add addressIndex 2026-05-27 23:23:29 +01:00
Cooldude2606 f2ca465ec6 Merge pull request #909 from Cooldude2606/fix/small-things
Multiple small changes
2026-05-14 13:08:06 +01:00
Cooldude2606 40e6961711 Fix conflict between defaultFilteredValue and responsive 2026-05-14 12:51:04 +01:00
Cooldude2606 1bb51988ac Fix popconfirm on instance kill button 2026-04-25 23:32:30 +01:00
Cooldude2606 2c30fa4a94 Add user section to changelog script 2026-04-25 22:38:23 +01:00
Cooldude2606 a22186ca01 Add a newline after each section title in change log 2026-04-25 22:19:49 +01:00
Cooldude2606 746b11ff65 Add scrollable div to player auth server list 2026-04-25 22:18:32 +01:00
Cooldude2606 de51741835 Use workspace:^ rather than workspace:* 2026-04-25 22:06:36 +01:00
Cooldude2606 a633c42904 Fix default filter for online users table 2026-04-25 22:03:02 +01:00
Cooldude2606 7522424517 2.0.0-alpha.25 2026-04-24 17:41:16 +01:00
Cooldude2606 57f15d7ba4 Merge pull request #894 from Cooldude2606/fix/892
Fix accessing main inventory when in map view
2026-04-24 11:30:56 +01:00
Cooldude2606 1af672aa1d Always exit remote view on sync 2026-04-23 17:25:13 +01:00
Cooldude2606 41f8f493aa Fix nil table key error in factorio 1.1 2026-04-23 00:19:20 +01:00
Cooldude2606 8be95c140a Fix edge cases with physical location and controller 2026-04-22 22:37:42 +01:00
Cooldude2606 add0088704 Switch to xpcall for better trace backs 2026-04-22 00:49:45 +01:00
Cooldude2606 037f06808e Support remote controller type within deserialize 2026-04-22 00:45:31 +01:00
Daniel 01456818a8 Merge pull request #897 from Cooldude2606/feature/custom-version-input
Allow custom versions to be selected as a target
2026-04-20 22:26:11 +02:00
Cooldude2606 5c3182fb56 Merge pull request #902 from Cooldude2606/feature/improved-role-page
Improved RoleViewPage
2026-04-19 22:52:19 +01:00
Cooldude2606 f515b2db80 Disable the select all and clear buttons using canUpdate 2026-04-19 22:47:05 +01:00
Daniel eb2c2b5aec Merge pull request #901 from Cooldude2606/fix/instance-config-update
Add missing instanceConfigUpdated to InstanceConfigSetRequest
2026-04-19 22:48:22 +02:00
Cooldude2606 6f181f357b Change colour of groups to match input colour 2026-04-19 16:53:27 +01:00
Cooldude2606 1bcc152368 Improved RoleViewPage
Add groups, search, better description format, and default / dangerous icons.
2026-04-19 03:28:09 +01:00
Cooldude2606 26df9d2caa Add missing instanceConfigUpdated to InstanceConfigSetRequest 2026-04-18 22:05:05 +01:00
Cooldude2606 45adb1e88c Merge pull request #896 from Cooldude2606/feature/ctl-double-interrupt
Add double interrupt logic to ctl to force exit
2026-04-15 22:22:58 +01:00
Cooldude2606 6f5b8ce68d Update log statement to fatal 2026-04-15 22:15:14 +01:00
Cooldude2606 16a249d5bf Have version selectors share a common base component 2026-04-14 23:32:16 +01:00
Cooldude2606 a52e4320ec Improve UX on keyboard 2026-04-14 22:50:39 +01:00
Cooldude2606 e8cabb7196 Standardise shutdown signal guarding 2026-04-14 22:10:01 +01:00
Cooldude2606 7e82ad89ff Change config errors to be subclasses of RequestError (#895)
This prevents them from being logged when raised during an event handler.
2026-04-14 21:38:11 +01:00
Cooldude2606 2219b58000 Fix two missed cases of get_main_inventory 2026-04-14 21:31:26 +01:00
Cooldude2606 3667b4733a Fix webui error on setting host config (#893) 2026-04-14 21:21:00 +01:00
Hornwitser 490e9c0e5f Remove download headless Factorio in installer (#890)
This feature was made obsolete by hosts being able to download a
configured target version on instance startup in #831.
2026-04-13 12:27:20 +02:00
Cooldude2606 7e87201c24 Allow custom versions to be selected as a target
Add a custom version input field which can be used to enter any version number.
2026-04-12 23:18:13 +01:00
Cooldude2606 e9653c13eb Add double interrupt logic to ctl to force exit 2026-04-12 22:37:50 +01:00
Cooldude2606 255cc4c325 Fix accessing main inventory when in map view 2026-04-12 22:15:24 +01:00
68 changed files with 3260 additions and 1594 deletions

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
+72
View File
@@ -31,6 +31,78 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
+5 -5
View File
@@ -35,11 +35,11 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
+3 -28
View File
@@ -18,7 +18,6 @@ import path from "path";
import fs from "node:fs/promises";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -446,33 +445,9 @@ async function startup() {
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
+3 -5
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -20,7 +20,7 @@
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
@@ -28,14 +28,13 @@
"finalhandler": "^1.3.1",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
@@ -43,7 +42,6 @@
"@types/finalhandler": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"react": "catalog:",
@@ -330,6 +330,7 @@ export default class ControlConnection extends BaseConnection {
}
instance.config.commitStaging();
await this._controller.instanceConfigUpdated(instance);
} finally {
instance.config.revertStaging();
}
+27 -1
View File
@@ -875,6 +875,9 @@ export default class Controller {
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
);
@@ -897,6 +900,9 @@ export default class Controller {
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const hosts = [...this.hosts.values()].filter(
host => host.updatedAtMs > request.lastRequestTimeMs,
).map(host => host.toHostDetails());
@@ -944,6 +950,9 @@ export default class Controller {
}
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const instances = [...this.instances.values()].filter(
instance => instance.updatedAtMs > request.lastRequestTimeMs,
).map(instance => instance.toInstanceDetails());
@@ -955,7 +964,12 @@ export default class Controller {
}
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
if (request.action === "unsubscribe") {
return null;
}
const saves = [...this.saves.values()].filter(
save => save.updatedAtMs > request.lastRequestTimeMs
);
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
}
@@ -966,6 +980,9 @@ export default class Controller {
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const modPacks = [...this.modPacks.values()].filter(
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
);
@@ -979,6 +996,9 @@ export default class Controller {
}
async handleModSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const mods = [...this.modStore.files.values()].filter(
mod => mod.updatedAtMs > request.lastRequestTimeMs,
);
@@ -990,6 +1010,9 @@ export default class Controller {
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1037,6 +1060,9 @@ export default class Controller {
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
-82
View File
@@ -14,7 +14,6 @@ const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
@@ -660,22 +659,6 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -767,54 +750,6 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(tmpDir, { recursive: true });
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.mkdir(tmpFactorioDir, { recursive: true });
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.mkdir("factorio", { recursive: true });
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function main() {
let args = yargs
.option("log-level", {
@@ -837,10 +772,6 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -879,12 +810,6 @@ async function main() {
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
@@ -911,13 +836,6 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+1 -1
View File
@@ -62,7 +62,7 @@ async function copyTemplateFile(src, dst, properties) {
});
// Create folder for output file
const dir = path.parse(dst);
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
+3 -10
View File
@@ -8,7 +8,6 @@ import fs from "node:fs/promises";
import yargs, { type Argv } from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
@@ -311,15 +310,9 @@ async function startControl() {
throw err;
}
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", control.shutdown.bind(control)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", control.shutdown.bind(control)));
try {
const targetCommand = selectTargetCommand(args, rootCommands);
+2 -4
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -20,9 +20,8 @@
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"set-blocking": "catalog:",
"winston": "catalog:",
"yargs": "^17.7.2"
},
@@ -31,7 +30,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+2 -27
View File
@@ -15,7 +15,6 @@
import fs from "node:fs/promises";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
import { version } from "./package.json";
import winston from "winston";
import "winston-daily-rotate-file";
@@ -239,32 +238,8 @@ async function startHost() {
);
// Handle interrupts
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -21,7 +21,7 @@
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.0",
"jszip": "catalog:",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
+4 -3
View File
@@ -9,6 +9,7 @@ import * as libSchema from "../schema";
import { StringEnum } from "../data/composites";
import { safeOutputFile } from "../file_ops";
import * as validators from "./validators";
import { RequestError } from "../errors";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
@@ -21,7 +22,7 @@ export type ConfigLocation = Static<typeof ConfigLocation>;
*
* @extends Error
*/
export class InvalidAccess extends Error { };
export class InvalidAccess extends RequestError { };
/**
* Invalid Value exception
@@ -31,7 +32,7 @@ export class InvalidAccess extends Error { };
*
* @extends Error
*/
export class InvalidValue extends Error { };
export class InvalidValue extends RequestError { };
/**
* Invalid Field exception
@@ -41,7 +42,7 @@ export class InvalidValue extends Error { };
*
* @extends Error
*/
export class InvalidField extends Error { };
export class InvalidField extends RequestError { };
const OldConfigGroupSchema = Type.Object({
+3 -2
View File
@@ -8,16 +8,17 @@ export default class Permission {
public name: string,
public title: string,
public description: string,
public grantByDefault?: boolean,
public grantByDefault: boolean = false,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
description: Type.String(),
grantByDefault: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.title, json.description);
return new this(json.name, json.title, json.description, Boolean(json.grantByDefault));
}
}
+10 -2
View File
@@ -102,8 +102,16 @@ export class Address {
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address
*/
index() {
return `${this.type}:${this.id}:${this.requestId}`;
requestIndex() {
return `${this.type}:${this.id}:${this.requestId}` as const;
}
/**
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address, excluding required id
*/
addressIndex() {
return `${this.type}:${this.id}` as const;
}
/**
+29
View File
@@ -3,6 +3,9 @@
* @module lib/helpers
*/
import type { Logger } from "./logging";
import setBlocking from "set-blocking";
/**
* Return a string describing the type of the value passed
*
@@ -51,6 +54,32 @@ export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutRe
}
}
/**
* Catch process signals, force exit on second signal
*/
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
let called = false;
return () => {
if (called) {
setBlocking(true);
logger.fatal(`Caught second ${signalName}, terminating immediately`);
process.exit(1);
}
called = true;
logger.info(`Caught ${signalName}, shutting down`);
callback().catch(err => {
setBlocking(true);
logger.fatal("Error during shutdown, terminating immediately");
logger.fatal(err.stack);
process.exit(1);
});
};
}
/**
* Helper to serialise and merge waiting calls to an async callback.
*
+2 -2
View File
@@ -219,7 +219,7 @@ export class Link {
if (!message.dst.addressedTo(this.connector.src)) {
if (message.type === "response" || message.type === "responseError") {
this._forwardedRequests.delete(message.dst.index());
this._forwardedRequests.delete(message.dst.requestIndex());
}
this._routeMessage(message, entry);
return;
@@ -566,7 +566,7 @@ export class Link {
src: message.src,
dst: message.dst,
};
this._forwardedRequests.set(message.src.index(), pending);
this._forwardedRequests.set(message.src.requestIndex(), pending);
this.connector.forward(message);
}
+389 -153
View File
@@ -1,13 +1,142 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError, SessionLost } from "./errors";
import { RequestError } from "./errors";
import { logger } from "./logging";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
/**
* Represents a set of string subscription filters.
* When isAll() is true, all strings match this filter.
* When isEmpty() is true, no strings match this filter.
* Otherwise, literal string matching is performed against a set.
*/
export class SubscriptionFilters {
private constructor(
private _all: boolean,
private _filters: Set<string>,
) {}
static jsonSchema = Type.Array(Type.String());
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(false, new Set(json));
}
toJSON() {
return [...this._filters];
}
/** Creates a subscription filter accepting all strings */
static all() {
return new this(true, new Set());
}
isAll() {
return this._all;
}
/** Creates a subscription filter rejecting all strings */
static empty() {
return new this(false, new Set());
}
isEmpty() {
return !this._all && this._filters.size === 0;
}
/**
* Creates a subscription filter from undefined, string, or array of strings.
* Strings work as expected, undefined will produce a filter accepting all values.
* @param filters Shorthand representation
*/
static fromShorthand(filters?: string | string[]) {
if (filters === undefined) {
return this.all();
}
if (Array.isArray(filters)) {
return new this(false, new Set(filters));
}
return new this(false, new Set([filters]));
}
toString() {
if (this._all) {
return "[SubscriptionFilters All]";
}
if (this._filters.size === 0) {
return "[SubscriptionFilters Empty]";
}
return `[SubscriptionFilters Set<${this._filters.size}>]`;
}
/** Returns true if this filter accepts the given string */
accepts(value: string) {
return this._all || this._filters.has(value);
}
/** Returns true if this filter extends the other */
extends(other: this) {
return this._all || (!other._all && [...other._filters.values()].every(value => this._filters.has(value)));
}
/** Returns true if there is overlap between two filters */
intersects(other: this) {
if (this._all) {
return !other.isEmpty();
}
if (other._all) {
return !this.isEmpty();
}
for (const filter of other._filters) {
if (this._filters.has(filter)) {
return true;
}
}
return false;
}
/** Add filters from a filter into this one. */
union(other: this) {
if (this._all) {
return; // Already all
}
if (other._all) {
this._all = true;
this._filters.clear();
return;
}
for (const filter of other._filters) {
this._filters.add(filter);
}
}
/**
* Remove filters in a filter from this one.
* You cannot subtract filters from a filter accepting all.
* Subtracting a filter accepting all produces an empty filter.
*/
subtract(other: this) {
if (other._all) {
this._all = false;
this._filters.clear();
return;
}
if (this._all) {
return; // Cannot subtract from all
}
for (const filter of other._filters) {
this._filters.delete(filter);
}
}
}
const SubscriptionActionSchema = StringEnum(["subscribe", "unsubscribe", "replace"]);
type SubscriptionAction = Static<typeof SubscriptionActionSchema>;
/**
* A subscription request sent by a subscriber, this updates what events the subscriber will be sent
* The permission for this request copies the permission from the event being subscribed to
@@ -20,7 +149,7 @@ export class SubscriptionRequest {
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const data = message.data as Static<typeof this.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
if (entry && entry.Event.permission) {
if (typeof entry.Event.permission === "string") {
@@ -34,55 +163,86 @@ export class SubscriptionRequest {
/** Indicates if updates have been sent */
static Response = JsonBoolean;
public filters: SubscriptionFilters;
public action: SubscriptionAction;
/** @deprecated Use SubscriptionFilters and SubscriptionAction instead */
constructor(
eventName: string,
subscribe: boolean,
lastRequestTimeMs: number | undefined,
filters?: string | string[],
);
constructor(
eventName: string,
action: SubscriptionAction,
lastRequestTimeMs: number | undefined,
filters?: SubscriptionFilters,
);
constructor(
public eventName: string,
public subscribe: boolean,
action: boolean | SubscriptionAction,
public lastRequestTimeMs: number = 0,
public filters: string | string[] | undefined = undefined,
filters: undefined | string | string[] | SubscriptionFilters,
) {
if (!Link._eventsByName.has(eventName)) {
throw new Error(`Unregistered Event class ${eventName}`);
}
if (filters instanceof SubscriptionFilters) { // Easier migration for plugins
this.filters = filters;
} else {
this.filters = SubscriptionFilters.fromShorthand(filters);
}
if (typeof action === "boolean") { // Easier migration for plugins
this.action = action ? "subscribe" : "unsubscribe";
} else {
this.action = action;
}
}
static jsonSchema = Type.Union([
Type.Tuple([
Type.String(),
Type.Boolean(),
SubscriptionActionSchema,
Type.Number(),
]),
Type.Tuple([
Type.String(),
Type.Boolean(),
SubscriptionActionSchema,
Type.Number(),
Type.Union([Type.String(), Type.Array(Type.String())]),
SubscriptionFilters.jsonSchema,
]),
]);
toJSON() {
if (this.filters !== undefined) {
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
if (!this.filters.isAll()) {
return [this.eventName, this.action, this.lastRequestTimeMs, this.filters.toJSON()];
}
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
return [this.eventName, this.action, this.lastRequestTimeMs];
}
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
return new this(eventName, subscribe, lastRequestTimeMs, filters);
static fromJSON(json: Static<typeof this.jsonSchema>) {
const [eventName, action, lastRequestTimeMs, filters] = json;
return new this(
eventName,
action,
lastRequestTimeMs,
filters ? SubscriptionFilters.fromJSON(filters) : SubscriptionFilters.all()
);
}
}
type Subscriber = {
link: Link,
dst: Address,
/** Optional filters this subscriber is interested in. Undefined means all events */
filters?: string[],
filters: SubscriptionFilters,
}
type EventData = {
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
subscriptions: Array<Subscriber>,
subscriptions: Map<ReturnType<Address["addressIndex"]>, Subscriber>,
};
/**
@@ -92,13 +252,6 @@ 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
@@ -116,7 +269,7 @@ export class SubscriptionController {
}
this._events.set(entry.name, {
subscriptionUpdate: subscriptionUpdate,
subscriptions: [],
subscriptions: new Map(),
});
}
@@ -124,7 +277,7 @@ export class SubscriptionController {
* Broadcast an event to all subscribers of that event
* @param event - Event to broadcast.
*/
broadcast<T>(event: Event<T>, filter?: string | string[]) {
broadcast<T extends Event<T>>(event: T & { filters?: string | string[] }, filters?: string | string[]) {
const entry = Link._eventsByClass.get(event.constructor);
if (!entry) {
throw new Error(`Unregistered Event class ${event.constructor.name}`);
@@ -133,24 +286,17 @@ export class SubscriptionController {
if (!eventData) {
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) {
const broadcastFilters = SubscriptionFilters.fromShorthand(filters ?? event.filters);
for (const [addressIndex, subscriber] of eventData.subscriptions) {
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
toRemove.push(subscriber);
eventData.subscriptions.delete(addressIndex);
continue;
}
if (
filtersToMatch === undefined
|| subscriber.filters === undefined
|| subscriber.filters.some(f => filtersToMatch.includes(f))
) {
if (subscriber.filters.intersects(broadcastFilters)) {
subscriber.link.sendTo(subscriber.dst, event);
}
}
eventData.subscriptions = eventData.subscriptions.filter(subscriber => !toRemove.includes(subscriber));
}
/**
@@ -159,10 +305,9 @@ export class SubscriptionController {
* @param address - Address to stop sending events to.
*/
unsubscribeAddress(address: Address) {
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => !subscriber.dst.addressedTo(address)
);
const addressIndex = address.addressIndex();
for (const eventData of this._events.values()) {
eventData.subscriptions.delete(addressIndex);
}
}
@@ -172,69 +317,76 @@ export class SubscriptionController {
* @param link - Link to stop sending events to.
*/
unsubscribeLink(link: Link) {
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => subscriber.link !== link
);
for (const eventData of this._events.values()) {
for (const [addressIndex, subscriber] of eventData.subscriptions) {
if (subscriber.link === link) {
eventData.subscriptions.delete(addressIndex);
}
}
}
}
/**
* Handle incoming subscription requests on a link
* @param link - Link message was received on
* @param event - incomming event.
* @param request - incoming request.
* @param src - Source address of incomming request.
* @param dst - destination address of incomming request.
*/
async handleRequest(link: Link, event: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(event.eventName)) {
throw new Error(`Event ${event.eventName} is not a registered event`);
async handleRequest(link: Link, request: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(request.eventName)) {
throw new Error(`Event ${request.eventName} is not a registered event`);
}
const eventData = this._events.get(event.eventName);
const eventData = this._events.get(request.eventName);
if (!eventData) {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
throw new Error(`Event ${request.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
// 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);
const addressIndex = src.addressIndex();
const subscriber = eventData.subscriptions.get(addressIndex);
switch (request.action) {
case "unsubscribe":
if (!subscriber) {
return false;
}
}
} else {
// 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);
subscriber.filters.subtract(request.filters);
if (subscriber.filters.isEmpty()) {
eventData.subscriptions.delete(addressIndex);
}
break;
case "subscribe":
if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters.union(request.filters);
}
break;
case "replace":
if (request.filters.isEmpty()) {
if (!subscriber) {
return false;
}
eventData.subscriptions.delete(addressIndex);
} else if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters = request.filters;
}
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
break;
default:
throw new Error(`unreachable case: ${String(request.action)}`);
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(request, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
}
return false;
}
}
@@ -245,36 +397,27 @@ export interface SubscribableValue {
isDeleted: boolean,
}
export type EventSubscribable<T, V extends SubscribableValue> = Event<T> & Partial<{
updates: V[],
}>
/**
* Component for subscribing to and tracking updates of a remote resource
* Multiple handlers can be subscribed at the same time
*/
export class EventSubscriber<
T extends EventSubscribable<T, V>,
K extends string | number = NonNullable<T["updates"]>[number]["id"],
V extends SubscribableValue = NonNullable<T["updates"]>[number],
> {
export class EventSubscriber<E, S = null> {
/** The time at which an event was last received, is less than 0 when there have been no events */
lastResponseTimeMs = -1;
/** Values of the subscribed resource */
values = new Map<K, V>();
lastUpdatedMs = -1;
/** True if this subscriber is currently synced with the source */
synced = false;
/** True if this subscriber is expecting to receive updates from the source */
_syncing = false;
/** Repeat calls to getSnapshot will return the same readonly copy unless values has updated */
_snapshot: readonly [ReadonlyMap<K, Readonly<V>>, boolean] = [new Map<K, V>(), false];
_snapshotLastUpdatedMs = -1;
private _snapshot: readonly [S, boolean] = [this.makeSnapshot(), false];
private _snapshotLastUpdatedMs = -1;
/** Callbacks will be called when an event is received or the synced state changes */
_callbacks = new Array<EventSubscriberCallback<T>>();
private _callbacks = new Array<EventSubscriberCallback<E>>();
/** Filters applied to the filtered handler, if empty the filtered handler will not be called */
private _filters = SubscriptionFilters.empty();
constructor(
private Event: EventClass<T>,
protected Event: EventClass<E>,
public control: Link,
protected filteredHandler?: EventSubscriberCallback<E>,
) {
control.handle(Event, this._handleEvent.bind(this));
// The events below exist only on websocket connectors, we can't early return because of test mocking
@@ -294,12 +437,10 @@ export class EventSubscriber<
*/
handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect" || event === "resume") {
this._updateSubscription();
this._sendRequest("replace", this._callbacks.length > 0 ? SubscriptionFilters.all() : this._filters);
} else if (this.synced) {
this.synced = false;
for (let callback of this._callbacks) {
callback(null, false);
}
this._notify(null);
}
}
@@ -308,33 +449,35 @@ export class EventSubscriber<
* @param event - event from subscribed resource
* @internal
*/
async _handleEvent(event: T) {
// We support the automatic maintaining of a map for events with the updates property
const updates = event.updates ?? [];
for (const value of updates) {
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
// Event handling logic
if (this._syncing && !this.synced) {
this._snapshotLastUpdatedMs = -1;
this.synced = true;
private async _handleEvent(event: E) {
const eventTimeMs = this.getLastUpdatedTimeMs(event);
if (eventTimeMs > this.lastUpdatedMs) {
this.lastUpdatedMs = eventTimeMs;
}
this.synced = this._hasSubscriptions();
this.processEvent(event);
this._notify(event);
}
private _notify(event: E | null) {
for (const callback of this._callbacks) {
callback(event, this.synced);
}
if (this.filteredHandler && !this._filters.isEmpty()) {
this.filteredHandler(event, this.synced);
}
}
protected getLastUpdatedTimeMs(event: E) {
return Date.now();
}
protected processEvent(event: E) {
// Noop for EventSubscriber
}
protected makeSnapshot(): S {
return null as S;
}
/**
@@ -344,10 +487,10 @@ export class EventSubscriber<
* synced property changes, in which case the event will be null.
* @returns function that will unsubscribe from notifications
*/
subscribe(handler: EventSubscriberCallback<T>) {
subscribe(handler: EventSubscriberCallback<E>) {
this._callbacks.push(handler);
if (this._callbacks.length === 1) {
this._updateSubscription();
this._sendRequest("subscribe", SubscriptionFilters.all());
}
return () => {
// During a page transition the components currently rendered
@@ -368,52 +511,145 @@ export class EventSubscriber<
}
this._callbacks.splice(index, 1);
if (this._callbacks.length === 0) {
this._updateSubscription();
this._sendRequest("replace", this._filters);
}
});
};
}
/** Returns true if all filters are present */
hasFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
return this._filters.extends(filters);
}
/** Add filters to the filtered handler */
addFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.union(filters);
this._sendRequest("subscribe", filters);
}
/** Remove filters from the filtered handler */
removeFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.subtract(filters);
this._sendRequest("unsubscribe", filters);
}
/** Clear all filters from the filtered handler, it will no longer be called */
clearFilters() {
this._filters = SubscriptionFilters.empty();
if (!this._hasSubscriptions()) {
this._sendRequest("unsubscribe", SubscriptionFilters.all());
}
}
/**
* Obtain a snapshot of the current state of the tracked resource
* @returns tuple of values map snapshot and synced property.
*/
getSnapshot() {
if (this._snapshotLastUpdatedMs !== this.lastResponseTimeMs) {
this._snapshotLastUpdatedMs = this.lastResponseTimeMs;
this._snapshot = [new Map(this.values), this.synced];
getSnapshot(): readonly [S, boolean] {
if (this._snapshotLastUpdatedMs !== this.lastUpdatedMs || this._snapshot[1] !== this.synced) {
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
this._snapshot = [this.makeSnapshot(), this.synced];
}
return this._snapshot!;
return this._snapshot;
}
/** Returns true if there are any active subscription callbacks */
private _hasSubscriptions() {
return this._callbacks.length > 0 || !this._filters.isEmpty();
}
/**
* Update the subscription with the controller based on current handler counts
*/
async _updateSubscription() {
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
if (!this.control.connector.valid) {
return;
}
const entry = Link._eventsByClass.get(this.Event)!;
const entry = Link._eventsByClass.get(this.Event)!;
try {
this._syncing = this._callbacks.length > 0;
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
entry.name,
this._syncing,
this.lastResponseTimeMs
entry.name, action, this.lastUpdatedMs, filters
));
if (!updatesSent) {
this.synced = this._syncing;
for (const callback of this._callbacks) {
callback(null, this.synced);
}
this.synced = this._hasSubscriptions();
this._notify(null);
}
} catch (err: any) {
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
logger.error(
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
);
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
}
}
}
}
export class ValueSubscriber<
E extends { value: V },
V extends SubscribableValue = E["value"],
> extends EventSubscriber<E, Readonly<V> | null> {
/** Current value of the subscribed resource */
value: V | null = null;
protected override getLastUpdatedTimeMs(event: E) {
return event.value.updatedAtMs;
}
protected override processEvent(event: E) {
this.value = event.value;
}
protected override makeSnapshot() {
return this.value;
}
}
export class MapSubscriber<
E extends { updates: V[] },
K extends string | number = E["updates"][number]["id"],
V extends SubscribableValue = E["updates"][number],
> extends EventSubscriber<E, ReadonlyMap<K, Readonly<V>>> {
/** Current value of the subscribed resource */
values = new Map<K, V>();
protected override getLastUpdatedTimeMs(event: E) {
let maxTime = this.lastUpdatedMs;
for (const update of event.updates) {
if (update.updatedAtMs > maxTime) {
maxTime = update.updatedAtMs;
}
}
return maxTime;
}
protected override processEvent(event: E) {
for (const value of event.updates) {
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
}
protected override makeSnapshot() {
return new Map(this.values);
}
}
+3 -1
View File
@@ -132,7 +132,9 @@ export async function gatherSystemInfo(
const currentIdleCpuMs = cpus.map(({ times }) => times.idle);
const deltaTotalCpuMs = minZip(previousTotalCpuMs, currentTotalCpuMs).map(([prev, curr]) => curr - prev);
const deltaIdleCpuMs = minZip(previousIdleCpuMs, currentIdleCpuMs).map(([prev, curr]) => curr - prev);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(([total, idle]) => (total - idle) / total);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(
([total, idle]) => (total <= 0 ? 0 : (total - idle) / total)
);
previousTotalCpuMs = currentTotalCpuMs;
previousIdleCpuMs = currentIdleCpuMs;
const stats = await fs.statfs(".");
+10
View File
@@ -3,6 +3,16 @@ declare module "*.png" {
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
declare module "*.md" {
const value: string;
export default value;
}
declare const webRoot: string;
declare const staticRoot: string;
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -19,7 +19,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "^0.30.4",
"@swc/core": "^1.13.3",
"antd": "^5.24.2",
@@ -37,6 +37,7 @@
"rc-field-form": "^1.37.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "catalog:",
"react-router-dom": "^6.29.0",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.3",
+2 -2
View File
@@ -9,8 +9,7 @@ import App from "./components/App";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputRole from "./components/InputRole";
import InputModPack from "./components/InputModPack";
import InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -111,6 +110,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"full_version": InputFullVersion,
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
@@ -0,0 +1,189 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Modal, Button, Space, Typography, message, Row, Col, Card } from "antd";
import { ReloadOutlined, CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { PluginWebApi } from "@clusterio/lib";
import webUiPackage from "../../package.json";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { useInstances } from "../model/instance";
import { useUsers } from "../model/user";
const { Text } = Typography;
function formatOS(os?: string) {
switch (os) {
case "win32": return "Windows";
case "darwin": return "macOS";
case "linux": return "Linux";
default: return os;
}
}
export default function AboutModal({ open, onClose, onOpenChangelog }: any) {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts] = useHosts();
const [instances] = useInstances();
const [users] = useUsers();
const [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
}
})();
}, [webRoot]);
const browserInfo = useMemo(() => ({
userAgent: navigator.userAgent,
platform: navigator.platform,
}), []);
const debugText = useMemo(() => {
const permissions = new Set(
account.roles.flatMap(role => role.permissions)
);
const date = new Date();
const lines: string[] = [
"Clusterio Debug Info",
`- Timestamp: ${date.toISOString()}`,
`- Local Time: ${date.toString()}`,
"",
"Build",
`- Version: ${webUiPackage.version}`,
`- Build Date: ${process.env.BUILD_DATE}`,
`- Commit Hash: ${process.env.COMMIT_HASH}`,
`- Environment: ${process.env.BUILD_ENV}`,
`- System: ${process.env.BUILD_OS}`,
"",
"Runtime",
`- Platform: ${browserInfo.platform}`,
`- User Agent: ${browserInfo.userAgent}`,
`- Screen: ${window.screen.width}x${window.screen.height}`,
`- Viewport: ${window.innerWidth}x${window.innerHeight}`,
"",
"Cluster",
`- Url: ${new URL(webRoot, window.location.origin)}`,
`- Hosts: ${hosts.size}`,
`- Instances: ${instances.size}`,
`- Plugins: ${pluginList.length}`,
`- Users: ${users.size}`,
"",
"Account",
`- Name: ${account.name}`,
`- Roles: ${account.roles.map(r => r.name).sort().join(", ") || "None"}`,
`- Permissions: ${permissions.size}`,
"",
"Permission List",
...[...permissions].sort().map(p => `- ${p}`),
"",
];
lines.push("Plugin List");
for (let plugin of pluginList) {
lines.push(`- ${plugin.name}`);
lines.push(` Version: ${plugin.version ?? "unknown"}`);
lines.push(` Loaded: ${plugin.loaded ? "yes" : "no"}`);
lines.push(` Controller: ${plugin.enabled ? "enabled" : "disabled"}`);
lines.push(` Web: ${control.plugins.get(plugin.name) ? "enabled" : "disabled"}`);
if (plugin.npmPackage) {
lines.push(` Package: ${plugin.npmPackage}`);
}
}
return lines.join("\n");
}, [account, hosts, instances, users, pluginList, browserInfo]);
async function copyDebug() {
await navigator.clipboard.writeText(debugText);
message.success("Copied debug info");
}
function forceRefresh() {
window.location.reload();
}
return <Modal open={open} onCancel={onClose} footer={null} title="About Clusterio" width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Row gutter={[24, 16]}>
<Col xs={24} md={12}>
<Card title="Build Info">
<Space direction="vertical">
<Text>Version: {`${webUiPackage.version} (${process.env.COMMIT_HASH})`}</Text>
<Text>Date: {new Date(process.env.BUILD_DATE ?? "").toLocaleString()}</Text>
<Text>Environment: {process.env.BUILD_ENV}</Text>
<Text>System: {formatOS(process.env.BUILD_OS)}</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="Cluster Info">
<Space direction="vertical">
<Text>Hosts: {hosts.size}</Text>
<Text>Instances: {instances.size}</Text>
<Text>Plugins: {pluginList.length}</Text>
<Text>Users: {users.size}</Text>
</Space>
</Card>
</Col>
</Row>
<Row>
<Col span={24}>
<Space style={{ width: "100%", justifyContent: "space-between" }} wrap>
<Space wrap>
<Button icon={<InfoCircleOutlined />} onClick={onOpenChangelog}>
Changelog
</Button>
<Button
target="_blank"
href="https://github.com/clusterio/clusterio/issues"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={GitHubLogo} style={{ width: 16 }} />
</span>}
>
GitHub Issues
</Button>
<Button
target="_blank"
href="https://discord.gg/mzAsgnm"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={DiscordLogo} style={{ width: 16}} />
</span>}
>
Support Server
</Button>
</Space>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={forceRefresh}>
Reload
</Button>
<Button icon={<CopyOutlined />} onClick={copyDebug} type="primary">
Copy Debug
</Button>
</Space>
</Space>
</Col>
</Row>
</Space>
</Modal>;
}
@@ -9,7 +9,7 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
import type { Control } from "../util/websocket";
import { notifyErrorHandler } from "../util/notify";
import notify from "../util/notify";
const { Title } = Typography;
@@ -183,7 +183,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
await props.setConfig(fields);
setChangedFields(new Set());
} catch (err: any) {
notifyErrorHandler(err.message);
notify(err.message, "error");
}
await updateConfig();
@@ -0,0 +1,26 @@
import React from "react";
import { Modal } from "antd";
import ReactMarkdown from "react-markdown";
import changelog from "../../../../CHANGELOG.md";
const cleanedChangelog = changelog
.replace(/<!--[\s\S]*?-->/g, "") // remove comments
.replace(/^#\s+Changelog\s*\n?/i, ""); // remove top-level title
/**
* Displays a modal element which contains the change log
*/
export default function ChangeLogModal({ open, onClose }: any) {
return <Modal
title="Changelog"
open={open}
onCancel={onClose}
footer={null}
width={800}
>
<ReactMarkdown>
{cleanedChangelog}
</ReactMarkdown>
</Modal>;
}
+5 -2
View File
@@ -2,6 +2,9 @@ import React from "react";
import { Button, Card, Flex, Image, Typography } from "antd";
import example from "../images/console-error-edge.png";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import PageLayout from "./PageLayout";
import type { ErrorProps } from "./App";
@@ -38,12 +41,12 @@ export default function ErrorPage(props: ErrorProps & { throw?: boolean }) {
<SupportLinkCard
name="Github Issues"
href="https://github.com/clusterio/clusterio/issues"
image="https://github.githubassets.com/favicons/favicon-dark.svg"
image={GitHubLogo}
/>
<SupportLinkCard
name="Support Server"
href="https://discord.gg/mzAsgnm"
image="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
image={DiscordLogo}
/>
</Flex>
<br/>
@@ -14,7 +14,7 @@ export default function HostConfigTree(props: { id: number, available: boolean }
}
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.HostConfigSetRequest(fields));
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetRequest(fields));
}
return <BaseConfigTree
@@ -0,0 +1,42 @@
import { Select } from "antd";
import { InputComponentProps } from "../BaseWebPlugin";
import { useInstances } from "../model/instance";
interface BaseInputInstanceProps extends InputComponentProps {
includeAll?: boolean;
}
function BaseInputInstance(props: BaseInputInstanceProps) {
const [instances, synced] = useInstances();
const options = [
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
...[...instances.values()].map(instance => ({
label: instance.name ?? `Instance ${instance.id}`,
value: instance.id,
})),
];
return (
<Select
showSearch
optionFilterProp="label"
style={{ minWidth: 175 }}
onChange={value => props.onChange(value ?? null)}
value={props.value}
options={options}
allowClear={props.fieldDefinition.optional}
disabled={!synced || props.disabled}
loading={!synced}
/>
);
}
export function InputInstanceWithAll(props: InputComponentProps) {
return <BaseInputInstance {...props} includeAll />;
}
export function InputInstance(props: InputComponentProps) {
return <BaseInputInstance {...props} />;
}
@@ -5,7 +5,7 @@ import { useModPacks } from "../model/mod_pack";
import { InputComponentProps } from "../BaseWebPlugin";
export default function InputModPack(props: InputComponentProps) {
const [modPacks] = useModPacks();
const [modPacks, synced] = useModPacks();
return <Select
showSearch
optionFilterProp="label"
@@ -17,6 +17,7 @@ export default function InputModPack(props: InputComponentProps) {
value: modPack.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={props.disabled}
disabled={!synced || props.disabled}
loading={!synced}
/>;
}
@@ -1,68 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { InputComponentProps } from "../BaseWebPlugin";
import { useRoles } from "../model/roles";
export default function InputRole(props: InputComponentProps) {
const [roles] = useRoles();
const [roles, synced] = useRoles();
return <Select
showSearch
@@ -17,6 +17,7 @@ export default function InputRole(props: InputComponentProps) {
value: role.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={props.disabled}
disabled={!synced || props.disabled}
loading={!synced}
/>;
}
@@ -1,71 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
@@ -0,0 +1,179 @@
import React, { useContext, useEffect, useState, useRef } from "react";
import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
function InputVersion<
Version extends lib.TargetVersion,
IncludeLatest extends (Version extends "latest" ? boolean : false),
> (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
version: {
isValid: (v: string) => v is Version;
includeLatest: IncludeLatest;
};
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
const [customVersion, setCustomVersion] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<InputRef>(null);
const selectRef = useRef<RefSelectProps>(null);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<string, string[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
if (props.version.includeLatest) {
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
}
return <TreeSelect
showSearch
ref={selectRef}
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
open={open}
onOpenChange={newOpen => {
setOpen(newOpen);
if (newOpen && typeof props.value === "string") {
setCustomVersion(props.value);
}
}}
onKeyDown={e => {
if (open && e.key === "Tab") {
e.preventDefault();
inputRef.current?.focus();
}
}}
popupRender={(menu) => {
const isValid = props.version.isValid(customVersion);
const submit = () => {
if (customVersion && isValid) {
setTimeout(() => selectRef.current?.focus(), 0);
props.onChange(customVersion);
setOpen(false);
}
};
return (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<div style={{ display: "flex", gap: 8, padding: "0 8px 4px" }}>
<Input
ref={inputRef}
style={{ flex: 1 }}
placeholder="Custom X.Y.Z"
value={customVersion}
status={customVersion && !isValid ? "error" : undefined}
onChange={(e) => setCustomVersion(e.target.value)}
onPressEnter={submit}
/>
<Button
type="primary"
disabled={!customVersion || !isValid}
onClick={submit}
>
Use
</Button>
</div>
</>
);
}}
/>;
}
export function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isTargetVersion,
includeLatest: true,
}}
/>;
}
export function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isPartialVersion,
includeLatest: false,
}}
/>;
}
export function InputFullVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isFullVersion,
includeLatest: false,
}}
/>;
}
@@ -21,6 +21,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
const account = useAccount();
const control = useContext(ControlContext);
const [switching, setSwitching] = useState<boolean>(false);
const [popconfirm, setPopconfirm] = useState<string | null>(null);
const instanceId = props.instance.id;
const instanceStatus = props.instance.status;
@@ -88,35 +89,43 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
if (account.hasPermission("core.instance.kill")) {
actions.push({
key: "kill",
label: "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>),
onClick: () => setPopconfirm("kill"),
});
}
return <VariableDropdownButton
{...props}
type="primary"
actions={actions}
loading={switching}
disabled={!(showStart || showStop)}
/>;
return <div style={{ position: "relative", display: "inline-block" }}>
<VariableDropdownButton
{...props}
type="primary"
actions={actions}
loading={switching}
disabled={!(showStart || showStop)}
/>
<Popconfirm
title={<>
Killing this instance may leave you save and player data corrupted.
Are you sure you want to kill the instance?
</>}
open={popconfirm === "kill"}
placement="bottomRight"
okText="Kill"
okButtonProps={{ danger: true }}
onCancel={() => setPopconfirm(null)}
onConfirm={() => {
control.sendTo(
{ instanceId },
new lib.InstanceKillRequest(),
).catch(
notifyErrorHandler("Error killing instance")
).finally(actionFinish);
setPopconfirm(null);
}}
>
{/* Used to position the popup under the button */}
<span style={{ position: "absolute", right: 0, top: "100%", width: 1, height: 1 }} />
</Popconfirm>
</div>;
}
@@ -26,7 +26,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
import Link from "./Link";
const { logger } = lib;
+1 -1
View File
@@ -21,7 +21,7 @@ import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -8,6 +8,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
type PluginExtraProps = {
component: string;
search?: string;
instance?: lib.InstanceDetails;
user?: lib.UserDetails;
host?: lib.HostDetails;
+271 -71
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useContext, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { Button, Checkbox, Input, Popconfirm, Space, Spin, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -13,20 +13,165 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
/** Splits a permission name into parts, removing the last part */
function getGroupName(name: string) {
const parts = name.split(".");
return parts.length <= 2
? parts.slice(0, 2).join(".")
: parts.slice(0, parts.length - 1).join(".");
}
/** Converts a group name into Title Case with _ and . removed */
function formatGroupTitle(name: string) {
return name
.replace(/\./g, " / ")
.replace(/_/g, " ")
.split(" ")
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/** Check if a permission is dangerous, in the future we might have metadata for this */
function isDangerous(permission: lib.Permission) {
return ["delete", "kill", "stop", "revoke", "update", "core.admin"]
.some(word => permission.name.includes(word));
}
/** A single permission item inside a group; includes a checkbox, name, and description */
function PermissionItem({
permission, baseline, checked, disabled, onChange,
}: {
permission: lib.Permission;
checked: boolean;
baseline: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
const modified = checked !== baseline;
return (
<div style={{
marginBottom: 8,
padding: modified ? "4px 6px" : undefined,
background: modified ? "#2a1912" : undefined,
borderRadius: 4,
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)}>
<Space size={6}>
<span>{permission.title}</span>
{permission.grantByDefault && (
<Tooltip title="Granted by default">
<StarOutlined style={{ color: "#faad14" }} />
</Tooltip>
)}
{isDangerous(permission) && (
<Tooltip title="Dangerous permission">
<ExclamationCircleOutlined style={{ color: "#ff4d4f" }} />
</Tooltip>
)}
</Space>
</Checkbox>
</div>
<div style={{ marginLeft: 24, color: "#888", fontSize: 12 }}>
{permission.description}
</div>
</div>
);
}
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
function PermissionGroup({
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
}: {
groupName: string;
permissions: lib.Permission[];
canUpdate: boolean;
baselineState: Record<string, boolean>;
permissionState: Record<string, boolean>;
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setEdited?: (value: boolean) => void;
}) {
function setGroup(value: boolean) {
setPermissionState((prev: any) => {
const next = { ...prev };
for (let p of permissions) {
next[p.name] = value;
}
return next;
});
}
return (
<div style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<strong>{formatGroupTitle(groupName)}</strong>
<Space>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(true)}>Select all</Button>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(false)}>Clear</Button>
</Space>
</Space>
<div style={{ marginTop: 8 }}>
{permissions.map((p: any) => (
<PermissionItem
key={p.name}
permission={p}
checked={permissionState[p.name]}
baseline={baselineState[p.name]}
disabled={!canUpdate}
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
/>
))}
</div>
</div>
);
}
/** The role page view */
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
const params = useParams();
const roleId = Number(params.id);
let navigate = useNavigate();
let account = useAccount();
let control = useContext(ControlContext);
const navigate = useNavigate();
const account = useAccount();
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
let [edited, setEdited] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }];
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
const canUpdate = Boolean(account.hasPermission("core.role.update"));
useEffect(() => {
if (!role) {
return;
}
const base = Object.fromEntries(
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
);
setName(role.name);
setDescription(role.description);
setPermissionState(base);
setBaselineState(base);
}, [role, allPermissions]);
// If no role then display either the loading screen or the not found screen
if (!role) {
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
@@ -40,81 +185,136 @@ export default function RoleViewPage() {
</PageLayout>;
}
// TODO: Update displayed perms when role perms change and have not been edited locally
let initialValues = {
name: role["name"],
description: role["description"],
permissions: {
...Object.fromEntries([...lib.permissions.values()].map(perm => [
perm.name, [...role.permissions].includes(perm.name),
])),
},
};
// Apply the filter by searching all permission fields
const filtered = allPermissions.filter(p => {
const q = search.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.title.toLowerCase().includes(q)
|| p.description.toLowerCase().includes(q);
});
let canUpdate = account.hasPermission("core.role.update");
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
}
// Organise the permissions into groups
const groups = new Map<string, typeof filtered>();
for (let p of filtered) {
const g = getGroupName(p.name);
if (!groups.has(g)) {
groups.set(g, []);
}
groups.get(g)!.push(p);
}
control.send(
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
).then(() => {
setEdited(false);
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
// Sort permissions alphabetically within their groups
const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
for (let [, permissions] of sortedGroups) {
permissions.sort((a, b) => a.title.localeCompare(b.title));
}
// Check if any changes have been made to the permissions or role metadata
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
const metaChanged = name !== role.name || description !== role.description;
const edited = permissionChanged || metaChanged;
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
.then(() => {
setBaselineState(permissionState);
})
.catch(notifyErrorHandler("Error applying changes"));
}
/** Revert any pending changes */
function revertChanges() {
setPermissionState(baselineState);
if (role) {
setName(role.name);
setDescription(role.description);
}
}
return (
<PageLayout nav={[{ name: "Roles", path: "/roles" }, { name: role.name }]}>
<PageHeader
title={role.name}
extra={<Space wrap>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.RoleDeleteRequest(roleId)
).then(() => {
navigate("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
control.send(new lib.RoleDeleteRequest(roleId))
.then(() => navigate("/roles"))
.catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
<Button danger><DeleteOutlined /></Button>
</Popconfirm>}
</Space>}
/>
<Form.Item name="name" label="Name">
<Input disabled={!canUpdate}/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input disabled={!canUpdate}/>
</Form.Item>
{edited && (
<div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#2a1912",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 1000,
}}>
<span style={{ color: "#FFF" }}>
You have unsaved changes
</span>
<Space>
<Button onClick={revertChanges}>
Revert
</Button>
<Button type="primary" onClick={applyChanges}>
Apply
</Button>
</Space>
</div>
)}
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
</div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
</div>
<h3>Permissions</h3>
{[...lib.permissions.values()].map(({name, title, description}) => (
<Form.Item
name={["permissions", name]}
key={name}
label={title}
tooltip={description}
valuePropName="checked"
labelCol={{ sm: { span: 22, push: 2 }}}
wrapperCol={{ sm: { span: 2, pull: 22 }}}
labelAlign="left"
colon={false}
>
<Checkbox disabled={!canUpdate} />
</Form.Item>
))}
</Form>
<PluginExtra component="RoleViewPage" role={role} />
</PageLayout>;
<Input.Search placeholder="Search permissions" allowClear
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
<Space direction="vertical" style={{ width: "100%" }}>
{sortedGroups.map(([groupName, permissions]) => (
<PermissionGroup
key={groupName}
groupName={groupName}
permissions={permissions}
permissionState={permissionState}
baselineState={baselineState}
setPermissionState={setPermissionState}
canUpdate={canUpdate}
/>
))}
</Space>
<PluginExtra component="RoleViewPage" role={role} search={search}/>
</PageLayout>
);
}
+46 -6
View File
@@ -1,18 +1,21 @@
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Button, Dropdown, Layout, Menu, MenuProps, Tooltip } from "antd";
import UserOutlined from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
import { UserOutlined, DownloadOutlined } from "@ant-design/icons";
import { useAccount } from "../model/account";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
import { pages } from "../pages";
import { DraggingContext } from "../model/is_dragging";
import { saveJson } from "../util/save_file";
import { useAccount } from "../model/account";
import { DraggingContext } from "../model/is_dragging";
import webUiPackage from "../../package.json";
import { ControlConfig } from "@clusterio/lib";
import { DownloadOutlined } from "@ant-design/icons";
type MenuItem = Required<MenuProps>["items"][number];
@@ -43,6 +46,9 @@ export default function SiteLayout() {
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
const [dragging, setDragging] = useState(0);
const [aboutOpen, setAboutOpen] = useState(false);
const [wasAboutOpen, setWasAboutOpen] = useState(false);
const [changeLogOpen, setChangeLogOpen] = useState(false);
function SetSidebar(props: { path: string | null }) {
useEffect(() => {
@@ -61,6 +67,8 @@ export default function SiteLayout() {
"control.controller_url": (new URL(webRoot, document.location.href)).href,
});
saveJson("config-control.json", config.toJSON());
} else if (key === "about") {
setAboutOpen(true);
} else if (key === "logOut") {
account.logOut();
}
@@ -73,6 +81,7 @@ export default function SiteLayout() {
</Tooltip>,
key: "ctlConfig",
},
{ label: "About", key: "about" },
{ type: "divider" },
{ label: "Log out", danger: true, key: "logOut" },
],
@@ -141,11 +150,42 @@ export default function SiteLayout() {
}
}}
>
<AboutModal
open={aboutOpen}
onClose={() => {
setAboutOpen(false);
setWasAboutOpen(false);
}}
onOpenChangelog={() => {
setChangeLogOpen(true);
setWasAboutOpen(aboutOpen);
setAboutOpen(false);
}}
/>
<ChangeLogModal
open={changeLogOpen}
onClose={() => {
setChangeLogOpen(false);
setAboutOpen(wasAboutOpen);
}}
/>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{webUiPackage.version}</span>
<Tooltip
title="View changelog"
placement="bottom"
align={{ offset: [0, -10] }}
>
<span
className="site-version"
style={{ cursor: "pointer" }}
onClick={() => setChangeLogOpen(true)}
>
{webUiPackage.version}
</span>
</Tooltip>
<Menu
theme="dark"
mode="horizontal"
@@ -0,0 +1,184 @@
import React, { useRef } from "react";
import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
import { UserDetails } from "@clusterio/lib";
const { Text } = Typography;
export interface UserFilter {
search?: string;
admin?: boolean;
whitelisted?: boolean;
banned?: boolean;
}
function encodeFilter(value: UserFilter): string {
return JSON.stringify(value);
}
function decodeFilter(value: string): UserFilter {
try {
return JSON.parse(value);
} catch {
return {};
}
}
function triStateToSegmented(value?: boolean) {
if (value === true) { return "yes"; }
if (value === false) { return "no"; }
return "any";
}
function segmentedToTriState(value: string): boolean | undefined {
if (value === "yes") { return true; }
if (value === "no") { return false; }
return undefined;
}
function matchesTriState(value: boolean, filter?: boolean) {
return filter === undefined || value === filter;
}
export function onFilterUser(
value: string | number | boolean,
record: UserDetails
) {
const filter = decodeFilter(String(value));
// Name filter
if (filter.search) {
if (!record.name.toLowerCase().includes(filter.search.toLowerCase())) {
return false;
}
}
// Status filter
return matchesTriState(record.isAdmin, filter.admin)
&& matchesTriState(record.isWhitelisted, filter.whitelisted)
&& matchesTriState(record.isBanned, filter.banned);
}
export function Username({
user,
withStatus = false,
} : {
user: UserDetails,
withStatus: boolean,
}) {
return (
<Space>
{user.name}
{withStatus && (
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
)}
</Space>
);
}
export function useUserFilter(withStatus?: boolean) {
const searchInput = useRef<InputRef>(null);
function filterDropdown({
selectedKeys,
setSelectedKeys,
confirm,
clearFilters,
}: {
selectedKeys: string[];
setSelectedKeys: (keys: string[]) => void;
confirm: FilterDropdownProps["confirm"];
clearFilters: FilterDropdownProps["clearFilters"];
}) {
const filter: UserFilter = selectedKeys[0]
? decodeFilter(selectedKeys[0])
: {};
function update(next: Partial<UserFilter>) {
const encoded = encodeFilter({ ...filter, ...next });
if (encoded === "{}") {
clearFilters?.({ closeDropdown: false });
} else {
setSelectedKeys([encoded]);
}
confirm({ closeDropdown: false });
}
return (
<div style={{ padding: 8, width: 240 }} onKeyDown={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
ref={searchInput}
placeholder="Search username"
value={filter.search}
allowClear
onSearch={() => confirm({ closeDropdown: true })}
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
{withStatus &&<>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.admin)}
onChange={value => update({ admin: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Admin</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.whitelisted)}
onChange={value => update({ whitelisted: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Whitelist</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.banned)}
onChange={value => update({ banned: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Banned</Text>
</Space>
</>}
</Space>
</div>
);
}
const filterDropdownProps = {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
}
},
};
return { filterDropdown, filterDropdownProps };
}
+13 -4
View File
@@ -19,6 +19,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import UsersTable from "./UsersTable";
import { InputInstanceWithAll } from "./InputInstance";
function CreateUserButton() {
let control = useContext(ControlContext);
@@ -366,18 +367,26 @@ function BulkUserActionButton() {
}
export default function UsersPage() {
let account = useAccount();
const account = useAccount();
const [instanceId, setInstanceId] = useState<number | null>(null);
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={<Space>
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
&& <InputInstanceWithAll
value={instanceId}
onChange={value => setInstanceId(value as number)}
fieldDefinition={{ optional: true } as any}
disabled={false}
/>}
{account.hasPermission("core.user.create") && <CreateUserButton />}
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
? <BulkUserActionButton /> : undefined}
&& <BulkUserActionButton />}
</Space>}
/>
<UsersTable />
<UsersTable instanceId={instanceId ?? undefined}/>
<PluginExtra component="UsersPage" />
</PageLayout>;
}
+97 -171
View File
@@ -1,6 +1,5 @@
import React, { useRef } from "react";
import { Table, Tag, Space, Input, InputRef } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import React from "react";
import { Table, Tag } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
@@ -9,20 +8,16 @@ import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import { formatDuration } from "../util/time_format";
import {
formatFirstSeen,
formatLastSeen,
sortFirstSeen,
sortLastSeen,
useUsers,
calculateFirstSeen, formatFirstSeen, sortFirstSeen,
calculateLastSeen, formatLastSeen, sortLastSeen,
useUsers, getUserStats, isUserOnline,
} from "../model/user";
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
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.
*/
/** Optional instance id. If provided, stats will be filtered to that instance only */
instanceId?: number;
/** Show only players that are currently online, works with instanceId to show online of a particular instance */
onlyOnline?: boolean;
@@ -35,71 +30,71 @@ export interface UsersTableProps {
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 [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
const navigate = useNavigate();
const searchInput = useRef<InputRef>(null);
const [users] = useUsers();
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
const data = [...users.values()];
// Determine online predicate based on instanceId
const isUserOnline = (user: lib.UserDetails) => {
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 durationFilters = [
{ text: "Online", value: "online" },
{ text: "24h", value: "24h" },
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
{ text: "Anytime", value: "any" },
];
function onFilterDuration(
value: string,
record: lib.UserDetails,
calculateDuration: (user: lib.UserDetails, instanceId?: number) => number | undefined,
) {
const online = isUserOnline(record, instanceId);
if (value === "online") {
return online;
}
// Online players should appear in all buckets
if (online) {
return true;
}
const ts = calculateDuration(record, instanceId);
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;
case "any":
return true;
default:
return false;
}
}
const columns: any[] = [
{
title: "Name",
key: "name",
render: (_: any, user: lib.UserDetails) => (
<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>
<Username user={user} withStatus />
),
defaultSortOrder: "ascend",
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
onFilter: (value: string | number | boolean, record: lib.UserDetails) => 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>
),
onFilter: onFilterUser,
filterDropdownProps,
filterDropdown,
},
{
title: "Roles",
@@ -117,123 +112,53 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
))
),
},
];
// Helper to get last seen timestamp
function getLastSeenTimestamp(user: lib.UserDetails): 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.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
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.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.joinCount ?? 0;
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
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.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
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.UserDetails) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs)
: null),
// eslint-disable-next-line max-len
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
responsive: ["lg"],
},
{
title: "First seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
},
);
}
columns.push(
{
title: "Last seen",
title: "Online Time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => {
const userStats = getUserStats(user, instanceId);
return userStats?.onlineTimeMs ? formatDuration(userStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
const statsB = getUserStats(b, instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
},
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => (
getUserStats(user, instanceId)?.joinCount ?? 0
),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
const statsB = getUserStats(b, instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
},
},
{
title: "First Seen",
key: "firstSeen",
filterMultiple: false,
filters: durationFilters,
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateFirstSeen),
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b, instanceId, instanceId),
},
{
title: "Last Seen",
key: "lastSeen",
filterMultiple: false,
filters: durationFilters,
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.UserDetails) => {
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;
}
},
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
responsive: ["lg"],
// Responsive breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847
// responsive: ["lg"],
},
);
];
const defaultPagination = pagination === undefined
? {
@@ -252,6 +177,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
pagination={defaultPagination}
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
onRow={(user) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("a")) {
+3
View File
@@ -0,0 +1,3 @@
<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 0C7.16 0 0 7.16 0 16C0 23.08 4.58 29.06 10.94 31.18C11.74 31.32 12.04 30.84 12.04 30.42C12.04 30.04 12.02 28.78 12.02 27.44C8 28.18 6.96 26.46 6.64 25.56C6.46 25.1 5.68 23.68 5 23.3C4.44 23 3.64 22.26 4.98 22.24C6.24 22.22 7.14 23.4 7.44 23.88C8.88 26.3 11.18 25.62 12.1 25.2C12.24 24.16 12.66 23.46 13.12 23.06C9.56 22.66 5.84 21.28 5.84 15.16C5.84 13.42 6.46 11.98 7.48 10.86C7.32 10.46 6.76 8.82 7.64 6.62C7.64 6.62 8.98 6.2 12.04 8.26C13.32 7.9 14.68 7.72 16.04 7.72C17.4 7.72 18.76 7.9 20.04 8.26C23.1 6.18 24.44 6.62 24.44 6.62C25.32 8.82 24.76 10.46 24.6 10.86C25.62 11.98 26.24 13.4 26.24 15.16C26.24 21.3 22.5 22.66 18.94 23.06C19.52 23.56 20.02 24.52 20.02 26.02C20.02 28.16 20 29.88 20 30.42C20 30.84 20.3 31.34 21.1 31.18C27.42 29.06 32 23.06 32 16C32 7.16 24.84 0 16 0V0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

+12 -5
View File
@@ -4,20 +4,27 @@ import ControlContext from "../components/ControlContext";
import * as lib from "@clusterio/lib";
function getInstanceStats(user: lib.UserDetails, instanceId?: number) {
export function getUserStats(user: lib.UserDetails, instanceId?: number) {
if (instanceId === undefined) {
return user.playerStats;
}
return user.instanceStats.get(instanceId);
}
function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getInstanceStats(user, instanceId);
export function isUserOnline(user: lib.UserDetails, instanceId?: number) {
if (instanceId === undefined) {
return user.instances && user.instances.size > 0;
}
return user.instances && user.instances.has(instanceId);
}
export function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getUserStats(user, instanceId);
return stats?.firstJoinAt?.getTime();
}
function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getInstanceStats(user, instanceId);
export function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getUserStats(user, instanceId);
if (!stats) {
return undefined;
}
+27
View File
@@ -409,3 +409,30 @@ tr.ant-table-row.deleted {
border-radius: 8px;
max-width: 384px;
}
/**
* Tri-state segmented control:
* represents include, exclude, or no filter
*/
.seg-yes {
color: #389e0d;
}
.seg-no {
color: #cf1322;
}
.ant-segmented-item-selected.seg-yes {
background-color: #389e0d !important;
color: white !important;
}
.ant-segmented-item-selected.seg-no {
background-color: #cf1322 !important;
color: white !important;
}
.seg-yes:hover {
background-color: #306317;
}
.seg-no:hover {
background-color: #7a2e2f;
}
+8 -8
View File
@@ -60,14 +60,14 @@ export class Control extends lib.Link {
logHandlers: Map<lib.LogFilter, logHandler[]> = new Map();
/** Updates handled by the subscription service */
systems = new lib.EventSubscriber(lib.SystemInfoUpdateEvent, this);
hosts = new lib.EventSubscriber(lib.HostUpdatesEvent, this);
instances = new lib.EventSubscriber(lib.InstanceDetailsUpdatesEvent, this);
saves = new lib.EventSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
modPacks = new lib.EventSubscriber(lib.ModPackUpdatesEvent, this);
mods = new lib.EventSubscriber(lib.ModUpdatesEvent, this);
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
systems = new lib.MapSubscriber(lib.SystemInfoUpdateEvent, this);
hosts = new lib.MapSubscriber(lib.HostUpdatesEvent, this);
instances = new lib.MapSubscriber(lib.InstanceDetailsUpdatesEvent, this);
saves = new lib.MapSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
modPacks = new lib.MapSubscriber(lib.ModPackUpdatesEvent, this);
mods = new lib.MapSubscriber(lib.ModUpdatesEvent, this);
users = new lib.MapSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.MapSubscriber(lib.RoleUpdatesEvent, this);
/** Cache for factorio versions to avoid repeat calls to the controller */
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
+23
View File
@@ -1,9 +1,20 @@
"use strict";
const os = require("os");
const { execSync } = require("child_process");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const TerserPlugin = require("terser-webpack-plugin");
function getCommitHash() {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
module.exports = (env = {}, argv = {}) => ({
mode: argv.mode ?? "development",
devtool: argv.mode === "production" ? "source-map" : "eval-source-map",
@@ -29,6 +40,10 @@ module.exports = (env = {}, argv = {}) => ({
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
"process.env.COMMIT_HASH": JSON.stringify(getCommitHash()),
"process.env.BUILD_DATE": JSON.stringify(new Date().toISOString()),
"process.env.BUILD_ENV": JSON.stringify(process.env.NODE_ENV ?? "dev"),
"process.env.BUILD_OS": JSON.stringify(os.platform()),
}),
// Make sure ant-design icons use the ES variant
@@ -114,6 +129,14 @@ module.exports = (env = {}, argv = {}) => ({
test: /\.png$/,
type: "asset/resource",
},
{
test: /\.svg$/,
type: "asset/resource",
},
{
test: /\.md$/,
type: "asset/source",
},
],
},
resolve: {
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,19 +23,19 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*"
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/lib": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
@@ -16,6 +16,8 @@ local get_script_data = require("modules/inventory_sync/get_script_data")
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")
local v2_remote_controller = compat.version_ge("2.0.0")
-- Returns true if the player is currently in a cutscene
local function is_in_cutscene(player)
return player.controller_type == defines.controllers.cutscene
@@ -116,9 +118,9 @@ function inventory_sync.deserialize_player(player, finished_record)
-- Transfer items from stashed inventory
if stashed_corpse then
local main = player.get_main_inventory()
local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
if main then
if player.character then
local main = player.character.get_main_inventory()
for i = 1, #stash do
-- Try transferring a stack
local source = stash[i]
@@ -312,7 +314,7 @@ function inventory_sync.sync_player(acquire_response)
end
function inventory_sync.finish_download(player, finished_record)
local status, result = pcall(inventory_sync.deserialize_player, player, finished_record)
local status, result = xpcall(inventory_sync.deserialize_player, debug.traceback, player, finished_record)
if not status then
log("ERROR: Deserializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Deserializing player data failed: " .. result)
@@ -382,7 +384,7 @@ inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
end
player_record.generation = player_record.generation + 1
local status, result = pcall(inventory_sync.serialize_player, player, player_record)
local status, result = xpcall(inventory_sync.serialize_player, debug.traceback, player, player_record)
if not status then
log("ERROR: Serializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Serializing player data failed: " .. result)
@@ -440,8 +442,13 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener
-- player data is downloading
if player_record.sync then
-- Store original position to teleport back to
record.surface = player.surface
record.position = player.position
if v2_remote_controller then
record.surface = player.physical_surface
record.position = player.physical_position
else
record.surface = player.surface
record.position = player.position
end
if player.driving then
record.vehicle = player.vehicle
end
@@ -1,9 +1,18 @@
local compat = require("modules/clusterio/compat")
local can_enter_vehicle = {
[defines.controllers.character] = true,
[defines.controllers.god] = true,
[defines.controllers.editor] = true,
}
if defines.controllers.remote then
-- Does not exist in 1.1, so we need this nil check
can_enter_vehicle[defines.controllers.remote] = true
end
local v2_surface_platform = compat.version_ge("2.0.0")
--- @param player LuaPlayer
--- @param record table
return function(player, record)
@@ -20,6 +29,8 @@ return function(player, record)
player.teleport(safe_position, player.surface)
end
end
elseif v2_surface_platform and record.surface and record.surface.platform and can_enter_vehicle[player.controller_type] then
player.enter_space_platform(record.surface.platform)
elseif record.surface and record.position then
player.teleport(record.position, record.surface)
end
+67 -36
View File
@@ -6,6 +6,7 @@ local serialize = {}
local v2_logistic_api = compat.version_ge("2.0.0")
local v2_storage_api = compat.version_ge("2.0.0")
local v2_remote_controller = compat.version_ge("2.0.0")
local recipe_notifications_api = compat.version_ge("2.0.67")
function serialize.serialize_inventories(source, inventories)
@@ -203,7 +204,7 @@ function serialize.serialize_crafting_queue(player)
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Save current items
local inventory = player.get_main_inventory()
local inventory = player.character.get_main_inventory()
local old_items = inventory.get_contents()
local crafting_queue_progress = player.crafting_queue_progress
@@ -317,7 +318,7 @@ function serialize.serialize_crafting_queue(player)
end
function serialize.deserialize_crafting_queue(player, serialized)
local inventory = player.get_main_inventory()
local inventory = player.character.get_main_inventory()
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
@@ -460,6 +461,11 @@ function serialize.serialize_player(player, failed_deserialization)
ticks_to_respawn = player.ticks_to_respawn,
}
-- In 2.0 we want to sync the physical controller to ignore remote view
if v2_remote_controller then
serialized.controller = controller_to_name[player.physical_controller_type]
end
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
if player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then
player.ticks_to_respawn = nil -- Respawn now
@@ -482,7 +488,9 @@ function serialize.serialize_player(player, failed_deserialization)
end
-- Serialize non-character inventories
if player.controller_type == defines.controllers.god then
if player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
then
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
end
@@ -510,6 +518,26 @@ function serialize.serialize_player(player, failed_deserialization)
return serialized
end
--- Ensure a player has a character, works from any controller type
--- @param player LuaPlayer
--- @return LuaEntity
local function ensure_character(player)
-- Do nothing if the player has a valid character
local character = player.character
if character and character.valid then
return character
end
-- Switch to god controller if create_character would fail
if player.controller_type ~= defines.controllers.god then
player.set_controller{ type = defines.controllers.god }
end
-- Create and return the character
assert(player.create_character(), "Failed to create character")
return player.character
end
--- @class FailedDeserializationPlayerData
--- @field recipe_notifications string[]?
@@ -519,46 +547,49 @@ end
function serialize.deserialize_player(player, serialized)
local failed_deserialization = {}
if player.controller_type ~= defines.controllers[serialized.controller] or serialized.controller == "ghost" then
-- If targeting the character or ghost controller then create a character
if serialized.controller == "character" or serialized.controller == "ghost" then
if player.controller_type == defines.controllers.ghost or player.controller_type == defines.controllers.spectator then
player.set_controller({ type = defines.controllers.god })
end
if player.controller_type == defines.controllers.god then
player.create_character()
local target_controller = defines.controllers[serialized.controller]
if player.controller_type ~= target_controller or serialized.controller == "ghost" then
if serialized.controller == "character" then
-- Create a character but do not destroy an existing one
ensure_character(player)
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
-- The ghost state stores hidden logistic and filters which are only accessible in the character controller
if serialized.controller == "ghost" then
elseif serialized.controller == "ghost" then
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
local character = ensure_character(player)
if serialized.personal_logistic_slots then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
if serialized.inventories then
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
local character = player.character
if serialized.ticks_to_respawn then
player.ticks_to_respawn = serialized.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
player.ticks_to_respawn = 0
player.set_controller({ type = defines.controllers.god })
player.set_controller({ type = defines.controllers.ghost })
end
if character and character.valid then
character.destroy()
end
end
if serialized.ticks_to_respawn then
player.ticks_to_respawn = serialized.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
player.ticks_to_respawn = 0
player.set_controller{ type = defines.controllers.god }
player.set_controller{ type = defines.controllers.ghost }
end
if character and character.valid then
character.destroy()
end
elseif serialized.controller ~= "remote" then
-- All other controllers should not have a character
local character = player.character
if character and character.valid then
character.destroy()
end
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
else
-- Targeting the god or spectator controller, if coming from the
-- character controller then destroy the character
if player.controller_type == defines.controllers.character then
player.character.destroy()
end
-- Switching to god or spectator is a matter of setting the controller
if player.controller_type ~= defines.controllers[serialized.controller] then
player.set_controller({ type = defines.controllers[serialized.controller] })
end
error("Remote should not be 'serialized.controller'")
end
end
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -23,19 +23,19 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*"
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,10 +23,10 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
"@clusterio/controller": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:",
@@ -34,10 +34,10 @@
"express": "catalog:"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@types/express": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
+5 -3
View File
@@ -89,9 +89,11 @@ function LoginForm(props: LoginFormProps) {
Start Factorio, join one of the following multiplayer servers
and type <Text keyboard>/web-login</Text> into the chat:
</Paragraph>
<ul>
{servers.map(text => <li key={text}>{text}</li>)}
</ul>
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16 }}>
<ul>
{servers.map(text => <li key={text}>{text}</li>)}
</ul>
</div>
</li>
<li>
<Paragraph>Enter the code displayed in the in-game dialog here:</Paragraph>
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,19 +23,19 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*"
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,14 +23,14 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -23,19 +23,19 @@
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*"
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/express": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
+1
View File
@@ -22,6 +22,7 @@ catalog:
jszip: ^3.10.1
react: ^18.2.0
react-dom: ^18.2.0
react-markdown: ^10.1.0
react-router-dom: ^6.29.0
set-blocking: ^2.0.0
typescript: ^5.7.3
+5
View File
@@ -95,6 +95,11 @@ export interface Issue {
html_url: string,
state: string,
body?: string | null,
user: {
login: string,
url: string,
html_url: string,
}
pull_request?: {
url: string,
html_url: string,
+23 -6
View File
@@ -187,11 +187,17 @@ async function fetchIssuesUpdatedSince(since: string) {
);
}
function printMarkdown(changelog: Changelog, issues: Issue[], refText: (issue: Issue) => string) {
function printMarkdown(
changelog: Changelog,
issues: Issue[],
users: Issue["user"][],
refText: (issue: Issue) => string,
userText: (user: Issue["user"]) => string,
) {
const sections = new Set([...changelogSections, ...Object.keys(changelog)]);
for (const section of sections) {
if (section in changelog) {
console.log(`### ${section}`);
console.log(`### ${section}\n`);
const entries = changelog[section];
console.log(entries.map(text => (
`- ${text.replaceAll(/#(\d+)/g, (ref, idAsText) => {
@@ -209,6 +215,9 @@ function printMarkdown(changelog: Changelog, issues: Issue[], refText: (issue: I
console.log();
}
}
console.log(`Many thanks to the following for contributing to this release:`);
console.log(users.map(userText).join("\n"))
}
async function main() {
@@ -222,13 +231,21 @@ async function main() {
));
const changelog = changelogFromPullRequests(pullRequests, issues);
const users = [...new Map(pullRequests.map(issue => [issue.user.login, issue.user])).values()]
console.log();
console.log("=== Github markdown ===");
printMarkdown(changelog, issues, issue => `${repository}#${issue.number}`);
console.log("=== Github Release Markdown ===");
printMarkdown(changelog, issues, users,
issue => `${repository}#${issue.number}`,
user => `@${user.login}`,
);
console.log();
console.log("=== Discord markdown ===");
printMarkdown(changelog, issues, issue => `[#${issue.number}](<${issue.html_url}>)`);
console.log("=== Discord / Changelog Markdown ===");
printMarkdown(changelog, issues, users,
issue => `[#${issue.number}](<${issue.html_url}>)`,
user => `[@${user.login}](<${user.html_url}>)`
);
}
if (import.meta.main) {
+5 -4
View File
@@ -1655,12 +1655,13 @@ describe("Integration of Clusterio", function() {
});
it("should grant default permissions with --grant-default", async function() {
await execCtl("role edit new --grant-default");
let roles = await getControl().send(new lib.RoleListRequest());
const control = getControl();
let roles = await control.send(new lib.RoleListRequest());
let newRole = roles.find(role => role.name === "new");
let defaultPermissions = [...lib.permissions.values()]
const permissions = await control.send(new lib.PermissionListRequest());
const defaultPermissions = permissions
.filter(p => p.grantByDefault)
.map(p => p.name)
;
.map(p => p.name);
assert.deepEqual(new Set(newRole.permissions), new Set(defaultPermissions));
});
});
+10 -8
View File
@@ -240,14 +240,16 @@ describe("lib/config/definitions/validators", function() {
});
describe("plugin.load_plugin", function() {
const initialConfigFields = {
"global_chat.load_plugin": false,
"inventory_sync.load_plugin": false,
"player_auth.load_plugin": false,
"research_sync.load_plugin": false,
"statistics_exporter.load_plugin": false,
"subspace_storage.load_plugin": false,
};
const initialConfigFields = {};
before(function() {
for (const fieldName of Object.keys(InstanceConfig.fieldDefinitions)) {
if (fieldName.endsWith("load_plugin")) {
initialConfigFields[fieldName] = false;
}
}
});
it("should validate against factorio.enable_save_patching", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
-703
View File
@@ -1,703 +0,0 @@
"use strict";
const events = require("events");
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { MockController, MockConnector, MockControl } = require("../mock");
const addr = lib.Address.fromShorthand;
describe("lib/subscriptions", function() {
class UnregisteredEvent {
static type = "event";
static src = ["controller", "control"];
static dst = ["controller", "control"];
static permission = null;
}
class RegisteredEvent {
static type = "event";
static src = ["controller", "control"];
static dst = ["controller", "control"];
static permission = null;
constructor(updates = []) {
this.updates = updates;
}
}
lib.Link.register(RegisteredEvent);
class StringPermissionEvent {
static type = "event";
static src = ["controller", "control"];
static dst = ["controller", "control"];
static permission = "StringPermission";
}
lib.Link.register(StringPermissionEvent);
class FunctionPermissionEvent {
static type = "event";
static src = ["controller", "control"];
static dst = ["controller", "control"];
static permission(user, message) {
user.checkPermission("FunctionPermission");
};
}
lib.Link.register(FunctionPermissionEvent);
class MockUser {
checkPermission(permission) {
this.lastPermissionCheck = permission;
}
}
describe("class SubscriptionRequest", function() {
describe("permission()", function() {
function newSubscriptionRequestMessage(request) {
return new lib.MessageRequest(
0,
addr({ controlId: 0 }),
addr("controller"),
lib.SubscriptionRequest.name,
request.toJSON()
);
}
it("should do nothing when the event has no permission property", function() {
const mockUser = new MockUser();
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request));
assert.equal(mockUser.lastPermissionCheck, undefined);
});
it("should check user permission when the permission property is a string", function() {
const mockUser = new MockUser();
const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true);
lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request));
assert.equal(mockUser.lastPermissionCheck, "StringPermission");
});
it("should call the permission property when it is a function", function() {
const mockUser = new MockUser();
const request = new lib.SubscriptionRequest(FunctionPermissionEvent.name, true);
lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request));
assert.equal(mockUser.lastPermissionCheck, "FunctionPermission");
});
});
it("should be round trip json serialisable", function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true, 123);
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 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),
new Error(`Unregistered Event class ${UnregisteredEvent.name}`)
);
});
});
describe("class SubscriptionController", function() {
let mockController, subscriptions;
const connectorSetupDate = [{
id: 0,
request: new lib.SubscriptionRequest(RegisteredEvent.name, true),
dst: addr({ controlId: 0 }),
src: addr("controller"),
}, {
id: 1,
request: new lib.SubscriptionRequest(RegisteredEvent.name, true),
dst: addr({ controlId: 1 }),
src: addr("controller"),
}];
function getLink(id) {
return mockController.wsServer.controlConnections.get(id);
}
function onceConnectorSend(id) {
return events.once(getLink(id).connector, "send");
}
function assertLastEvent(connectorId, Event) {
const link = getLink(connectorId);
const lastMessage = link.connector.sentMessages.at(-1);
const name = Event.plugin ? `${Event.plugin}:${Event.name}` : Event.name;
assert.equal(lastMessage.name, name);
}
function assertNoEvent(connectorId) {
const link = getLink(connectorId);
assert.equal(link.connector.sentMessages.length, 0);
}
beforeEach(function() {
mockController = new MockController();
mockController.wsServer = {
controlConnections: new Map(connectorSetupDate.map(function (connectorData) {
return [connectorData.id, new lib.Link(new MockConnector(connectorData.src, connectorData.dst))];
})),
};
subscriptions = new lib.SubscriptionController(mockController);
});
describe("handle()", function() {
it("should accept registered events", function() {
subscriptions.handle(RegisteredEvent);
});
it("should not accept unregistered events", function() {
assert.throws(
() => subscriptions.handle(UnregisteredEvent),
new Error(`Unregistered Event class ${UnregisteredEvent.name}`)
);
});
it("should not accept events already handled by the class", function() {
subscriptions.handle(RegisteredEvent);
assert.throws(
() => subscriptions.handle(RegisteredEvent),
new Error(`Event ${RegisteredEvent.name} is already registered`)
);
});
});
describe("broadcast()", function() {
beforeEach(function() {
subscriptions.handle(RegisteredEvent);
for (let connectorData of connectorSetupDate) {
subscriptions.handleRequest(
getLink(connectorData.id), connectorData.request, connectorData.dst, connectorData.src
);
}
});
it("should not accept unregistered events", function() {
assert.throws(
() => subscriptions.broadcast(new UnregisteredEvent()),
new Error(`Unregistered Event class ${UnregisteredEvent.name}`)
);
});
it("should not accept events not handled by the class", function() {
assert.throws(
() => subscriptions.broadcast(new StringPermissionEvent()),
new Error(`Event ${StringPermissionEvent.name} is not a registered as subscribable`)
);
});
it("should notify all links who subscribed", async function() {
subscriptions.broadcast(new RegisteredEvent());
await Promise.all([onceConnectorSend(0), onceConnectorSend(1)]);
assertLastEvent(0, RegisteredEvent); // RegisteredEvent: All
assertLastEvent(1, RegisteredEvent); // RegisteredEvent: All
});
it("should not notify a link who unsubscribed", async function() {
const connectorData = connectorSetupDate[0];
const request = new lib.SubscriptionRequest(RegisteredEvent.name, false);
await subscriptions.handleRequest(
getLink(connectorData.id), request, connectorData.dst, connectorData.src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
assertLastEvent(1, RegisteredEvent);
});
it("should not notify links which are closed or closing", async function() {
const link = getLink(0);
link.connector.closing = true;
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
assertLastEvent(1, RegisteredEvent);
});
});
describe("unsubscribe()", function() {
beforeEach(function() {
subscriptions.handle(RegisteredEvent);
for (let connectorData of connectorSetupDate) {
subscriptions.handleRequest(
getLink(connectorData.id), connectorData.request, connectorData.dst, connectorData.src
);
}
});
it("should remove an active subscription link", async function() {
const link = getLink(0);
subscriptions.unsubscribeLink(link);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
assertLastEvent(1, RegisteredEvent);
});
it("should remove an active subscription address", async function() {
const link = getLink(0);
subscriptions.unsubscribeAddress(connectorSetupDate[0].dst);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
assertLastEvent(1, RegisteredEvent);
});
});
describe("handleRequest()", function() {
beforeEach(function() {
subscriptions.handle(RegisteredEvent);
});
it("should not accept subscriptions to unregistered events", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
request.eventName = UnregisteredEvent.name;
await assert.rejects(
subscriptions.handleRequest(getLink(0), request, addr({ controlId: 0 }), addr("controller")),
new Error(`Event ${UnregisteredEvent.name} is not a registered event`)
);
});
it("should not accept subscriptions to events not handled by the class", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
request.eventName = StringPermissionEvent.name;
await assert.rejects(
subscriptions.handleRequest(getLink(0), request, addr({ controlId: 0 }), addr("controller")),
new Error(`Event ${StringPermissionEvent.name} is not a registered as subscribable`)
);
});
it("should accept a subscription", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(0);
assertLastEvent(0, RegisteredEvent);
});
it("should accept an unsubscription", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(0);
assertLastEvent(0, RegisteredEvent);
const link = getLink(0);
link.connector.sentMessages.pop();
const unsubRequest = new lib.SubscriptionRequest(RegisteredEvent.name, false);
await subscriptions.handleRequest(
getLink(0), unsubRequest, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
assertNoEvent(0);
});
it("should accept a response with an event replay when returned by the handler", async function() {
subscriptions.handle(StringPermissionEvent, async function() {
return new StringPermissionEvent();
});
const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
await onceConnectorSend(0);
assertLastEvent(0, StringPermissionEvent);
const link = getLink(0);
const lastMessage = link.connector.sentMessages.pop();
const eventEntry = lib.Link._eventsByClass.get(StringPermissionEvent);
const response = eventEntry.eventFromJSON(lastMessage.data);
assert.deepEqual(response, new StringPermissionEvent());
});
it("should accept a response with null when returned by the handler", async function() {
subscriptions.handle(StringPermissionEvent, async function() {
return null;
});
const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
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);
});
});
});
});
describe("class EventSubscriber", function() {
let mockControl, registeredEvent;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(
addr({ controlId: 0 }),
addr("controller"),
));
registeredEvent = new lib.EventSubscriber(RegisteredEvent, mockControl);
});
function assertLastMessage(message) {
const Message = message.constructor;
const lastMessage = mockControl.connector.sentMessages.at(-1);
const name = Message.plugin ? `${Message.plugin}:${Message.name}` : Message.name;
assert.equal(lastMessage.name, name);
assert.deepEqual(lastMessage.data, message);
}
function assertNoMessageSent() {
assert.equal(mockControl.connector.sentMessages.length, 0, "messages were sent");
}
describe("constructor()", function() {
it("should not accept unregistered events", function() {
assert.throws(
() => new lib.EventSubscriber(UnregisteredEvent, mockControl),
new Error(`Unregistered Event class ${UnregisteredEvent.name}`)
);
});
it("should handle the provided event", function() {
assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true);
});
});
describe("subscribe()", function() {
it("should allow subscriptions to an event", async function() {
let calledWith = null;
let calledWithTwo = null;
const item = { id: 1, updatedAtMs: 1, isDeleted: false };
const eventUpdate = new RegisteredEvent([item]);
registeredEvent.subscribe(function(event, synced) { calledWith = [event, synced]; });
registeredEvent.subscribe(function(event, synced) { calledWithTwo = [event, synced]; });
await registeredEvent._handleEvent(eventUpdate);
assert.deepEqual(calledWith, [eventUpdate, true]);
assert.deepEqual(calledWithTwo, [eventUpdate, true]);
});
it("should allow unsubscribing from an event", async function() {
let called = false;
let event = new RegisteredEvent();
function callback() { called = true; }
const unsubscribe = registeredEvent.subscribe(callback);
await registeredEvent._handleEvent(event);
assert.deepEqual(called, true);
called = false;
event = new RegisteredEvent();
unsubscribe();
// Wait for unsubscribe to happen.
await new Promise(resolve => setImmediate(resolve));
await registeredEvent._handleEvent(event);
assert.equal(called, false);
});
it("should not throw error on incorrectly unsubscribing multiple times", async function() {
const unsubscribe = registeredEvent.subscribe(() => {});
unsubscribe();
unsubscribe();
// Wait for unsubscribe to happen.
await new Promise(resolve => setImmediate(resolve));
});
});
describe("getSnapshot()", function() {
it("should give an immutable copy of the tracked values", async function() {
const update1 = [{ id: 1, updatedAtMs: 1, isDeleted: false }];
const update2 = [
{ id: 1, updatedAtMs: 2, isDeleted: false },
{ id: 2, updatedAtMs: 3, isDeleted: false },
];
const update3 = [{ id: 1, updatedAtMs: 4, isDeleted: true }];
const snap0 = registeredEvent.getSnapshot();
assert.deepEqual(snap0, [new Map(), false]);
await registeredEvent._handleEvent(new RegisteredEvent(update1));
const snap1 = registeredEvent.getSnapshot();
assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]);
await registeredEvent._handleEvent(new RegisteredEvent(update2));
const snap2 = registeredEvent.getSnapshot();
assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]);
await registeredEvent._handleEvent(new RegisteredEvent(update3));
const snap3 = registeredEvent.getSnapshot();
assert.deepEqual(snap3, [new Map([[2, update2[1]]]), false]);
// validate previous snapshots were not modified
assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]);
assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]);
assert.deepEqual(snap0, [new Map(), false]);
});
});
describe("handleConnectionEvent", function() {
it("should be called on connector connect event", async function() {
registeredEvent.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.pop();
registeredEvent.control.connector.emit("connect");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
mockControl.connector.sentMessages.pop();
registeredEvent.handleConnectionEvent("connect");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
mockControl.connector.sentMessages.pop();
registeredEvent.handleConnectionEvent("resume");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
});
it("should set synced to false on connection close", async function() {
registeredEvent.synced = true;
registeredEvent.control.connector.emit("close");
assert(!registeredEvent.synced, "synced was not set to false during close event");
registeredEvent.synced = true;
registeredEvent.handleConnectionEvent("drop");
assert(!registeredEvent.synced, "synced was not set to false during drop");
registeredEvent.synced = true;
registeredEvent.handleConnectionEvent("close");
assert(!registeredEvent.synced, "synced was not set to false during close");
});
it("should invoke callbacks on connector close", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(false)
));
await new Promise(resolve => setImmediate(resolve));
assert(registeredEvent.synced, "synced was not set to true before close");
calledWith = undefined;
registeredEvent.control.connector.emit("close");
assert(!registeredEvent.synced, "synced was not set to false");
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [null, false]);
});
});
describe("_updateSubscription()", function() {
it("should do nothing if connection is not valid", async function() {
registeredEvent.control.connector.valid = false;
registeredEvent.subscribe(() => true);
await new Promise(resolve => setImmediate(resolve));
assertNoMessageSent();
});
it("should correctly request a subscription", async function() {
registeredEvent.subscribe(() => true);
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
});
it("should correctly request an unsubscription", async function() {
function callback() {}
const unsubscribe = registeredEvent.subscribe(callback);
await events.once(mockControl.connector, "send");
unsubscribe();
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, false, -1));
});
it("should set synced and invoke callbacks when request is responded to", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
assert(!registeredEvent.synced, "synced was set before response");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(false)
));
await new Promise(resolve => setImmediate(resolve));
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [null, true]);
assert(registeredEvent.synced, "synced was not set");
});
it("should set synced and invoke callbacks when request is responded to with data", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
assert(!registeredEvent.synced, "synced was set before response");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(true)
));
await new Promise(resolve => setImmediate(resolve));
assert(!registeredEvent.synced, "synced was set before update");
assert(calledWith === undefined, "callback called before update");
const eventUpdate = new RegisteredEvent();
registeredEvent._handleEvent(eventUpdate);
assert(registeredEvent.synced, "synced was not set");
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [eventUpdate, true]);
});
});
});
});
File diff suppressed because it is too large. Load diff