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
48 changed files with 2640 additions and 1181 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
+43
View File
@@ -31,6 +31,49 @@ 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
+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 -3
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"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",
@@ -34,7 +34,7 @@
"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",
+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,
);
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"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 });
}
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -20,7 +20,7 @@
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"winston": "catalog:",
"yargs": "^17.7.2"
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"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.25",
"version": "2.0.0-alpha.26",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
+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;
}
/**
+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.25",
"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",
@@ -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>;
}
@@ -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/>
@@ -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}
/>;
}
+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}
/>;
}
@@ -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>;
}
+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.25",
"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:",
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.25",
"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.25",
"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.25",
"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.25",
"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.25",
"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