mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb77ca566c | ||
|
|
9e727f9a13 | ||
|
|
d35e8caa2b | ||
|
|
b9c073e26f | ||
|
|
b160fecac4 | ||
|
|
dab727f90f | ||
|
|
0e36df8ae3 | ||
|
|
f7d3516c24 | ||
|
|
e6665d0803 | ||
|
|
6fd4654c21 | ||
|
|
b5c99fad2a | ||
|
|
f588f7f8d4 | ||
|
|
4fe83cbcbc | ||
|
|
069a050f39 | ||
|
|
577615803b | ||
|
|
680ade5975 | ||
|
|
6914645c19 | ||
|
|
1d0b6b9a62 | ||
|
|
dd80d49989 | ||
|
|
e3d5f434a4 | ||
|
|
11b8a0ff97 | ||
|
|
e54b292fce | ||
|
|
41222343e3 | ||
|
|
4676f1c820 | ||
|
|
e49b82842d | ||
|
|
e0a39e5630 | ||
|
|
21b050b220 | ||
|
|
9bed1223fd | ||
|
|
4735f0725d | ||
|
|
1f27617a27 | ||
|
|
ee460d677d | ||
|
|
f2ca465ec6 | ||
|
|
40e6961711 | ||
|
|
1bb51988ac | ||
|
|
2c30fa4a94 | ||
|
|
a22186ca01 | ||
|
|
746b11ff65 | ||
|
|
de51741835 | ||
|
|
a633c42904 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -62,7 +62,7 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
});
|
||||
|
||||
// Create folder for output file
|
||||
const dir = path.parse(dst);
|
||||
const { dir } = path.parse(dst);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -3,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"
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(".");
|
||||
|
||||
Vendored
+10
@@ -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,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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
Reference in new issue
Block a user