mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cd830c01 | ||
|
|
ddc03eaf30 | ||
|
|
b5d8cde93f | ||
|
|
4e8a833d62 | ||
|
|
3add08b100 | ||
|
|
f2b28968a0 | ||
|
|
599cf3ed22 | ||
|
|
ff28357d7a | ||
|
|
8b4c8ad337 | ||
|
|
07b030d045 | ||
|
|
7f799c8490 | ||
|
|
91a5108b85 | ||
|
|
90c8188952 | ||
|
|
198ea76674 | ||
|
|
5758cd94b6 | ||
|
|
eab1a42e07 | ||
|
|
fdfd77df9a | ||
|
|
58f2ad9810 | ||
|
|
3cc99a1ea6 | ||
|
|
15781bb038 | ||
|
|
9ec12ac7d7 | ||
|
|
2559c0478c | ||
|
|
07fcdb0d4e | ||
|
|
40407e52d6 | ||
|
|
2898fdf5d3 | ||
|
|
bb4c82ea5a | ||
|
|
1f2730a64f | ||
|
|
43238feada | ||
|
|
573274875c | ||
|
|
2e27c4f499 | ||
|
|
8029ad3b2d | ||
|
|
eca5cdcd9e | ||
|
|
523293d4d9 | ||
|
|
5d20f8fc84 | ||
|
|
e27ad5fdf9 | ||
|
|
5b1f7d796d | ||
|
|
0874c26208 | ||
|
|
1e7b8f08cf | ||
|
|
7c2793752d | ||
|
|
5cde390c24 | ||
|
|
46ebb13fda | ||
|
|
4775a5fd06 | ||
|
|
7e89e7fb6f | ||
|
|
95ea073b77 | ||
|
|
e135d37878 | ||
|
|
2dedb44372 | ||
|
|
97a6c32838 | ||
|
|
a56fcbd785 | ||
|
|
e16603bbd0 | ||
|
|
dfe132699e | ||
|
|
f793ec33b3 | ||
|
|
ce8bddad8f | ||
|
|
3fe0c85055 | ||
|
|
9da48d5387 | ||
|
|
2bd9bc6138 | ||
|
|
a274fe74e1 | ||
|
|
ea5c35198f | ||
|
|
0f6f3bbb8a | ||
|
|
448eb9223d | ||
|
|
cc0be69019 | ||
|
|
9c627f4c16 | ||
|
|
118154a43a | ||
|
|
b29f4db52c | ||
|
|
5efa257a36 | ||
|
|
09ec6d6ec5 | ||
|
|
8cacdc5112 | ||
|
|
d4394f1190 | ||
|
|
6feb9c2762 | ||
|
|
79c6bd1ac4 | ||
|
|
4e94092730 | ||
|
|
82629a57c6 | ||
|
|
92f607a864 | ||
|
|
245483cb35 | ||
|
|
f04fc68513 | ||
|
|
49eb594e5b | ||
|
|
ed84a476c6 | ||
|
|
eb77ca566c | ||
|
|
262357acef | ||
|
|
9e727f9a13 | ||
|
|
d35e8caa2b | ||
|
|
b9c073e26f | ||
|
|
b160fecac4 | ||
|
|
dab727f90f | ||
|
|
0e36df8ae3 | ||
|
|
f7d3516c24 | ||
|
|
e6665d0803 | ||
|
|
6fd4654c21 | ||
|
|
b5c99fad2a | ||
|
|
f588f7f8d4 | ||
|
|
4fe83cbcbc | ||
|
|
069a050f39 | ||
|
|
577615803b | ||
|
|
680ade5975 | ||
|
|
6914645c19 | ||
|
|
1d0b6b9a62 | ||
|
|
0482f4ae08 | ||
|
|
16ce748ffa | ||
|
|
2c199028ea | ||
|
|
22b16a7ac2 | ||
|
|
f2ccde0dd6 | ||
|
|
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
@@ -13,9 +13,9 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [20.x, 22.x]
|
||||
node-version: [22.x, 24.x]
|
||||
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.47]
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
types:
|
||||
- released
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -15,12 +19,10 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
@@ -33,7 +35,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
|
||||
@@ -31,6 +31,87 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.27
|
||||
|
||||
### Features
|
||||
|
||||
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
|
||||
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
|
||||
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
|
||||
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
|
||||
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
|
||||
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
|
||||
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
|
||||
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
|
||||
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
|
||||
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>)
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## 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
|
||||
|
||||
+6
-6
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -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.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
@@ -17,10 +17,10 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link {
|
||||
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
|
||||
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
|
||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
@@ -79,7 +80,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
|
||||
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
@@ -113,4 +114,8 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -675,6 +675,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
||||
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
|
||||
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const optionalRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const candidateReleases = new Map<string, lib.ModInfo>();
|
||||
const errors = new Map<string, lib.ModDependencyResolveErrors>();
|
||||
@@ -707,7 +708,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const release = releases.releases
|
||||
.filter(info => versionRange.testVersion(info.version))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
|
||||
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (!release) {
|
||||
@@ -739,16 +740,32 @@ export default class ControlConnection extends BaseConnection {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the current version range (optionals are tracked in case they become required)
|
||||
let versionRange = dependencyRequirements.get(mod.name);
|
||||
if (!versionRange) {
|
||||
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
|
||||
if (mod.required) {
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
// Get the current version range (dependencies can be promoted to a stricter requirement)
|
||||
const versionRange =
|
||||
dependencyRequirements.get(mod.name)
|
||||
?? recommendedRequirements.get(mod.name)
|
||||
?? optionalRequirements.get(mod.name)
|
||||
?? new lib.ModVersionRange();
|
||||
|
||||
if (mod.required) {
|
||||
// Add to required, remove from recommended and optional
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
recommendedRequirements.delete(mod.name);
|
||||
optionalRequirements.delete(mod.name);
|
||||
|
||||
} else if (mod.recommended) {
|
||||
// If not required, add to recommended and remove from optional
|
||||
if (!dependencyRequirements.has(mod.name)) {
|
||||
recommendedRequirements.set(mod.name, versionRange);
|
||||
optionalRequirements.delete(mod.name);
|
||||
} else {
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
} else if (
|
||||
!dependencyRequirements.has(mod.name)
|
||||
&& !recommendedRequirements.has(mod.name)
|
||||
) {
|
||||
// If not required or recommended, add to optional
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
// Update the version range if needed
|
||||
@@ -757,6 +774,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!versionRange.valid) {
|
||||
candidateReleases.delete(mod.name);
|
||||
errors.set(mod.name, "unsatisfiable");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,9 +822,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove incompatible dependencies if they aren't required by others
|
||||
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
|
||||
for (const [modName, error] of errors) {
|
||||
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
|
||||
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
|
||||
errors.delete(modName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,28 @@ export default class Controller {
|
||||
}
|
||||
});
|
||||
|
||||
// Cache for the latest stable/experimental factorio releases. Persisted to
|
||||
// disk so that release channel targets can still be resolved when
|
||||
// factorio.com is unavailable.
|
||||
latestReleases = new lib.ValueCache(async () => {
|
||||
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
|
||||
try {
|
||||
const releases = await lib.fetchLatestReleases();
|
||||
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
|
||||
return releases;
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
|
||||
} catch (readErr: any) {
|
||||
if (readErr.code !== "ENOENT") {
|
||||
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||
@@ -741,8 +763,11 @@ export default class Controller {
|
||||
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
for (const mod of json.mods) {
|
||||
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
|
||||
// All other versions are kept verbatim so they still match the mod's file name.
|
||||
if (lib.isPartialVersion(mod.version)) {
|
||||
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
|
||||
}
|
||||
}
|
||||
return json;
|
||||
});
|
||||
@@ -875,6 +900,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 +925,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 +975,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 +989,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 +1005,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 +1021,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 +1035,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 +1085,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.27",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
@@ -13,7 +13,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "catalog:",
|
||||
|
||||
@@ -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.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -17,10 +17,10 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"as-table": "^1.0.55",
|
||||
"winston": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
|
||||
@@ -9,3 +9,23 @@ debugCommands.add(new lib.Command({
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"parse-exchange-string <exchange-string>",
|
||||
"Parse and print the contents of an exchange string",
|
||||
(yargs) => {
|
||||
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
|
||||
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
|
||||
}],
|
||||
handler: async function(args: { exchangeString: string, pretty: boolean }, control: Control) {
|
||||
const result = lib.readMapExchangeString(args.exchangeString);
|
||||
if (args.pretty) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -20,7 +20,7 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
|
||||
@@ -108,9 +108,13 @@ modCommands.add(new lib.Command({
|
||||
},
|
||||
control: Control
|
||||
) {
|
||||
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
|
||||
if (searchVersion === undefined) {
|
||||
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
|
||||
}
|
||||
let response = await control.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
lib.normaliseApiVersion(args.factorioVersion as any),
|
||||
lib.normaliseMajorMinorVersion(searchVersion),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
@@ -187,7 +191,7 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
|
||||
@@ -207,7 +211,7 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
|
||||
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
if (!version) {
|
||||
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
|
||||
}
|
||||
if (!lib.isFullVersion(version)) {
|
||||
if (!lib.isSourceVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
{
|
||||
"version": "2.0.20",
|
||||
"factorio_version": "2.0"
|
||||
},
|
||||
{
|
||||
"version": "2.0.21",
|
||||
"factorio_version": "2.1"
|
||||
}
|
||||
],
|
||||
"title": "Clusterio Library (Alpha)",
|
||||
|
||||
@@ -17,6 +17,8 @@ local lib_json = require("json")
|
||||
--- @field prototypes LuaPrototypes
|
||||
--- @field active_mods table<string, string>
|
||||
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
|
||||
--- @field encode_string fun(string: string): string
|
||||
--- @field decode_string fun(string: string): string
|
||||
--- @field table_to_json fun(data: table): string
|
||||
--- @field json_to_table fun(json: string): AnyBasic?
|
||||
local compat = {}
|
||||
@@ -177,6 +179,24 @@ function runtime_properties.write_file()
|
||||
end
|
||||
end
|
||||
|
||||
--- Deflates and base64 encodes the given string.
|
||||
function runtime_properties.encode_string()
|
||||
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
|
||||
return game.encode_string
|
||||
elseif v2 then
|
||||
return helpers.encode_string
|
||||
end
|
||||
end
|
||||
|
||||
--- Base64 decodes and inflates the given string.
|
||||
function runtime_properties.decode_string()
|
||||
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
|
||||
return game.decode_string
|
||||
elseif v2 then
|
||||
return helpers.decode_string
|
||||
end
|
||||
end
|
||||
|
||||
--- Convert tables to and from json, we have backported this to work before runtime
|
||||
if v0 or v1 then
|
||||
compat.table_to_json = function(tbl)
|
||||
|
||||
@@ -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.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -18,10 +18,10 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"jimp": "^1.6.0",
|
||||
"jszip": "catalog:",
|
||||
|
||||
@@ -659,10 +659,8 @@ export default class Host extends lib.Link {
|
||||
return await this.downloadMod(mod);
|
||||
}
|
||||
|
||||
async fetchMods(mods: Iterable<lib.ModRecord>) {
|
||||
// This is better than the previous hard coded names
|
||||
// But it really shouldn't be a hard coded version either
|
||||
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
|
||||
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
|
||||
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
|
||||
const modInfos: Promise<lib.ModInfo>[] = [];
|
||||
for (const mod of mods) {
|
||||
if (builtinModNames.includes(mod.name)) {
|
||||
|
||||
@@ -642,6 +642,21 @@ end`.replace(/\r?\n/g, " ");
|
||||
try {
|
||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
await this._loadStats();
|
||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||
// version using the controller's cached latest-releases data, so the
|
||||
// existing download/lookup logic can fetch and run it.
|
||||
const targetVersion = this.config.get("factorio.version") as string;
|
||||
if (lib.isReleaseChannel(targetVersion)) {
|
||||
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Cannot resolve Factorio release channel '${targetVersion}': ` +
|
||||
"latest-releases unavailable and no cached copy on the controller."
|
||||
);
|
||||
}
|
||||
this.server.setTargetVersion(resolved);
|
||||
}
|
||||
await this.server.checkForUpdates(factorioVersions);
|
||||
await this.server.init();
|
||||
} catch (err) {
|
||||
@@ -782,9 +797,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
this.activeModPack = modPack;
|
||||
|
||||
// TODO validate factorioVersion
|
||||
|
||||
const mods = await this._host.fetchMods(modPack.mods.values());
|
||||
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
|
||||
|
||||
await fs.mkdir(this.path("mods"), { recursive: true });
|
||||
|
||||
|
||||
@@ -190,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
||||
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
|
||||
return [path.join(factorioDir, "data"), directVersion];
|
||||
}
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
throw new Error(
|
||||
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
|
||||
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise assume it is a directory of multiple versions
|
||||
@@ -952,6 +956,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
@@ -987,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (!installedVersions.versions.has(latestVersion.version)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct || process.platform !== "linux") {
|
||||
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging";
|
||||
import { downloadFile, safeOutputFile } from "./file_ops";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
|
||||
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
|
||||
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
@@ -44,7 +44,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
super();
|
||||
}
|
||||
|
||||
getMod(name: string, version: FullVersion, sha1?: string) {
|
||||
getMod(name: string, version: SourceVersion, sha1?: string) {
|
||||
const mod = this.files.get(ModInfo.filename(name, version));
|
||||
if (!mod || sha1 && sha1 !== mod.sha1) {
|
||||
return undefined;
|
||||
@@ -86,11 +86,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
async loadMod(name: string, version: FullVersion) {
|
||||
async loadMod(name: string, version: SourceVersion) {
|
||||
return await this.loadFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
async deleteMod(name: string, version: FullVersion) {
|
||||
async deleteMod(name: string, version: SourceVersion) {
|
||||
return await this.deleteFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
private async downloadModsChunk(
|
||||
mods: ModNameVersionPair[],
|
||||
username: string, token: string,
|
||||
factorioVersion: ApiVersion,
|
||||
factorioVersion: MajorMinorVersion,
|
||||
) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", "max");
|
||||
@@ -209,7 +209,18 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
// Surface the portal's own error (e.g. an unsupported version) rather
|
||||
// than a generic status line.
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json() as { message?: string };
|
||||
if (body && typeof body.message === "string") {
|
||||
detail = body.message;
|
||||
}
|
||||
} catch {
|
||||
// Response body was not JSON; keep the status line.
|
||||
}
|
||||
throw new Error(`Mod portal request to ${url} failed: ${detail}`);
|
||||
}
|
||||
|
||||
const modReleases = (await response.json() as ModsInfoResponse).results;
|
||||
@@ -236,7 +247,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
async downloadMods(
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
|
||||
) {
|
||||
const chunkSize = 500;
|
||||
const futures: Promise<void>[] = [];
|
||||
|
||||
@@ -251,7 +251,7 @@ export class Config<
|
||||
constructor(
|
||||
public location: ConfigLocation,
|
||||
fields?: Static<typeof Config.jsonSchema>,
|
||||
public filepath?: string,
|
||||
private _filepath?: string,
|
||||
) {
|
||||
if (typeof location !== "string") {
|
||||
throw new Error("location must be a string");
|
||||
@@ -273,6 +273,15 @@ export class Config<
|
||||
}
|
||||
}
|
||||
|
||||
get filepath() {
|
||||
return this._filepath;
|
||||
}
|
||||
|
||||
set filepath(filepath: string | undefined) {
|
||||
this.dirty = true;
|
||||
this._filepath = filepath;
|
||||
}
|
||||
|
||||
/** A staging version of the config with changes tracked, invalided by revertStaging */
|
||||
private _staging?: { config: Config<Fields>, changes: Set<string>};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ControllerConfigFields {
|
||||
"controller.bind_address": string | null;
|
||||
"controller.trusted_proxies": string | null;
|
||||
"controller.public_url": string | null;
|
||||
"controller.grafana_url": string | null;
|
||||
"controller.tls_certificate": string | null;
|
||||
"controller.tls_private_key": string | null;
|
||||
"controller.auth_secret": string | null;
|
||||
@@ -137,6 +138,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
optional: true,
|
||||
// TODO extendedValidation, valid url including the protocol
|
||||
},
|
||||
"controller.grafana_url": {
|
||||
title: "Grafana URL",
|
||||
description: "URL of a Grafana dashboard to link from the overview page, including the protocol.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"controller.tls_certificate": {
|
||||
title: "TLS Certificate",
|
||||
description: "Path to the certificate to use for HTTPS.",
|
||||
@@ -501,14 +508,16 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
description:
|
||||
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
|
||||
"channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
validator: function(value) {
|
||||
if (!isTargetVersion(value)) {
|
||||
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
|
||||
throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,14 +8,13 @@ import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required" | "recommended";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
|
||||
@@ -40,6 +39,8 @@ export class ModDependency {
|
||||
return "hidden";
|
||||
case "~":
|
||||
return "unordered";
|
||||
case "+":
|
||||
return "recommended";
|
||||
case "":
|
||||
return "required";
|
||||
default:
|
||||
@@ -87,6 +88,10 @@ export class ModDependency {
|
||||
return this.type === "incompatible";
|
||||
}
|
||||
|
||||
get recommended() {
|
||||
return this.type === "recommended";
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.type === "unordered" || this.type === "required";
|
||||
}
|
||||
@@ -129,16 +134,23 @@ export default class ModInfo {
|
||||
name = "";
|
||||
|
||||
/**
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
* Version of the mod, exactly as it appears in info.json and the mod's file
|
||||
* name. This is the raw, un-normalised version the game reads leniently, so
|
||||
* it is only safe to use for building file names and identifiers; use
|
||||
* {@link integerVersion} to compare it.
|
||||
*/
|
||||
version = "0.0.0" as FullVersion;
|
||||
version = "0.0.0" as SourceVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
* Integer representation of the version, normalised the lenient way the
|
||||
* game reads it so it can be compared and sorted.
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerFullVersion(this.version);
|
||||
return integerSourceVersion(this.version);
|
||||
}
|
||||
|
||||
get normalisedVersion() {
|
||||
return normaliseSourceVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,17 +184,17 @@ export default class ModInfo {
|
||||
description = "";
|
||||
|
||||
/**
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
* Major version of Factorio this mod supports, reduced to major.minor.
|
||||
* Sourced from info.json and normalised when read in fromJSON.
|
||||
*/
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
factorioVersion = "0.12" as MajorMinorVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
return integerMajorMinorVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +233,7 @@ export default class ModInfo {
|
||||
* @param version - Mod's version
|
||||
* @returns string containing {name}_{version}.zip
|
||||
*/
|
||||
static filename(name: string, version: FullVersion) {
|
||||
static filename(name: string, version: SourceVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -248,16 +260,17 @@ export default class ModInfo {
|
||||
*/
|
||||
isDeleted = false;
|
||||
|
||||
// Content of info.json found in mod files
|
||||
// Content of info.json found in mod files. The version fields use the same
|
||||
// lenient format the game accepts; they are normalised when read in fromJSON.
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"factorio_version": Type.Optional(SourceVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -279,13 +292,24 @@ export default class ModInfo {
|
||||
|
||||
// info.json fields
|
||||
if (json.name) { modInfo.name = json.name; }
|
||||
if (json.version) { modInfo.version = json.version; }
|
||||
if (json.version) {
|
||||
if (!isSourceVersion(json.version)) {
|
||||
throw new Error(`Invalid mod version "${json.version}"`);
|
||||
}
|
||||
modInfo.version = json.version;
|
||||
}
|
||||
if (json.title) { modInfo.title = json.title; }
|
||||
if (json.author) { modInfo.author = json.author; }
|
||||
if (json.contact) { modInfo.contact = json.contact; }
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.factorio_version) {
|
||||
const factorioVersion = normaliseSourceVersion(json.factorio_version);
|
||||
if (factorioVersion === undefined) {
|
||||
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
|
||||
}
|
||||
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
|
||||
}
|
||||
|
||||
// Parse the dependencies
|
||||
try {
|
||||
@@ -314,7 +338,7 @@ export default class ModInfo {
|
||||
if (this.contact) { json.contact = this.contact; }
|
||||
if (this.homepage) { json.homepage = this.homepage; }
|
||||
if (this.description) { json.description = this.description; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as SourceVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
SourceVersion, SourceVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface ModRecord {
|
||||
name: string,
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: FullVersion,
|
||||
/** version of the mod, verbatim as it appears in the mod's file name. */
|
||||
version: SourceVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
@@ -61,7 +61,7 @@ export interface ModRecord {
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -396,7 +396,7 @@ export default class ModPack {
|
||||
|
||||
/** Array of default mod packs which should exist on a newly installed cluster */
|
||||
static defaultModPacks = [
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Base Game ${version}`,
|
||||
description: `Factorio ${version} with no extra mods.`,
|
||||
@@ -413,6 +413,18 @@ export default class ModPack {
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
...(["2.1"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Space Age ${version}`,
|
||||
description: `Factorio ${version} with Space Age expansion.`,
|
||||
factorio_version: version,
|
||||
mods: [
|
||||
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "recycler", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -422,18 +434,24 @@ export default class ModPack {
|
||||
* @return Built in mods for the given version
|
||||
*/
|
||||
static getBuiltinMods(factorioVersion: PartialVersion) {
|
||||
factorioVersion = normaliseFullVersion(factorioVersion);
|
||||
const version = normaliseFullVersion(factorioVersion) as SourceVersion;
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
let defaultMods: ModRecord[] = [
|
||||
// "core" not included because core cannot be disabled
|
||||
{ name: "base", enabled: true, version: factorioVersion },
|
||||
{ name: "base", enabled: true, version: version },
|
||||
];
|
||||
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
if (integerVersion >= integerPartialVersion("1.2")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "elevated-rails", enabled: false, version: factorioVersion },
|
||||
{ name: "quality", enabled: false, version: factorioVersion },
|
||||
{ name: "space-age", enabled: false, version: factorioVersion },
|
||||
{ name: "elevated-rails", enabled: false, version: version },
|
||||
{ name: "quality", enabled: false, version: version },
|
||||
{ name: "space-age", enabled: false, version: version },
|
||||
]);
|
||||
}
|
||||
|
||||
if (integerVersion >= integerPartialVersion("2.1")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "recycler", enabled: false, version: version },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
||||
import { levels } from "../logging";
|
||||
import { ControllerConfig, HostConfig } from "../config";
|
||||
import { ExternalFactorioVersionSchema } from "../external";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external";
|
||||
|
||||
export class ControllerStopRequest {
|
||||
declare ["constructor"]: typeof ControllerStopRequest;
|
||||
@@ -442,3 +442,25 @@ export class FactorioVersionsRequest {
|
||||
|
||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
||||
}
|
||||
|
||||
export class LatestReleasesRequest {
|
||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_latest_releases" as const;
|
||||
|
||||
constructor(
|
||||
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"maxAgeMs": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(LatestReleasesSchema);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ import ModPack from "./ModPack";
|
||||
import { JsonString, jsonArray } from "./composites";
|
||||
|
||||
import {
|
||||
FullVersion, FullVersionSchema,
|
||||
ApiVersion, ApiVersionSchema, normaliseApiVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
@@ -119,13 +119,13 @@ export class ModGetRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ export class ModSearchRequest {
|
||||
|
||||
constructor(
|
||||
public query: string,
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public page: number,
|
||||
public pageSize?: number,
|
||||
public sort?: string,
|
||||
@@ -163,7 +163,7 @@ export class ModSearchRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"query": Type.String(),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"page": Type.Integer(),
|
||||
"pageSize": Type.Optional(Type.Integer()),
|
||||
"sort": Type.Optional(Type.String()),
|
||||
@@ -205,9 +205,9 @@ export class ModSearchRequest {
|
||||
|
||||
// Define the structure for the latest release info from the portal
|
||||
export const ModPortalReleaseSchema = Type.Object({
|
||||
version: FullVersionSchema,
|
||||
version: SourceVersionSchema,
|
||||
// Match the structure from ModStore's ModRelease/ModDetails
|
||||
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
|
||||
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
|
||||
released_at: Type.String(), // ISO 8601 date string
|
||||
download_url: Type.String(),
|
||||
file_name: Type.String(),
|
||||
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public hide_deprecated?: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"hide_deprecated": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModNameVersionPair[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModNameVersionPairSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModDependency[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public checkForUpdates: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModDependency.jsonSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"checkForUpdates": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
|
||||
[...modPack.mods.values()]
|
||||
.filter(mod => mod.enabled)
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,24 +24,55 @@ export function isVersionEquality(input: string): input is VersionEquality {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions accepts by the web API
|
||||
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
|
||||
* Known Factorio major versions, offered as fallback options in the web
|
||||
* interface before the live version list has been fetched. Versions are no
|
||||
* longer validated against this list; the mod portal is the source of truth.
|
||||
*/
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
|
||||
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
|
||||
export type ApiVersion = Static<typeof ApiVersionSchema>;
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const;
|
||||
|
||||
export function isApiVersion(input: string): input is ApiVersion {
|
||||
return ApiVersions.includes(input as any);
|
||||
/**
|
||||
* Matches a version string the same lenient way the game reads it: each number
|
||||
* may be preceded by whitespace, the major and minor are required, the patch is
|
||||
* optional, and any trailing content is ignored. The pattern is intentionally
|
||||
* not anchored at the end.
|
||||
*/
|
||||
const sourceVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
|
||||
|
||||
/**
|
||||
* A raw, un-normalised version string exactly as the game reads it, such as a
|
||||
* mod's version taken from info.json or a mod file name.
|
||||
*/
|
||||
declare const sourceVersionSymbol: unique symbol;
|
||||
export type SourceVersion = string & { [sourceVersionSymbol]: void };
|
||||
export const SourceVersionSchema = Type.Unsafe<SourceVersion>(
|
||||
Type.String({ pattern: sourceVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isSourceVersion(input: string): input is SourceVersion {
|
||||
return sourceVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function normaliseApiVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
const apiVersion = `${parts[0]}.${parts[1]}`;
|
||||
if (!isApiVersion(apiVersion)) {
|
||||
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
|
||||
export function integerSourceVersion(version: SourceVersion) {
|
||||
return integerFullVersion(normaliseSourceVersion(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a version string the same lenient way the game reads it. Numbers
|
||||
* are read as integers, so leading whitespace and zeros are dropped and any
|
||||
* content after the patch is ignored.
|
||||
*
|
||||
* @returns The normalised full version, or undefined if no major and minor
|
||||
* could be read.
|
||||
*/
|
||||
export function normaliseSourceVersion(input: SourceVersion): FullVersion;
|
||||
export function normaliseSourceVersion(input: string): FullVersion | undefined;
|
||||
|
||||
export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined {
|
||||
const match = sourceVersionRegExp.exec(input);
|
||||
if (match === null) {
|
||||
return undefined;
|
||||
}
|
||||
return apiVersion;
|
||||
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,11 +99,38 @@ export function normaliseFullVersion(version: PartialVersion) {
|
||||
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a major.minor version, as used by the mod portal's version filter.
|
||||
*/
|
||||
const majorMinorVersionRegExp = /^\d+\.\d+$/;
|
||||
export type MajorMinorVersion = `${number}.${number}`;
|
||||
export const MajorMinorVersionSchema = Type.Unsafe<MajorMinorVersion>(
|
||||
Type.String({ pattern: majorMinorVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isMajorMinorVersion(input: string): input is MajorMinorVersion {
|
||||
return majorMinorVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerMajorMinorVersion(version: MajorMinorVersion) {
|
||||
const [major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
// Can't use bitwise here because this is 48-bits. sub is always 0 so is omitted.
|
||||
return major * 0x100000000 + minor * 0x10000 as IntegerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a version to its major.minor, as used by the mod portal's version filter.
|
||||
*/
|
||||
export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion {
|
||||
const [major, minor] = version.split(".");
|
||||
return `${major}.${minor}` as MajorMinorVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
|
||||
*/
|
||||
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
|
||||
export type PartialVersion = FullVersion | `${number}.${number}`;
|
||||
export type PartialVersion = FullVersion | MajorMinorVersion;
|
||||
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
|
||||
Type.String({ pattern: partialVersionRegExp.source })
|
||||
);
|
||||
@@ -88,16 +146,35 @@ export function integerPartialVersion(version: PartialVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
|
||||
* Matches a release channel name from the latest-releases API, such as
|
||||
* "stable" or "experimental". Excludes "latest", which is handled separately.
|
||||
* The set of channels is dynamic, so any lowercase identifier is accepted here
|
||||
* and resolved against the live API when the instance starts.
|
||||
*/
|
||||
export type TargetVersion = PartialVersion | "latest";
|
||||
const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/;
|
||||
declare const releaseChannelSymbol: unique symbol;
|
||||
export type ReleaseChannel = string & { [releaseChannelSymbol]: void };
|
||||
export const ReleaseChannelSchema = Type.Unsafe<ReleaseChannel>(
|
||||
Type.String({ pattern: releaseChannelRegExp.source })
|
||||
);
|
||||
|
||||
export function isReleaseChannel(input: string): input is ReleaseChannel {
|
||||
return input !== "latest" && releaseChannelRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions: a partial version, "latest", or a
|
||||
* release channel name (e.g. "stable" or "experimental").
|
||||
*/
|
||||
export type TargetVersion = PartialVersion | ReleaseChannel | "latest";
|
||||
export const TargetVersionSchema = Type.Union([
|
||||
PartialVersionSchema,
|
||||
ReleaseChannelSchema,
|
||||
Type.Literal("latest"),
|
||||
]);
|
||||
|
||||
export function isTargetVersion(input: string): input is TargetVersion {
|
||||
return input === "latest" || partialVersionRegExp.test(input);
|
||||
return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +213,8 @@ export class ModVersionEquality {
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: SourceVersion) {
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -199,8 +276,11 @@ export class ModVersionRange {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: PartialVersion | SourceVersion) {
|
||||
if (isPartialVersion(version)) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersion, FullVersionSchema } from "../data/version";
|
||||
|
||||
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
|
||||
|
||||
/**
|
||||
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
|
||||
* for a single release channel.
|
||||
*/
|
||||
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
|
||||
|
||||
/**
|
||||
* The latest released version of each build, keyed by release channel name
|
||||
* (e.g. "stable" and "experimental").
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
*
|
||||
* @returns The latest version of each build keyed by release channel
|
||||
* @throws Network errors and non-ok status responses
|
||||
*/
|
||||
export async function fetchLatestReleases(): Promise<LatestReleases> {
|
||||
let res;
|
||||
|
||||
try {
|
||||
res = await fetch(LATEST_RELEASES_URL);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return await res.json() as LatestReleases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a release channel to a concrete factorio version
|
||||
*
|
||||
* @param releases Releases data as returned by {@link fetchLatestReleases}
|
||||
* @param channel Release channel name, e.g. "stable" or "experimental"
|
||||
* @param build Build to read the version of, defaults to the headless build
|
||||
* @returns The version for the channel, or undefined if it cannot be resolved
|
||||
*/
|
||||
export function resolveReleaseChannel(
|
||||
releases: LatestReleases,
|
||||
channel: string,
|
||||
build = "headless",
|
||||
): FullVersion | undefined {
|
||||
const builds = releases[channel];
|
||||
if (!builds) {
|
||||
return undefined;
|
||||
}
|
||||
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
|
||||
}
|
||||
Vendored
+1
@@ -3,3 +3,4 @@
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
export * from "./LatestReleases";
|
||||
@@ -4,7 +4,10 @@ class MapReaderState {
|
||||
pos = 0;
|
||||
last_position = { x: 0, y: 0 };
|
||||
/** True when a version greater than 2.0.0 is detected */
|
||||
v2 = false;
|
||||
gt_v2_0 = false;
|
||||
/** True when a version greater than 2.1.0 is detected */
|
||||
gt_v2_1 = false;
|
||||
// If more versions are required then we should expose version directly
|
||||
constructor(
|
||||
public buf: Buffer
|
||||
) { }
|
||||
@@ -161,7 +164,7 @@ function readBoundingBox(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readCliffSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
name: readString(state),
|
||||
control: readString(state), // v2
|
||||
cliff_elevation_0: readFloat(state),
|
||||
@@ -186,7 +189,7 @@ function readTerritorySettings(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readMapGenSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
|
||||
default_enable_all_autoplace_controls: readBool(state),
|
||||
@@ -262,7 +265,23 @@ function readEnemyEvolution(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readEnemyExpansion(state: MapReaderState) {
|
||||
return {
|
||||
return state.gt_v2_1 ? {
|
||||
enabled: readOptional(state, readBool),
|
||||
max_expansion_distance: readOptional(state, readUInt32),
|
||||
min_expansion_distance: readOptional(state, readUInt32), // v2.1
|
||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||
enemy_building_influence_radius: readOptional(state, readUInt32),
|
||||
building_coefficient: readOptional(state, readDouble),
|
||||
other_base_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_chunk_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
|
||||
max_colliding_tiles_coefficient: readOptional(state, readDouble),
|
||||
settler_group_min_size: readOptional(state, readUInt32),
|
||||
settler_group_max_size: readOptional(state, readUInt32),
|
||||
evolution_group_size_factor: readOptional(state, readDouble), // v2.1
|
||||
min_expansion_cooldown: readOptional(state, readUInt32),
|
||||
max_expansion_cooldown: readOptional(state, readUInt32),
|
||||
} : {
|
||||
enabled: readOptional(state, readBool),
|
||||
max_expansion_distance: readOptional(state, readUInt32),
|
||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||
@@ -336,7 +355,7 @@ function readPathFinder(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readDifficultySettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
technology_price_multiplier: readDouble(state),
|
||||
spoil_time_modifier: readDouble(state), // v2
|
||||
} : {
|
||||
@@ -355,17 +374,32 @@ function readAsteroids(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readMapSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
} : {
|
||||
if (state.gt_v2_1) {
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
// steering removed v2.1
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
};
|
||||
} else if (state.gt_v2_0) {
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
};
|
||||
}
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
@@ -426,7 +460,8 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
|
||||
try {
|
||||
const version = readVersion(state);
|
||||
state.v2 = version[0] >= 2;
|
||||
state.gt_v2_0 = version[0] >= 2;
|
||||
state.gt_v2_1 = version[0] >= 2 && version[1] >= 1;
|
||||
data = {
|
||||
version: version,
|
||||
unknown: readUInt8(state),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
controller.LatestReleasesRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
|
||||
@@ -524,3 +524,9 @@ definePermission({
|
||||
title: "Get factorio versions",
|
||||
description: "Get list of all factorio versions from Wube",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.external.get_latest_releases",
|
||||
title: "Get factorio releases",
|
||||
description: "Get the latest stable and experimental factorio releases from Wube",
|
||||
});
|
||||
+401
-153
@@ -1,13 +1,144 @@
|
||||
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, AccountDetails } 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 extends Set<string> {
|
||||
private constructor(
|
||||
private _all: boolean,
|
||||
_filters: Iterable<string> = [],
|
||||
) {
|
||||
super(_filters);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Array(Type.String());
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(false, json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return [...this];
|
||||
}
|
||||
|
||||
/** Creates a subscription filter accepting all strings */
|
||||
static all() {
|
||||
return new this(true);
|
||||
}
|
||||
|
||||
isAll() {
|
||||
return this._all;
|
||||
}
|
||||
|
||||
/** Creates a subscription filter rejecting all strings */
|
||||
static empty() {
|
||||
return new this(false);
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !this._all && this.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.size === 0) {
|
||||
return "[SubscriptionFilters Empty]";
|
||||
}
|
||||
return `[SubscriptionFilters Set<${this.size}>]`;
|
||||
}
|
||||
|
||||
/** Returns true if this filter accepts the given string */
|
||||
accepts(value: string) {
|
||||
return this._all || this.has(value);
|
||||
}
|
||||
|
||||
/** Returns true if this filter extends the other */
|
||||
extends(other: this) {
|
||||
return this._all || (!other._all && [...other.values()].every(value => this.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) {
|
||||
if (this.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.clear();
|
||||
return;
|
||||
}
|
||||
for (const filter of other) {
|
||||
this.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.clear();
|
||||
return;
|
||||
}
|
||||
if (this._all) {
|
||||
return; // Cannot subtract from all
|
||||
}
|
||||
for (const filter of other) {
|
||||
this.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 +151,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 +165,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 +254,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 +271,7 @@ export class SubscriptionController {
|
||||
}
|
||||
this._events.set(entry.name, {
|
||||
subscriptionUpdate: subscriptionUpdate,
|
||||
subscriptions: [],
|
||||
subscriptions: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +279,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 +288,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 +307,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 +319,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 +399,29 @@ 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;
|
||||
/** Error value from last attempted subscription event */
|
||||
error = null as Error | null;
|
||||
/** 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, Error | null] = [this.makeSnapshot(), false, null];
|
||||
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 +441,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 +453,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 +491,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 +515,153 @@ 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, Error | null] {
|
||||
if (
|
||||
this._snapshotLastUpdatedMs !== this.lastUpdatedMs
|
||||
|| this._snapshot[1] !== this.synced
|
||||
|| this._snapshot[2] !== this.error
|
||||
) {
|
||||
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
|
||||
this._snapshot = [this.makeSnapshot(), this.synced, this.error];
|
||||
}
|
||||
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
|
||||
));
|
||||
this.error = null;
|
||||
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}`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
this.error = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.27",
|
||||
"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>;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) {
|
||||
confirmLoading={applying}
|
||||
onOk={handleAssign}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph style={{ maxWidth: "30em" }}>
|
||||
Select a Host to assign this instance to. Assignment
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) {
|
||||
confirmLoading={creatingSave}
|
||||
onOk={createSave}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="saveName" label="Save Name">
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
|
||||
@@ -20,6 +19,10 @@ import {
|
||||
import { useHosts } from "../model/host";
|
||||
import { useSystems } from "../model/system";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -199,12 +202,16 @@ function CopyButton({ text, message }: { text:string, message:string }) {
|
||||
|
||||
export default function HostsPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const [showRatios, setShowRatios] = useState(true);
|
||||
const [showNumbers, setShowNumbers] = useState(false);
|
||||
const [showCpuModel, setShowCpuModel] = useState(false);
|
||||
const tableState = useTableQueryState<lib.HostDetails>({
|
||||
namespace: "host", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
<PageHeader
|
||||
@@ -217,13 +224,20 @@ export default function HostsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
className: "table-link-cell",
|
||||
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
|
||||
{host.name}
|
||||
</Link>,
|
||||
...nameSearch,
|
||||
},
|
||||
{
|
||||
title: "CPU Model",
|
||||
dataIndex: "cpuModel",
|
||||
sorter: (a, b) => strcmp(systems.get(a.id)?.cpuModel ?? "", systems.get(b.id)?.cpuModel ?? ""),
|
||||
sortOrder: tableState.sortOrder("cpuModel"),
|
||||
render: (_, host) => systems.get(host.id)?.cpuModel,
|
||||
hidden: !showCpuModel,
|
||||
},
|
||||
@@ -231,6 +245,7 @@ export default function HostsPage() {
|
||||
title: "CPU%",
|
||||
key: "cpuPct",
|
||||
sorter: (a, b) => (systems.get(a.id)?.cpuRatio ?? 0) - (systems.get(b.id)?.cpuRatio ?? 0),
|
||||
sortOrder: tableState.sortOrder("cpuPct"),
|
||||
render: (_, host) => <MetricCpuRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -240,6 +255,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.cpuUsed ?? 0) - (systems.get(b.id)?.cpuUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("cores"),
|
||||
render: (_, host) => <MetricCpuUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -249,6 +265,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.memoryRatio ?? 0) - (systems.get(b.id)?.memoryRatio ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("memPct"),
|
||||
render: (_, host) => <MetricMemoryRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -258,6 +275,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.memoryUsed ?? 0) - (systems.get(b.id)?.memoryUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("memory"),
|
||||
render: (_, host) => <MetricMemoryUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -265,6 +283,7 @@ export default function HostsPage() {
|
||||
title: "Disk%",
|
||||
key: "diskPct",
|
||||
sorter: (a, b) => (systems.get(a.id)?.diskAvailable ?? 0) - (systems.get(b.id)?.diskAvailable ?? 0),
|
||||
sortOrder: tableState.sortOrder("diskPct"),
|
||||
render: (_, host) => <MetricDiskRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -274,6 +293,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.diskUsed ?? 0) - (systems.get(b.id)?.diskUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("disk"),
|
||||
render: (_, host) => <MetricDiskUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -287,11 +307,13 @@ export default function HostsPage() {
|
||||
</Tooltip>}
|
||||
</Space>,
|
||||
sorter: (a, b) => strcmp(a.version, b.version),
|
||||
sortOrder: tableState.sortOrder("version"),
|
||||
},
|
||||
{
|
||||
title: "Public address",
|
||||
dataIndex: "publicAddress",
|
||||
sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""),
|
||||
sortOrder: tableState.sortOrder("publicAddress"),
|
||||
},
|
||||
{
|
||||
title: "Connected",
|
||||
@@ -301,16 +323,14 @@ export default function HostsPage() {
|
||||
<RestartRequired system={systems.get(host.id)}/>
|
||||
</>,
|
||||
sorter: (a, b) => Number(a.connected) - Number(b.connected),
|
||||
sortOrder: tableState.sortOrder("connected"),
|
||||
},
|
||||
]}
|
||||
dataSource={[...hosts.values()]}
|
||||
rowKey={host => host.id}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/hosts/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
onRow={record => rowNav(`/hosts/${record.id}/view`)}
|
||||
/>
|
||||
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
|
||||
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
|
||||
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function InputVersion<
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
|
||||
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
|
||||
const [customVersion, setCustomVersion] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
@@ -39,6 +40,21 @@ function InputVersion<
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Release channels (e.g. stable, experimental) only apply to target versions
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) {
|
||||
const res = await control.latestReleases.get(5 * 60 * 1000);
|
||||
// Read the headless build's version directly rather than via a lib
|
||||
// helper: lib is a federation-shared module here and unused exports
|
||||
// can be tree-shaken out of it.
|
||||
setChannels(Object.entries(res).map(([name, builds]) => (
|
||||
{ name, version: builds.headless ?? Object.values(builds)[0] ?? "" }
|
||||
)));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
@@ -58,7 +74,10 @@ function InputVersion<
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
// Skip a patch equal to the group key (a bare major.minor version,
|
||||
// e.g. the ApiVersions fallback): the selectable group node already
|
||||
// represents it, and a duplicate value warns in the TreeSelect.
|
||||
children: patchVersions.filter((v) => v !== majorMinor).map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
@@ -66,9 +85,17 @@ function InputVersion<
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" option for TargetVersion
|
||||
// Add "latest" and release channel options for TargetVersion
|
||||
if (props.version.includeLatest) {
|
||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||
tree.unshift(
|
||||
{ title: "latest", value: "latest", key: "latest", children: [] },
|
||||
...channels.map(({ name, version }) => ({
|
||||
title: version ? `${name} (${version})` : name,
|
||||
value: name,
|
||||
key: name,
|
||||
children: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return <TreeSelect
|
||||
|
||||
@@ -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,9 +1,8 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Button, Table, Space } from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
import type { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { ColumnType } from "antd/es/table/interface";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useSystems } from "../model/system";
|
||||
@@ -14,6 +13,9 @@ import InstanceControlButton, { InstanceControlButtonPermissions } from "./Insta
|
||||
import * as lib from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -25,9 +27,15 @@ type InstanceListProps = {
|
||||
|
||||
export default function InstanceList(props: InstanceListProps) {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const tableState = useTableQueryState<lib.InstanceDetails>({
|
||||
namespace: "instance", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.InstanceDetails>(
|
||||
tableState, "name", instance => instance.name, "Search instances"
|
||||
);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function hostName(hostId?: number) {
|
||||
if (hostId === undefined) {
|
||||
@@ -37,32 +45,41 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
}
|
||||
|
||||
function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) {
|
||||
if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") {
|
||||
return -1;
|
||||
const v = instance.factorioVersion;
|
||||
if (v && lib.isPartialVersion(v)) {
|
||||
return lib.integerPartialVersion(v);
|
||||
}
|
||||
return lib.integerPartialVersion(instance.factorioVersion);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let columns: ColumnsType<lib.InstanceDetails> = [
|
||||
let columns: ColumnType<lib.InstanceDetails>[] = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
className: "table-link-cell",
|
||||
render: (_, instance) => <Link to={`/instances/${instance.id}/view`} style={{ color: "inherit" }}>
|
||||
{instance.name}
|
||||
</Link>,
|
||||
...nameSearch,
|
||||
},
|
||||
{
|
||||
title: "Assigned Host",
|
||||
key: "assignedHost",
|
||||
className: "table-link-cell",
|
||||
render: (_, instance) => <Space>
|
||||
<Link
|
||||
{instance.assignedHost !== undefined && <Link
|
||||
to={`/hosts/${instance.assignedHost}/view`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{hostName(instance.assignedHost)}
|
||||
</Link>
|
||||
</Link>}
|
||||
<RestartRequired system={instance.assignedHost ? systems.get(instance.assignedHost) : undefined}/>
|
||||
</Space>,
|
||||
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
|
||||
sortOrder: tableState.sortOrder("assignedHost"),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +104,7 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
instancePublicAddress(a, hosts.get(a.assignedHost!) ?? null),
|
||||
instancePublicAddress(b, hosts.get(b.assignedHost!) ?? null)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("publicAddress"),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
@@ -94,6 +112,7 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
key: "version",
|
||||
render: (_, instance) => instance.factorioVersion ?? "unknown",
|
||||
sorter: (a, b) => integerFactorioVersionOrDefault(a) - integerFactorioVersionOrDefault(b),
|
||||
sortOrder: tableState.sortOrder("version"),
|
||||
responsive: ["xl"],
|
||||
},
|
||||
{
|
||||
@@ -101,6 +120,7 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
key: "status",
|
||||
render: (_, instance) => <InstanceStatusTag status={instance["status"]} />,
|
||||
sorter: (a, b) => strcmp(a["status"], b["status"]),
|
||||
sortOrder: tableState.sortOrder("status"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -123,11 +143,8 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
columns={columns}
|
||||
dataSource={[...props.instances.values()]}
|
||||
rowKey={instance => instance["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/instances/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
onRow={record => rowNav(`/instances/${record.id}/view`)}
|
||||
/>;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography } from "antd";
|
||||
import {
|
||||
Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography, message,
|
||||
} from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import DownOutlined from "@ant-design/icons/DownOutlined";
|
||||
|
||||
@@ -25,11 +28,15 @@ import { useHost } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
import useLocalStorage from "../util/useLocalStorage";
|
||||
import { MetricRelativeDate } from "./system_metrics";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
const { Title } = Typography;
|
||||
|
||||
// Remembers the console "Chat/Log" toggle across reloads (defaults to on).
|
||||
const consoleActionsOnlyKey = "instance-console-actions-only";
|
||||
|
||||
type InstanceDescriptionProps = {
|
||||
host?: Readonly<lib.HostDetails>;
|
||||
instance: Readonly<lib.InstanceDetails>;
|
||||
@@ -39,10 +46,11 @@ function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
|
||||
const { host, instance } = props;
|
||||
let assigned = instance.assignedHost !== undefined;
|
||||
const publicAddress = instancePublicAddress(instance, host);
|
||||
return <Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={{ xs: 1, md: 3, lg: 3, xl: 3, xxl: 3 }}
|
||||
column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}
|
||||
>
|
||||
<Descriptions.Item label="Host">
|
||||
{!assigned
|
||||
@@ -63,6 +71,22 @@ function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
buttonContent={assigned ? "Reassign" : "Assign"}
|
||||
/>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Public address">
|
||||
{publicAddress
|
||||
? <Space>
|
||||
{publicAddress}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(publicAddress);
|
||||
message.success("Copied public address!");
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
: <em>N/A</em>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Version">
|
||||
{instance.factorioVersion ?? "unknown"}
|
||||
</Descriptions.Item>
|
||||
@@ -177,7 +201,7 @@ export default function InstanceViewPage() {
|
||||
let params = useParams();
|
||||
let instanceId = Number(params.id);
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("server");
|
||||
const [actionsOnly, setActionsOnly] = useState<boolean>(true);
|
||||
const [actionsOnly, setActionsOnly] = useLocalStorage(consoleActionsOnlyKey, true);
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
@@ -198,12 +222,7 @@ export default function InstanceViewPage() {
|
||||
description={<>Instance with id {instanceId} was not found on the controller.</>}
|
||||
type="warning"
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => { navigate("/instances"); }}
|
||||
>
|
||||
Go back to instances list
|
||||
</Button>
|
||||
<Link to="/instances">Go back to instances list</Link>
|
||||
}
|
||||
/>
|
||||
</PageLayout>;
|
||||
|
||||
@@ -53,7 +53,7 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
open={open}
|
||||
onOk={() => { createInstance().catch(notifyErrorHandler("Error creating instance")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) {
|
||||
confirmLoading={loadingScenario}
|
||||
onOk={loadScenario}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="scenario" label="Scenario">
|
||||
|
||||
@@ -4,10 +4,14 @@ import { Select, Typography } from "antd";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import useLocalStorage from "../util/useLocalStorage";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const consoleHeightKey = "instance-console-height";
|
||||
const defaultConsoleHeight = 300;
|
||||
|
||||
type Parsed = {
|
||||
format: string;
|
||||
type: string;
|
||||
@@ -99,6 +103,36 @@ export default function LogConsole(props: LogConsoleProps) {
|
||||
let anchor = useRef<any>();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
let [lines, setLines] = useState<ReactElement[]>([]);
|
||||
const [consoleHeight, setConsoleHeight] = useLocalStorage(consoleHeightKey, defaultConsoleHeight);
|
||||
const consoleHeightRef = useRef(consoleHeight);
|
||||
consoleHeightRef.current = consoleHeight;
|
||||
|
||||
// The console is resized with the browser's native resize handle, which owns
|
||||
// the element's inline height. Driving the height from React would rewrite
|
||||
// that inline style on every re-render and fight the drag (locking the size),
|
||||
// so the height is only read here (on mount) and persisted (on resize), never
|
||||
// written back through React. The scroll container is the <code> element
|
||||
// wrapping the anchor.
|
||||
useEffect(() => {
|
||||
const element = anchor.current?.parentElement as HTMLElement | undefined;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
element.style.height = `${consoleHeightRef.current}px`;
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
const next = Math.round(element.offsetHeight);
|
||||
if (next && next !== consoleHeightRef.current) {
|
||||
consoleHeightRef.current = next;
|
||||
setConsoleHeight(next);
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let logFilter = {
|
||||
@@ -164,6 +198,10 @@ export default function LogConsole(props: LogConsoleProps) {
|
||||
|
||||
return <>
|
||||
<Paragraph code className="instance-console">
|
||||
{/* Scrollable spacer (one console-height tall) that keeps short output
|
||||
pinned to the bottom and leaves room to scroll up. Unlike a
|
||||
padding-top it is scroll content, so it does not constrain resize. */}
|
||||
<div className="console-spacer" key="spacer" />
|
||||
{pastLines}
|
||||
{lines}
|
||||
<div className="scroll-anchor" key="anchor" ref={anchor} />
|
||||
|
||||
@@ -77,12 +77,12 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
|
||||
const [modResultCount, setModResultCount] = useState<number>(2);
|
||||
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
|
||||
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setFactorioVersion(null);
|
||||
}
|
||||
@@ -271,7 +271,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
|
||||
|
||||
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
|
||||
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // Missing mods and dependencies, excluding builtin
|
||||
@@ -295,7 +295,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
|
||||
setFactorioVersion(null);
|
||||
@@ -403,7 +403,9 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
missing.map(mod => ({
|
||||
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
|
||||
name: mod.name,
|
||||
// mod.version is the raw version; the equality bound must be canonical.
|
||||
version: new lib.ModVersionEquality("=", mod.normalisedVersion),
|
||||
})),
|
||||
factorioVersion,
|
||||
)
|
||||
@@ -577,8 +579,8 @@ function ModsTable(props: ModsTableProps) {
|
||||
if (mod.enabled && mod.info) {
|
||||
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
|
||||
try {
|
||||
const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion);
|
||||
const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion);
|
||||
const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion);
|
||||
const modFactorioVersion = mod.info.factorioVersion;
|
||||
if (packFactorioVersion !== modFactorioVersion) {
|
||||
mod.warning = "wrong_factorio_version";
|
||||
}
|
||||
@@ -1052,7 +1054,7 @@ function ExportButton(props: { modPack: lib.ModPack }) {
|
||||
open={open}
|
||||
onOk={close}
|
||||
onCancel={close}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
footer={<Space>
|
||||
<CopyButton content={exportString} />
|
||||
<Button onClick={close}>Close</Button>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {
|
||||
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
@@ -18,6 +17,10 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
@@ -145,7 +148,7 @@ function SearchModsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
|
||||
|
||||
// State for all mods fetched from backend
|
||||
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
|
||||
@@ -162,13 +165,16 @@ function SearchModsButton() {
|
||||
const handleControllerDownload = (
|
||||
modName: string,
|
||||
modTitle: string | undefined,
|
||||
modVersion: lib.FullVersion | undefined,
|
||||
portalFactorioVersion: lib.ApiVersion,
|
||||
modVersion: lib.SourceVersion | undefined,
|
||||
portalFactorioVersion: lib.MajorMinorVersion,
|
||||
) => {
|
||||
if (modVersion) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
|
||||
[{
|
||||
name: modName,
|
||||
version: new lib.ModVersionEquality("=", lib.normaliseSourceVersion(modVersion)),
|
||||
}],
|
||||
portalFactorioVersion
|
||||
)
|
||||
).then(() => {
|
||||
@@ -269,7 +275,7 @@ function SearchModsButton() {
|
||||
// Update search text and factorio version from form
|
||||
const handleSearch = (changedValues: any, allValues: any) => {
|
||||
const nameValue = allValues.name;
|
||||
const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined;
|
||||
const versionValue = allValues.factorioVersion as lib.MajorMinorVersion | undefined;
|
||||
|
||||
// Only trigger state updates if values actually changed
|
||||
if (nameValue !== searchText) {
|
||||
@@ -507,9 +513,19 @@ function SearchModsButton() {
|
||||
export default function ModsPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
let [mods] = useMods();
|
||||
let [modPacks] = useModPacks();
|
||||
const modPackTable = useTableQueryState<lib.ModPack>({
|
||||
namespace: "modPack", defaultSortKey: "name",
|
||||
});
|
||||
const modTable = useTableQueryState<lib.ModInfo>({
|
||||
namespace: "mod", defaultSortKey: "title",
|
||||
});
|
||||
const modPackNameSearch = useColumnSearch<lib.ModPack>(
|
||||
modPackTable, "name", modPack => modPack.name, "Search mod packs"
|
||||
);
|
||||
const modNameSearch = useColumnSearch<lib.ModInfo>(modTable, "title", mod => mod.title, "Search mods");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function actions(mod: lib.ModInfo) {
|
||||
return <Space>
|
||||
@@ -575,13 +591,23 @@ export default function ModsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
sortOrder: modPackTable.sortOrder("name"),
|
||||
filteredValue: modPackTable.filteredValue("name"),
|
||||
className: "table-link-cell",
|
||||
render: (_, modPack) => <Link
|
||||
to={`/mods/mod-packs/${modPack.id}/view`}
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{modPack.name}
|
||||
</Link>,
|
||||
...modPackNameSearch,
|
||||
},
|
||||
{
|
||||
title: "Factorio Version",
|
||||
dataIndex: "factorioVersion",
|
||||
sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion,
|
||||
sortOrder: modPackTable.sortOrder("factorioVersion"),
|
||||
},
|
||||
{
|
||||
title: "Mods",
|
||||
@@ -590,13 +616,10 @@ export default function ModsPage() {
|
||||
},
|
||||
]}
|
||||
dataSource={[...modPacks.values()]}
|
||||
pagination={false}
|
||||
pagination={modPackTable.pagination}
|
||||
onChange={modPackTable.onChange}
|
||||
rowKey={modPack => Number(modPack.id)}
|
||||
onRow={(modPack, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/mods/mod-packs/${modPack.id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)}
|
||||
/>
|
||||
<SectionHeader title="Stored Mods" extra={<Space wrap>
|
||||
<SearchModsButton />
|
||||
@@ -621,10 +644,12 @@ export default function ModsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "title",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => (
|
||||
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
|
||||
),
|
||||
sortOrder: modTable.sortOrder("title"),
|
||||
filteredValue: modTable.filteredValue("title"),
|
||||
...modNameSearch,
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
@@ -640,6 +665,7 @@ export default function ModsPage() {
|
||||
dataIndex: "filename",
|
||||
responsive: ["xl"],
|
||||
sorter: (a, b) => strcmp(a.filename, b.filename),
|
||||
sortOrder: modTable.sortOrder("filename"),
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
@@ -648,6 +674,7 @@ export default function ModsPage() {
|
||||
render: (_, mod) => lib.formatBytes(mod.size),
|
||||
align: "right",
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
sortOrder: modTable.sortOrder("size"),
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
@@ -661,7 +688,8 @@ export default function ModsPage() {
|
||||
expandedRowClassName: () => "no-expanded-padding",
|
||||
}}
|
||||
dataSource={[...mods.values()]}
|
||||
pagination={false}
|
||||
pagination={modTable.pagination}
|
||||
onChange={modTable.onChange}
|
||||
rowKey={mod => mod.id}
|
||||
/>
|
||||
</Upload.Dragger>
|
||||
|
||||
@@ -170,7 +170,7 @@ export function NpmButton(props: {
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Row, Col, Card, Statistic, Progress, Button, Typography } from "antd";
|
||||
import { LineChartOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useUsers, isUserOnline } from "../model/user";
|
||||
import { useSystems } from "../model/system";
|
||||
import { useSaves } from "../model/saves";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import LogConsole from "./LogConsole";
|
||||
import { MetricRelativeDate } from "./system_metrics";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
||||
|
||||
/** A resource card showing used / total with a usage bar. */
|
||||
function ResourceCard(
|
||||
props: { title: string, used: number, total: number, format: (n: number) => string, unit?: string }
|
||||
) {
|
||||
const percent = props.total > 0 ? Math.round((props.used / props.total) * 100) : 0;
|
||||
const suffix = `/ ${props.format(props.total)}${props.unit ? ` ${props.unit}` : ""}`;
|
||||
return <Card>
|
||||
<Statistic title={props.title} value={props.format(props.used)} suffix={suffix} />
|
||||
<Progress percent={percent} size="small" />
|
||||
</Card>;
|
||||
}
|
||||
|
||||
/** Format a core count: a fraction for partial use, a whole number for the total. */
|
||||
const formatCores = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||
|
||||
export default function OverviewPage() {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [hosts] = useHosts();
|
||||
const [instances] = useInstances();
|
||||
const [users] = useUsers();
|
||||
const [systems] = useSystems();
|
||||
const [saves] = useSaves();
|
||||
const [grafanaUrl, setGrafanaUrl] = useState<string | null>(null);
|
||||
|
||||
const canHosts = account.hasPermission("core.host.subscribe");
|
||||
const canSystems = account.hasPermission("core.system.subscribe");
|
||||
const canInstances = account.hasPermission("core.instance.subscribe");
|
||||
const canUsers = account.hasPermission("core.user.subscribe");
|
||||
const canSaves = account.hasPermission("core.instance.save.subscribe");
|
||||
const canLogs = account.hasPermission("core.log.follow");
|
||||
const canConfig = account.hasPermission("core.controller.get_config");
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig) {
|
||||
return undefined;
|
||||
}
|
||||
let canceled = false;
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(serialized => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
const config = lib.ControllerConfig.fromJSON(serialized, "control");
|
||||
const url = config.get("controller.grafana_url");
|
||||
setGrafanaUrl(typeof url === "string" ? url : null);
|
||||
}).catch(() => { /* link stays hidden if the config can't be read */ });
|
||||
return () => { canceled = true; };
|
||||
}, [control, canConfig]);
|
||||
|
||||
const hostList = [...hosts.values()];
|
||||
const instanceList = [...instances.values()];
|
||||
const userList = [...users.values()];
|
||||
// Resource totals across the connected host machines (the controller is shown only for uptime).
|
||||
const hostSystems = [...systems.values()].filter(system => system.id !== "controller");
|
||||
const controllerSystem = systems.get("controller");
|
||||
const saveList = [...saves.values()];
|
||||
const loadedSaveSize = sum(saveList.filter(save => save.loaded).map(save => save.size));
|
||||
const totalSaveSize = sum(saveList.map(save => save.size));
|
||||
|
||||
const colProps = { xs: 24, sm: 12, lg: 6 };
|
||||
|
||||
return <PageLayout nav={[{ name: "Overview" }]}>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
extra={grafanaUrl
|
||||
? <Button icon={<LineChartOutlined />} href={grafanaUrl} target="_blank" rel="noreferrer">
|
||||
Grafana
|
||||
</Button>
|
||||
: undefined}
|
||||
/>
|
||||
<Row gutter={[16, 16]}>
|
||||
{canHosts && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Hosts connected" value={hostList.filter(h => h.connected).length}
|
||||
suffix={`/ ${hostList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canInstances && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Instances running" value={instanceList.filter(i => i.status === "running").length}
|
||||
suffix={`/ ${instanceList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canUsers && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Users online" value={userList.filter(u => isUserOnline(u)).length}
|
||||
suffix={`/ ${userList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canSystems && <>
|
||||
<Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Controller running since" formatter={() => (
|
||||
<MetricRelativeDate timeMs={controllerSystem?.processStartedAtMs} compact />
|
||||
)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="CPU" used={sum(hostSystems.map(s => s.cpuUsed))}
|
||||
total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" />
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="Memory" used={sum(hostSystems.map(s => s.memoryUsed))}
|
||||
total={sum(hostSystems.map(s => s.memoryCapacity))} format={lib.formatBytes} />
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="Disk" used={sum(hostSystems.map(s => s.diskUsed))}
|
||||
total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} />
|
||||
</Col>
|
||||
</>}
|
||||
{canSaves && <Col {...colProps}>
|
||||
<ResourceCard title="Saves loaded" used={loadedSaveSize} total={totalSaveSize}
|
||||
format={lib.formatBytes} />
|
||||
</Col>}
|
||||
</Row>
|
||||
{canLogs && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Recent errors</Title>
|
||||
<LogConsole all maxLevel="warn" />
|
||||
</>}
|
||||
<PluginExtra component="OverviewPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Table } from "antd";
|
||||
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
|
||||
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
|
||||
|
||||
import type { PluginWebApi } from "@clusterio/lib";
|
||||
import type { PluginWebApi, PluginWebpackEnvInfo } from "@clusterio/lib";
|
||||
|
||||
import notify from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PageHeader from "./PageHeader";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
type PluginRow = {
|
||||
meta: PluginWebApi;
|
||||
info?: PluginWebpackEnvInfo;
|
||||
package?: any;
|
||||
};
|
||||
|
||||
|
||||
export default function PluginsPage() {
|
||||
const control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
|
||||
const tableState = useTableQueryState<PluginRow>({
|
||||
namespace: "plugin", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<PluginRow>(
|
||||
tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
|
||||
);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -30,7 +45,7 @@ export default function PluginsPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
let tableContents = [];
|
||||
let tableContents: PluginRow[] = [];
|
||||
for (let meta of pluginList) {
|
||||
if (control.plugins.has(meta.name)) {
|
||||
let plugin = control.plugins.get(meta.name)!;
|
||||
@@ -54,9 +69,17 @@ export default function PluginsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name),
|
||||
defaultSortOrder: "ascend",
|
||||
className: "table-link-cell",
|
||||
render: (_, plugin) => <Link
|
||||
to={`/plugins/${plugin.meta.name}/view`}
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{plugin.info ? plugin.info.title : plugin.meta.name}
|
||||
</Link>,
|
||||
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
...nameSearch,
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
@@ -74,23 +97,22 @@ export default function PluginsPage() {
|
||||
return plugin.package.version;
|
||||
},
|
||||
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
|
||||
sortOrder: tableState.sortOrder("version"),
|
||||
},
|
||||
{
|
||||
title: "Loaded",
|
||||
key: "loaded",
|
||||
render: (_, plugin) => (plugin.package ? "Yes" : null),
|
||||
sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)),
|
||||
sortOrder: tableState.sortOrder("loaded"),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
]}
|
||||
dataSource={tableContents}
|
||||
rowKey={plugin => plugin.meta.name}
|
||||
pagination={false}
|
||||
onRow={plugin => ({
|
||||
onClick: event => {
|
||||
navigate(`/plugins/${plugin.meta.name}/view`);
|
||||
},
|
||||
})}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { notifyErrorHandler } from "../util/notify";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -45,7 +49,7 @@ function CreateRoleButton() {
|
||||
open={open}
|
||||
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="roleName" label="Name">
|
||||
@@ -61,8 +65,10 @@ function CreateRoleButton() {
|
||||
|
||||
export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
const [roles] = useRoles();
|
||||
const tableState = useTableQueryState<lib.Role>({ namespace: "role" });
|
||||
const nameSearch = useColumnSearch<lib.Role>(tableState, "name", role => role.name, "Search roles");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
<PageHeader
|
||||
@@ -75,6 +81,13 @@ export default function RolesPage() {
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
className: "table-link-cell",
|
||||
render: (_, role) => <Link to={`/roles/${role.id}/view`} style={{ color: "inherit" }}>
|
||||
{role.name}
|
||||
</Link>,
|
||||
...nameSearch,
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
@@ -82,13 +95,10 @@ export default function RolesPage() {
|
||||
},
|
||||
]}
|
||||
dataSource={[...roles.values()]}
|
||||
pagination={false}
|
||||
pagination={tableState.pagination}
|
||||
rowKey={role => role.id}
|
||||
onRow={(role, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/roles/${role.id}/view`);
|
||||
},
|
||||
})}
|
||||
onChange={tableState.onChange}
|
||||
onRow={role => rowNav(`/roles/${role.id}/view`)}
|
||||
/>
|
||||
<PluginExtra component="RolesPage" />
|
||||
</PageLayout>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import ControlContext from "./ControlContext";
|
||||
import CreateSaveModal from "./CreateSaveModal";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
@@ -119,7 +120,7 @@ function TransferModal(props: ModalProps) {
|
||||
open={open}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
@@ -204,6 +205,9 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
let [saves] = useSavesOfInstance(props.instance.id);
|
||||
let [starting, setStarting] = useState(false);
|
||||
let [uploadingFiles, setUploadingFiles] = useState<File[]>([]);
|
||||
const tableState = useTableQueryState<lib.SaveDetails>({
|
||||
namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend",
|
||||
});
|
||||
|
||||
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
|
||||
const saveTable = <Table
|
||||
@@ -219,6 +223,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined /></Tooltip>}
|
||||
</>,
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
@@ -227,16 +232,19 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
render: (_, save) => lib.formatBytes(save.size),
|
||||
align: "right",
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
sortOrder: tableState.sortOrder("size"),
|
||||
},
|
||||
{
|
||||
title: "Last Modified",
|
||||
key: "mtimeMs",
|
||||
render: (_, save) => new Date(save.mtimeMs).toLocaleString(),
|
||||
sorter: (a, b) => a.mtimeMs - b.mtimeMs,
|
||||
defaultSortOrder: "descend",
|
||||
sortOrder: tableState.sortOrder("mtimeMs"),
|
||||
},
|
||||
]}
|
||||
dataSource={[...saves.values()]}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
rowKey={save => save.name}
|
||||
expandable={{
|
||||
columnWidth: 33,
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
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 { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd";
|
||||
import { UserOutlined, DownloadOutlined, MenuOutlined } 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 Link from "./Link";
|
||||
|
||||
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 logo from "../images/logo.png";
|
||||
|
||||
import { ControlConfig } from "@clusterio/lib";
|
||||
import { DownloadOutlined } from "@ant-design/icons";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
const { Header, Sider } = Layout;
|
||||
const { Sider, Header } = Layout;
|
||||
|
||||
// Render a sidebar menu label as a real link so right-click/middle-click can
|
||||
// open it in a new tab; stopPropagation keeps the Menu onClick from also firing.
|
||||
function navLink(path: string, label: string) {
|
||||
// color: inherit keeps the menu colour in every state (incl. the focus left
|
||||
// behind by a middle-click, which would otherwise show the link blue).
|
||||
return <Link to={path} style={{ color: "inherit" }} onClick={e => e.stopPropagation()}>{label}</Link>;
|
||||
}
|
||||
|
||||
function isActiveDropzone(element: HTMLElement | null): boolean {
|
||||
if (!element) {
|
||||
@@ -39,10 +52,20 @@ function isActiveDropzone(element: HTMLElement | null): boolean {
|
||||
export default function SiteLayout() {
|
||||
let navigate = useNavigate();
|
||||
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
|
||||
const screens = Grid.useBreakpoint();
|
||||
// On phone screens the sidebar becomes an overlay drawer instead of a fixed sider.
|
||||
// `md` matches the previous Sider breakpoint; `screens.md` is undefined until measured,
|
||||
// so treat the initial render as desktop to avoid flashing the hamburger.
|
||||
const isMobile = screens.md === false;
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
let account = useAccount();
|
||||
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,18 +84,27 @@ 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();
|
||||
}
|
||||
},
|
||||
items: [
|
||||
{ label: account.name, key: "user" },
|
||||
{
|
||||
label: <Link to={`/users/${account.name}/view`} style={{ color: "inherit" }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{account.name}
|
||||
</Link>,
|
||||
key: "user",
|
||||
},
|
||||
{
|
||||
label: <Tooltip title="Download credentials and configuration file for cli interface">
|
||||
Ctl Config <Button size="small" icon={<DownloadOutlined />} />
|
||||
</Tooltip>,
|
||||
key: "ctlConfig",
|
||||
},
|
||||
{ label: "About", key: "about" },
|
||||
{ type: "divider" },
|
||||
{ label: "Log out", danger: true, key: "logOut" },
|
||||
],
|
||||
@@ -101,9 +133,9 @@ export default function SiteLayout() {
|
||||
group = [];
|
||||
menuGroups.set(sidebarGroup, group);
|
||||
}
|
||||
group.push({ label: sidebarName, key: path });
|
||||
group.push({ label: navLink(path, sidebarName), key: path });
|
||||
} else {
|
||||
menuItems.push({ label: sidebarName, key: path });
|
||||
menuItems.push({ label: navLink(path, sidebarName), key: path });
|
||||
}
|
||||
}
|
||||
for (let [name, group] of menuGroups) {
|
||||
@@ -117,7 +149,39 @@ export default function SiteLayout() {
|
||||
setDragging(dragging + dragChange);
|
||||
}
|
||||
|
||||
// Navigate and dismiss the mobile drawer, matching hamburger menu conventions.
|
||||
function navigateAndClose(key: string) {
|
||||
navigate(key);
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
// The header trigger collapses the sider on desktop and toggles the overlay drawer on phones.
|
||||
function toggleSidebar() {
|
||||
if (isMobile) {
|
||||
setDrawerOpen(value => !value);
|
||||
} else {
|
||||
setCollapsed(value => !value);
|
||||
}
|
||||
}
|
||||
|
||||
let sidebarContent = <Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
defaultOpenKeys={[...menuGroups.keys()]}
|
||||
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
|
||||
style={{ height: "100%", overflow: "auto", borderInlineEnd: 0 }}
|
||||
onClick={({ key }) => navigateAndClose(key)}
|
||||
items={menuItems}
|
||||
/>;
|
||||
|
||||
let accountMenu = <Dropdown menu={accountMenuProps} trigger={["click"]} placement="bottomRight">
|
||||
<Button type="text" icon={<UserOutlined />} style={{ marginInlineStart: "auto" }}>
|
||||
{account.name}
|
||||
</Button>
|
||||
</Dropdown>;
|
||||
|
||||
return <Layout
|
||||
className="site-layout"
|
||||
style={{ minHeight: "100vh" }}
|
||||
onDragEnter={() => setDraggingProxy(1)}
|
||||
onDragLeave={() => setDraggingProxy(-1)}
|
||||
@@ -141,44 +205,75 @@ 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);
|
||||
}}
|
||||
/>
|
||||
<Header className="site-layout-header">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Toggle navigation menu"
|
||||
/>
|
||||
<Link to="/" style={{ display: "flex", alignItems: "center" }}>
|
||||
<img src={logo} width={56} height={56} alt="Clusterio logo" />
|
||||
</Link>
|
||||
<Flex vertical justify="center">
|
||||
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.2 }}>Clusterio</Typography.Title>
|
||||
<Tooltip title="View changelog" placement="right">
|
||||
<Typography.Text
|
||||
type="danger"
|
||||
style={{ cursor: "pointer", lineHeight: 1.2 }}
|
||||
onClick={() => setChangeLogOpen(true)}
|
||||
>
|
||||
{webUiPackage.version}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{accountMenu}
|
||||
</Header>
|
||||
<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>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
defaultSelectedKeys={["1"]}
|
||||
items={[{ label: "Dashboard", key: "1" }]}
|
||||
/>
|
||||
<Dropdown
|
||||
className="account-dropdown-header"
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
menu={accountMenuProps}
|
||||
>
|
||||
<UserOutlined />
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Layout className="site-layout">
|
||||
<Sider
|
||||
collapsible
|
||||
collapsedWidth={0}
|
||||
breakpoint="md"
|
||||
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
|
||||
width={250}
|
||||
className="site-layout-sider"
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultOpenKeys={[...menuGroups.keys()]}
|
||||
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout hasSider={!isMobile}>
|
||||
{isMobile
|
||||
? <Drawer
|
||||
placement="left"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={250}
|
||||
closable={false}
|
||||
// Start below the sticky 64px header so it stays visible and usable.
|
||||
rootStyle={{ top: 64 }}
|
||||
styles={{ body: { padding: 0, background: "#001529" } }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</Drawer>
|
||||
: <Sider
|
||||
width={250}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={0}
|
||||
trigger={null}
|
||||
className="site-layout-sider"
|
||||
>
|
||||
{sidebarContent}
|
||||
</Sider>
|
||||
}
|
||||
<Layout className="site-layout-content-container">
|
||||
<Routes>
|
||||
{combinedPages.map(({ path, sidebarPath, content }) => <Route
|
||||
|
||||
@@ -9,6 +9,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useInstances } from "../model/instance";
|
||||
import ControlContext from "./ControlContext";
|
||||
@@ -16,6 +17,8 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUser } from "../model/user";
|
||||
@@ -31,6 +34,7 @@ export default function UserViewPage() {
|
||||
let userName = params.name as string;
|
||||
|
||||
let navigate = useNavigate();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
@@ -38,6 +42,9 @@ export default function UserViewPage() {
|
||||
const [user, synced] = useUser(userName);
|
||||
let [roles] = useRoles();
|
||||
let [form] = Form.useForm();
|
||||
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
|
||||
namespace: "stats", defaultSortKey: "instance",
|
||||
});
|
||||
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
|
||||
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
|
||||
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
|
||||
@@ -298,21 +305,26 @@ export default function UserViewPage() {
|
||||
{
|
||||
title: "Instance",
|
||||
key: "instance",
|
||||
render: (_, [id]) => instanceName(id),
|
||||
defaultSortOrder: "ascend",
|
||||
className: "table-link-cell",
|
||||
render: (_, [id]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
|
||||
{instanceName(id)}
|
||||
</Link>,
|
||||
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
|
||||
sortOrder: statsTable.sortOrder("instance"),
|
||||
},
|
||||
{
|
||||
title: "Online time",
|
||||
key: "onlineTime",
|
||||
render: (_, [, stats]) => formatDuration(stats.onlineTimeMs || 0),
|
||||
sorter: (a, b) => (a[1].onlineTimeMs || 0) - (b[1].onlineTimeMs || 0),
|
||||
sortOrder: statsTable.sortOrder("onlineTime"),
|
||||
},
|
||||
{
|
||||
title: "Join count",
|
||||
key: "joinCoint",
|
||||
render: (_, [, stats]) => stats.joinCount || 0,
|
||||
sorter: (a, b) => (a[1].joinCount || 0) - (b[1].joinCount || 0),
|
||||
sortOrder: statsTable.sortOrder("joinCoint"),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
{
|
||||
@@ -320,22 +332,21 @@ export default function UserViewPage() {
|
||||
key: "firstSeen",
|
||||
render: (_, [id]) => formatFirstSeen(user, id),
|
||||
sorter: (a, b) => sortFirstSeen(user, user, a[0], b[0]),
|
||||
sortOrder: statsTable.sortOrder("firstSeen"),
|
||||
},
|
||||
{
|
||||
title: "Last seen",
|
||||
key: "lastSeen",
|
||||
render: (_, [id]) => formatLastSeen(user, id),
|
||||
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
|
||||
sortOrder: statsTable.sortOrder("lastSeen"),
|
||||
},
|
||||
]}
|
||||
dataSource={[...(user.instanceStats || []).entries()]}
|
||||
pagination={false}
|
||||
pagination={statsTable.pagination}
|
||||
onChange={statsTable.onChange}
|
||||
rowKey={([id]) => id}
|
||||
onRow={([id], rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/instances/${id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={([id]) => rowNav(`/instances/${id}/view`)}
|
||||
/>
|
||||
<PluginExtra component="UserViewPage" user={user} />
|
||||
</PageLayout>;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useRef } from "react";
|
||||
import { InputRef, Input, Space, Segmented, 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";
|
||||
import type { FilterCodec } from "../util/tableQuery";
|
||||
import type { TableQueryState } from "../util/useTableQueryState";
|
||||
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 triFromParam(value: string | null): boolean | undefined {
|
||||
if (value === "true") { return true; }
|
||||
if (value === "false") { return false; }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the bundled user filter as separate readable query parameters
|
||||
* (`filter.name` for the text search plus `filter.admin`/`filter.whitelisted`/
|
||||
* `filter.banned`) instead of one opaque JSON blob. Plug into
|
||||
* useTableQueryState via `filterCodecs: { name: userFilterCodec }`.
|
||||
*/
|
||||
export const userFilterCodec: FilterCodec = {
|
||||
decode(params, prefix) {
|
||||
const filter: UserFilter = {};
|
||||
const search = params.get(`${prefix}name`);
|
||||
if (search) { filter.search = search; }
|
||||
const admin = triFromParam(params.get(`${prefix}admin`));
|
||||
if (admin !== undefined) { filter.admin = admin; }
|
||||
const whitelisted = triFromParam(params.get(`${prefix}whitelisted`));
|
||||
if (whitelisted !== undefined) { filter.whitelisted = whitelisted; }
|
||||
const banned = triFromParam(params.get(`${prefix}banned`));
|
||||
if (banned !== undefined) { filter.banned = banned; }
|
||||
return Object.keys(filter).length ? [encodeFilter(filter)] : null;
|
||||
},
|
||||
encode(params, value, prefix) {
|
||||
const filter = value && value[0] ? decodeFilter(String(value[0])) : {};
|
||||
const set = (key: string, val: string | undefined) => (
|
||||
val !== undefined ? params.set(prefix + key, val) : params.delete(prefix + key)
|
||||
);
|
||||
set("name", filter.search || undefined);
|
||||
set("admin", filter.admin === undefined ? undefined : String(filter.admin));
|
||||
set("whitelisted", filter.whitelisted === undefined ? undefined : String(filter.whitelisted));
|
||||
set("banned", filter.banned === undefined ? undefined : String(filter.banned));
|
||||
},
|
||||
};
|
||||
|
||||
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: boolean | React.Key,
|
||||
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(
|
||||
tableState: TableQueryState<UserDetails>,
|
||||
columnKey: string,
|
||||
withStatus?: boolean,
|
||||
) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
function filterDropdown({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
close,
|
||||
}: FilterDropdownProps) {
|
||||
const filter: UserFilter = selectedKeys[0]
|
||||
? decodeFilter(String(selectedKeys[0]))
|
||||
: {};
|
||||
|
||||
// Update the live filter as the user types/toggles; the URL is only written when the
|
||||
// dropdown closes (see onOpenChange) or when enter is pressed (see onPressEnter)
|
||||
function update(next: Partial<UserFilter>) {
|
||||
const encoded = encodeFilter({ ...filter, ...next });
|
||||
const values = encoded === "{}" ? null : [encoded];
|
||||
setSelectedKeys(values ?? []);
|
||||
tableState.setFilter(columnKey, values);
|
||||
}
|
||||
|
||||
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
|
||||
onPressEnter={e => {
|
||||
close();
|
||||
tableState.commitFilter(columnKey);
|
||||
}}
|
||||
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
|
||||
/>
|
||||
|
||||
{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);
|
||||
} else {
|
||||
tableState.commitFilter(columnKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -49,7 +50,7 @@ function CreateUserButton() {
|
||||
open={open}
|
||||
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="userName" label="Name">
|
||||
@@ -344,7 +345,7 @@ function BulkUserActionButton() {
|
||||
okButtonProps={{ disabled: formAction === undefined }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
@@ -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,113 +1,123 @@
|
||||
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, type TablePaginationConfig } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import {
|
||||
formatFirstSeen,
|
||||
formatLastSeen,
|
||||
sortFirstSeen,
|
||||
sortLastSeen,
|
||||
useUsers,
|
||||
calculateFirstSeen, formatFirstSeen, sortFirstSeen,
|
||||
calculateLastSeen, formatLastSeen, sortLastSeen,
|
||||
useUsers, getUserStats, isUserOnline,
|
||||
} from "../model/user";
|
||||
|
||||
import { onFilterUser, Username, useUserFilter, userFilterCodec } from "./UsersFilters";
|
||||
import Link from "./Link";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
|
||||
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;
|
||||
/** Ant Design pagination prop. Pass `false` to disable pagination. */
|
||||
pagination?: false | object;
|
||||
pagination?: false | TablePaginationConfig;
|
||||
/** Ant Design size prop */
|
||||
size?: "small" | "middle" | "large";
|
||||
}
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
|
||||
const [roles] = useRoles();
|
||||
const navigate = useNavigate();
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
export default function UsersTable(
|
||||
{ instanceId, onlyOnline = false, pagination, size }: UsersTableProps
|
||||
) {
|
||||
const [roles, rolesSynced] = useRoles();
|
||||
const [users, usersSynced] = useUsers();
|
||||
const tableState = useTableQueryState<lib.UserDetails>({
|
||||
namespace: "user",
|
||||
defaultSortKey: "name",
|
||||
// Default to 50 per page when the caller does not specify a pagination config.
|
||||
pagination: pagination === false ? false : (pagination ?? { defaultPageSize: 50 }),
|
||||
filterCodecs: { name: userFilterCodec },
|
||||
});
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
const [users] = useUsers();
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", 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",
|
||||
className: "table-link-cell",
|
||||
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>
|
||||
<Link to={`/users/${user.name}/view`} style={{ color: "inherit" }}>
|
||||
<Username user={user} withStatus />
|
||||
</Link>
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("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",
|
||||
key: "roles",
|
||||
filters: roleFilters,
|
||||
filterMultiple: true,
|
||||
onFilter: (value: string | number | boolean, record: lib.UserDetails) => (
|
||||
record.roleIds.has(value as number)
|
||||
filteredValue: tableState.filteredValue("roles"),
|
||||
onFilter: (value: boolean | React.Key, record: lib.UserDetails) => (
|
||||
record.roleIds.has(Number(value))
|
||||
),
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
[...user.roleIds].map((id) => (
|
||||
@@ -117,149 +127,83 @@ 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);
|
||||
},
|
||||
sortOrder: tableState.sortOrder("onlineTime"),
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
sortOrder: tableState.sortOrder("joinCount"),
|
||||
},
|
||||
{
|
||||
title: "First Seen",
|
||||
key: "firstSeen",
|
||||
filterMultiple: false,
|
||||
filters: durationFilters,
|
||||
filteredValue: tableState.filteredValue("firstSeen"),
|
||||
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),
|
||||
sortOrder: tableState.sortOrder("firstSeen"),
|
||||
},
|
||||
{
|
||||
title: "Last Seen",
|
||||
key: "lastSeen",
|
||||
filterMultiple: false,
|
||||
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
|
||||
filters: [
|
||||
{ text: "Online", value: "online" },
|
||||
{ text: "24h", value: "24h" },
|
||||
{ text: "7d", value: "7d" },
|
||||
{ text: "30d", value: "30d" },
|
||||
],
|
||||
onFilter: (value: string | number | boolean, record: lib.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;
|
||||
}
|
||||
},
|
||||
filters: durationFilters,
|
||||
// With the onlyOnline prop set, seed this column's filter to "online" until the
|
||||
// URL provides one, so the table starts out showing only online players.
|
||||
filteredValue: tableState.filteredValue("lastSeen") ?? (onlyOnline ? ["online"] : null),
|
||||
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
|
||||
sortOrder: tableState.sortOrder("lastSeen"),
|
||||
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
const defaultPagination = pagination === undefined
|
||||
? {
|
||||
defaultPageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}
|
||||
: pagination;
|
||||
const displayPagination = {
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
};
|
||||
let tablePagination: false | object;
|
||||
if (pagination === false) {
|
||||
tablePagination = false;
|
||||
} else {
|
||||
// Controlled current/pageSize from the URL win over the caller's display props.
|
||||
tablePagination = { ...displayPagination, ...pagination, ...(tableState.pagination || {}) };
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey={(user) => user.name}
|
||||
pagination={defaultPagination}
|
||||
pagination={tablePagination}
|
||||
onChange={tableState.onChange}
|
||||
size={size}
|
||||
scroll={{ x: "max-content" }}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
loading={!usersSynced || !rolesSynced}
|
||||
onRow={(user) => rowNav(`/users/${user.name}/view`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -106,9 +106,13 @@ function humanAbsTimeDiff(date1: number, date2 = 0) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function MetricRelativeDate(props: { timeMs?: number }) {
|
||||
export function MetricRelativeDate(props: { timeMs?: number, compact?: boolean }) {
|
||||
if (!props.timeMs || props.timeMs === 0) {
|
||||
return "N/A";
|
||||
}
|
||||
return `${new Date(props.timeMs).toLocaleString()} (${humanAbsTimeDiff(props.timeMs, Date.now())})`;
|
||||
const diff = humanAbsTimeDiff(props.timeMs, Date.now());
|
||||
const absolute = props.compact
|
||||
? new Date(props.timeMs).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })
|
||||
: new Date(props.timeMs).toLocaleString();
|
||||
return `${absolute} (${diff})`;
|
||||
}
|
||||
@@ -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 |
@@ -42,3 +42,7 @@ export { default as SectionHeader } from "./components/SectionHeader";
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
|
||||
export { default as useTableQueryState } from "./util/useTableQueryState";
|
||||
export { default as useColumnSearch } from "./util/useColumnSearch";
|
||||
export { default as useRowNavigation } from "./util/useRowNavigation";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { ReactElement } from "react";
|
||||
|
||||
import { PluginPage, UserAccount } from "./BaseWebPlugin";
|
||||
|
||||
import OverviewPage from "./components/OverviewPage";
|
||||
import ControllerPage from "./components/ControllerPage";
|
||||
import HostsPage from "./components/HostsPage";
|
||||
import HostViewPage from "./components/HostViewPage";
|
||||
@@ -21,6 +22,11 @@ import IconReferencePage from "./components/IconReferencePage";
|
||||
// When adding or editing page paths here the corresponding webRoutes entry
|
||||
// in packages/controller/src/routes.ts also needs to be updated.
|
||||
export const pages: PluginPage[] = [
|
||||
{
|
||||
path: "/",
|
||||
sidebarName: "Overview",
|
||||
content: <OverviewPage />,
|
||||
},
|
||||
{
|
||||
path: "/controller",
|
||||
sidebarName: "Controller",
|
||||
|
||||
@@ -10,6 +10,14 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Make a table link-cell's link fill the cell width, so the whole column
|
||||
width is clickable and right-click "open in new tab"-able, not just the
|
||||
text. Uses display:block rather than an overlay so sibling content (e.g.
|
||||
the restart-required icon) stays interactive. */
|
||||
.table-link-cell a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -33,17 +41,13 @@ body {
|
||||
}
|
||||
|
||||
.site-breadcrumb {
|
||||
margin: 16px 0 16px 52px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.site-layout-content-container {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.site-breadcrumb {
|
||||
margin-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -56,13 +60,30 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.site-layout-sider {
|
||||
z-index: 0;
|
||||
/* Top bar hosting the menu toggle and branding, sticky above the content. */
|
||||
.site-layout-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-inline: 16px;
|
||||
background: #001529;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-left: 250px;
|
||||
padding-right: 64px;
|
||||
/* Keep the sidebar fixed in place (below the header) while content scrolls. */
|
||||
.site-layout-sider {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
height: calc(100vh - 64px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Snap the sider open/closed instead of animating its width — the width
|
||||
transition reflowed the content area and caused styling glitches. */
|
||||
.ant-layout-sider.site-layout-sider {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -105,75 +126,6 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 112px;
|
||||
font-size: 18pt;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.site-version {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 120px;
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
position: absolute;
|
||||
left: 36px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-image: url("./images/logo.png");
|
||||
background-size: 64px;
|
||||
}
|
||||
|
||||
@media not all and (min-width: 576px) {
|
||||
.header {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-version {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account-dropdown-header {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 18px;
|
||||
line-height: 72px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.account-dropdown-header::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
transition: all 0.3s;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.account-dropdown-header:hover::after {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.error-traceback code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
@@ -222,18 +174,25 @@ body {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Scroll headroom above the output so short output sits at the bottom; it is
|
||||
scroll content, not box padding, so it does not block dragging smaller. */
|
||||
.instance-console code .console-spacer {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.instance-console code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
border-color: #252525;
|
||||
/* Drag-resizable (the native handle needs the scroll overflow); height is set
|
||||
imperatively in LogConsole, border-box keeps padding out of offsetHeight. */
|
||||
box-sizing: border-box;
|
||||
height: 300px;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
overflow-y: scroll;
|
||||
white-space: pre-wrap;
|
||||
|
||||
/* Make sure there's at least one pixel of scroll space, this */
|
||||
/* is necessary for the view to stick on the scroll anchor. */
|
||||
padding-top: 300px;
|
||||
}
|
||||
|
||||
.instance-console .factorio-time {
|
||||
@@ -409,3 +368,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;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Pure helpers for storing antd Table state (sort / filters / pagination) in
|
||||
* the URL query string. Kept free of React and antd runtime imports so the
|
||||
* serialisation can be unit tested in isolation.
|
||||
*
|
||||
* Every parameter is namespaced with the table's name so the URL is
|
||||
* self-describing and two tables could coexist on one page without colliding:
|
||||
* <ns>.sort `<field>.<asc|desc>`, e.g. user.sort=name.desc
|
||||
* <ns>.page 1-based page number
|
||||
* <ns>.size rows per page
|
||||
* <ns>.<column> filter value for that column (e.g. user.name, user.admin)
|
||||
*
|
||||
* System keys (`sort`, `page`, `size`) are reserved, so filterable columns must
|
||||
* not use those names. `prefix` below is the namespace with its trailing dot,
|
||||
* e.g. "user.". Multi-value filters use a repeated parameter
|
||||
* (`user.roles=1&user.roles=2`) so URLSearchParams handles encoding once.
|
||||
*/
|
||||
|
||||
export type SortOrder = "ascend" | "descend";
|
||||
|
||||
export interface TableSortState {
|
||||
/** Column key the table is sorted by, or undefined for no/default sort. */
|
||||
columnKey?: string;
|
||||
/** Sort direction, or undefined when unsorted. */
|
||||
order?: SortOrder;
|
||||
}
|
||||
|
||||
export interface TablePageState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** A single filter value as produced by antd (React.Key is string | number | bigint). */
|
||||
export type FilterValue = string | number | bigint | boolean;
|
||||
|
||||
/**
|
||||
* Maps a single column's antd filter value to/from one or more query
|
||||
* parameters. Lets a column that bundles several controls (e.g. the user
|
||||
* filter) persist as separate readable params instead of one opaque blob.
|
||||
* `prefix` is the table namespace with its trailing dot, e.g. "user.".
|
||||
*/
|
||||
export interface FilterCodec {
|
||||
/** Read this column's `filteredValue` from the query, or null when inactive. */
|
||||
decode(params: URLSearchParams, prefix: string): string[] | null;
|
||||
/** Write this column's antd filter value into `params` (mutating). */
|
||||
encode(params: URLSearchParams, value: ReadonlyArray<FilterValue> | null, prefix: string): void;
|
||||
}
|
||||
|
||||
/** Suffixes reserved for table-level state; any other `<prefix><x>` is a column filter. */
|
||||
const RESERVED_SUFFIXES = new Set(["sort", "page", "size"]);
|
||||
|
||||
/** Convert a URL sort direction token to the antd sort order. */
|
||||
function orderFromToken(token: string): SortOrder {
|
||||
return token === "desc" ? "descend" : "ascend";
|
||||
}
|
||||
|
||||
/** Convert an antd sort order to a URL direction token. */
|
||||
function tokenFromOrder(order: SortOrder): string {
|
||||
return order === "descend" ? "desc" : "asc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sort state from the query, falling back to the supplied defaults
|
||||
* when no sort parameter is present. The value is `<field>.<asc|desc>`; only the
|
||||
* first `<prefix>sort` parameter is used (the format allows repeats so
|
||||
* multi-column sort can be added later without changing the URL shape).
|
||||
*/
|
||||
export function parseSort(
|
||||
params: URLSearchParams,
|
||||
prefix: string,
|
||||
defaultKey?: string,
|
||||
defaultOrder: SortOrder = "ascend",
|
||||
): TableSortState {
|
||||
const raw = params.getAll(`${prefix}sort`)[0];
|
||||
if (!raw) {
|
||||
return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined };
|
||||
}
|
||||
const dot = raw.lastIndexOf(".");
|
||||
const columnKey = dot === -1 ? raw : raw.slice(0, dot);
|
||||
const order = dot === -1 ? defaultOrder : orderFromToken(raw.slice(dot + 1));
|
||||
return { columnKey, order };
|
||||
}
|
||||
|
||||
/** Read pagination state from the query, tolerating missing/garbage values. */
|
||||
export function parsePagination(
|
||||
params: URLSearchParams,
|
||||
prefix: string,
|
||||
defaultPageSize: number,
|
||||
defaultPage = 1,
|
||||
): TablePageState {
|
||||
const page = Number.parseInt(params.get(`${prefix}page`) ?? "", 10);
|
||||
const pageSize = Number.parseInt(params.get(`${prefix}size`) ?? "", 10);
|
||||
return {
|
||||
page: Number.isInteger(page) && page >= 1 ? page : defaultPage,
|
||||
pageSize: Number.isInteger(pageSize) && pageSize >= 1 ? pageSize : defaultPageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Collect every namespaced column-filter parameter into a map of column key -> values. */
|
||||
export function parseFilters(params: URLSearchParams, prefix: string): Record<string, string[]> {
|
||||
const filters: Record<string, string[]> = {};
|
||||
for (const key of params.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
const column = key.slice(prefix.length);
|
||||
if (!RESERVED_SUFFIXES.has(column) && !(column in filters)) {
|
||||
filters[column] = params.getAll(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the sort state into `params` (mutating it). The parameters are omitted
|
||||
* when the sort matches the table default or is cleared, keeping default URLs
|
||||
* clean.
|
||||
*/
|
||||
export function applySort(
|
||||
params: URLSearchParams,
|
||||
prefix: string,
|
||||
sort: TableSortState,
|
||||
defaultKey?: string,
|
||||
defaultOrder: SortOrder = "ascend",
|
||||
): void {
|
||||
const { columnKey, order } = sort;
|
||||
const isDefault = columnKey === defaultKey && (order ?? defaultOrder) === defaultOrder;
|
||||
params.delete(`${prefix}sort`);
|
||||
if (columnKey && order && !isDefault) {
|
||||
params.set(`${prefix}sort`, `${columnKey}.${tokenFromOrder(order)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the filter state into `params` (mutating it). `filters` is the object
|
||||
* antd hands to onChange: every filterable column key mapped to its current
|
||||
* value array, or null when inactive.
|
||||
*/
|
||||
export function applyFilters(
|
||||
params: URLSearchParams,
|
||||
prefix: string,
|
||||
filters: Record<string, ReadonlyArray<FilterValue> | null>,
|
||||
): void {
|
||||
for (const [key, values] of Object.entries(filters)) {
|
||||
const param = prefix + key;
|
||||
params.delete(param);
|
||||
if (values) {
|
||||
for (const value of values) {
|
||||
params.append(param, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write pagination into `params` (mutating it). Defaults (page 1 / default
|
||||
* page size) are omitted to keep URLs clean.
|
||||
*/
|
||||
export function applyPagination(
|
||||
params: URLSearchParams,
|
||||
prefix: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
defaultPageSize: number,
|
||||
): void {
|
||||
if (page > 1) {
|
||||
params.set(`${prefix}page`, String(page));
|
||||
} else {
|
||||
params.delete(`${prefix}page`);
|
||||
}
|
||||
if (pageSize !== defaultPageSize) {
|
||||
params.set(`${prefix}size`, String(pageSize));
|
||||
} else {
|
||||
params.delete(`${prefix}size`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Input, InputRef } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
|
||||
import type { TableQueryState } from "./useTableQueryState";
|
||||
|
||||
/**
|
||||
* Column props for a free-text search filter rendered in the column header
|
||||
* dropdown (the same style as the Users table). Spread the result onto a column
|
||||
* whose `filteredValue` is `tableState.filteredValue(columnKey)`.
|
||||
*
|
||||
* Filtering is live: the table updates as you type via {@link TableQueryState.setFilter},
|
||||
* and the value is only written to the URL when the dropdown closes (via
|
||||
* {@link TableQueryState.commitFilter}).
|
||||
*
|
||||
* @param tableState The table's query state.
|
||||
* @param columnKey Key of the column this search filters (matches its filteredValue key).
|
||||
* @param getText Returns the searchable text for a row.
|
||||
* @param placeholder Placeholder shown in the search box.
|
||||
*/
|
||||
export default function useColumnSearch<T>(
|
||||
tableState: TableQueryState<T>,
|
||||
columnKey: string,
|
||||
getText: (record: T) => string,
|
||||
placeholder = "Search",
|
||||
) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
return {
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
|
||||
<div style={{ padding: 8, width: 240 }} onKeyDown={e => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
ref={searchInput}
|
||||
placeholder={placeholder}
|
||||
value={selectedKeys[0] as string | undefined}
|
||||
allowClear
|
||||
onChange={e => {
|
||||
const values = e.target.value ? [e.target.value] : [];
|
||||
setSelectedKeys(values);
|
||||
tableState.setFilter(columnKey, values.length ? values : null);
|
||||
}}
|
||||
onPressEnter={e => {
|
||||
close();
|
||||
tableState.commitFilter(columnKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
filterDropdownProps: {
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
} else {
|
||||
tableState.commitFilter(columnKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
onFilter: (value: React.Key | boolean, record: T) => (
|
||||
getText(record).toLowerCase().includes(String(value).toLowerCase())
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Persist a JSON-serialisable value in localStorage. Behaves like useState, but
|
||||
* seeds the initial value from localStorage and writes changes back. The write
|
||||
* is deferred to a later task so it never blocks the state update / re-render.
|
||||
*/
|
||||
export default function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(stored) as T;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
function set(next: T) {
|
||||
setValue(next);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. quota exceeded or storage disabled).
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/** Resolve an in-app path to a full path including the router basename. */
|
||||
function resolvePath(to: string): string {
|
||||
const base = webRoot.replace(/\/$/, "");
|
||||
return base + to;
|
||||
}
|
||||
|
||||
function isInsideLink(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLElement && target.closest("a") !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation handlers for a whole table row that also support "open in new
|
||||
* tab": left-click navigates in place, middle-click or ctrl/cmd-click opens the
|
||||
* target in a new tab. Spread the result into the object returned from antd's
|
||||
* `onRow`, e.g. `onRow={record => rowNav(`/hosts/${record.id}/view`)}`. Clicks
|
||||
* that land on a real link inside the row are ignored so the anchor handles
|
||||
* them (and shows the native context menu / new-tab affordances itself).
|
||||
*/
|
||||
export default function useRowNavigation() {
|
||||
const navigate = useNavigate();
|
||||
return function rowNavigation(to: string): React.HTMLAttributes<HTMLElement> {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: (event) => {
|
||||
if (event.defaultPrevented || isInsideLink(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
window.open(resolvePath(to), "_blank", "noopener");
|
||||
} else {
|
||||
navigate(to);
|
||||
}
|
||||
},
|
||||
onAuxClick: (event) => {
|
||||
// Middle click opens the row in a new tab.
|
||||
if (event.button === 1 && !isInsideLink(event.target)) {
|
||||
event.preventDefault();
|
||||
window.open(resolvePath(to), "_blank", "noopener");
|
||||
}
|
||||
},
|
||||
onMouseDown: (event) => {
|
||||
// Suppress the middle-click autoscroll cursor.
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { TablePaginationConfig } from "antd";
|
||||
import type { FilterValue, SorterResult } from "antd/es/table/interface";
|
||||
|
||||
import {
|
||||
applyFilters, applyPagination, applySort,
|
||||
parseFilters, parsePagination, parseSort,
|
||||
type FilterCodec, type SortOrder,
|
||||
} from "./tableQuery";
|
||||
|
||||
export interface TableQueryStateOptions {
|
||||
/** Table name used to namespace every query parameter, e.g. "user" -> `user.sort`, `user.name`. */
|
||||
namespace: string;
|
||||
/** Column key sorted by default (no `sort` param present). Omit for no default sort. */
|
||||
defaultSortKey?: string;
|
||||
/** Direction of the default sort. Default "ascend". */
|
||||
defaultSortOrder?: SortOrder;
|
||||
/** Pagination config to persist pagination; only `defaultPageSize` is read (omit for `pagination={false}`). */
|
||||
pagination?: false | TablePaginationConfig;
|
||||
/** Per-column-key codecs to persist a bundled filter value as separate readable params. */
|
||||
filterCodecs?: Record<string, FilterCodec>;
|
||||
}
|
||||
|
||||
export interface TableQueryState<T> {
|
||||
/** Spread onto `<Table onChange={...}>` to persist sort/filter/pagination (pushes history). */
|
||||
onChange: (
|
||||
pagination: TablePaginationConfig,
|
||||
filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<T> | SorterResult<T>[],
|
||||
) => void;
|
||||
/** Controlled `sortOrder` for the column with this key (null unless it's the active sort). */
|
||||
sortOrder: (columnKey: string) => SortOrder | null;
|
||||
/** Controlled `filteredValue` for the column with this key (null when no filter is active). */
|
||||
filteredValue: (columnKey: string) => string[] | null;
|
||||
/** Set a column's live filter — filters the table immediately without writing the URL. */
|
||||
setFilter: (columnKey: string, values: string[] | null) => void;
|
||||
/** Commit a column's filter to the URL: pass `values` to write now (e.g. clear), omit to commit the override. */
|
||||
commitFilter: (columnKey: string, values?: string[] | null) => void;
|
||||
/** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */
|
||||
pagination: false | TablePaginationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an antd Table's sort, column filters and pagination into the URL
|
||||
* query string, so reload, link-sharing and browser back/forward restore the
|
||||
* same view (clusterio issue #927).
|
||||
*/
|
||||
export default function useTableQueryState<T>(options: TableQueryStateOptions): TableQueryState<T> {
|
||||
const { defaultSortKey, defaultSortOrder = "ascend" } = options;
|
||||
const prefix = `${options.namespace}.`;
|
||||
const codecs = options.filterCodecs ?? {};
|
||||
const paginationOptions = options.pagination && typeof options.pagination === "object"
|
||||
? options.pagination
|
||||
: undefined;
|
||||
const paginationEnabled = paginationOptions !== undefined;
|
||||
const defaultPageSize = paginationOptions?.defaultPageSize ?? 10;
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
// Live filter overrides let the table filter as the user types without writing the
|
||||
// URL on every keystroke; they are committed to the URL when the filter dropdown
|
||||
// closes (see commitFilter) and cleared whenever the URL changes (commit, back/forward).
|
||||
const [liveFilters, setLiveFilters] = useState<Record<string, string[] | null>>({});
|
||||
// A ref mirrors the overrides so commitFilter always reads the latest values even within
|
||||
// the same event that set them (e.g. clearing then closing), instead of a stale closure.
|
||||
const liveFiltersRef = useRef<Record<string, string[] | null>>(liveFilters);
|
||||
const paramsString = params.toString();
|
||||
useEffect(() => {
|
||||
liveFiltersRef.current = {};
|
||||
setLiveFilters({});
|
||||
}, [paramsString]);
|
||||
|
||||
const sort = parseSort(params, prefix, defaultSortKey, defaultSortOrder);
|
||||
const filters = parseFilters(params, prefix);
|
||||
const page = paginationEnabled ? parsePagination(params, prefix, defaultPageSize) : undefined;
|
||||
|
||||
return {
|
||||
onChange(pagination, tableFilters, sorter) {
|
||||
// Only single-column sort is persisted; antd passes an array when several
|
||||
// columns are sorted, so take the first (primary) sorter.
|
||||
const single = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const order = single && single.order ? single.order : undefined;
|
||||
const columnKey = order ? String(single.columnKey ?? single.field ?? "") : undefined;
|
||||
|
||||
const next = new URLSearchParams(params);
|
||||
applySort(next, prefix, { columnKey, order }, defaultSortKey, defaultSortOrder);
|
||||
const plainFilters: Record<string, FilterValue | null> = {};
|
||||
for (const [key, values] of Object.entries(tableFilters)) {
|
||||
const codec = codecs[key];
|
||||
if (codec) {
|
||||
codec.encode(next, values, prefix);
|
||||
} else {
|
||||
plainFilters[key] = values;
|
||||
}
|
||||
}
|
||||
applyFilters(next, prefix, plainFilters);
|
||||
if (paginationEnabled) {
|
||||
applyPagination(
|
||||
next, prefix, pagination.current ?? 1, pagination.pageSize ?? defaultPageSize, defaultPageSize
|
||||
);
|
||||
}
|
||||
setParams(next);
|
||||
},
|
||||
|
||||
sortOrder(columnKey) {
|
||||
return columnKey === sort.columnKey ? (sort.order ?? null) : null;
|
||||
},
|
||||
|
||||
filteredValue(columnKey) {
|
||||
if (columnKey in liveFilters) {
|
||||
return liveFilters[columnKey];
|
||||
}
|
||||
const codec = codecs[columnKey];
|
||||
return codec ? codec.decode(params, prefix) : (filters[columnKey] ?? null);
|
||||
},
|
||||
|
||||
setFilter(columnKey, values) {
|
||||
const nextLive = { ...liveFiltersRef.current, [columnKey]: values };
|
||||
liveFiltersRef.current = nextLive;
|
||||
setLiveFilters(nextLive);
|
||||
},
|
||||
|
||||
commitFilter(columnKey, values) {
|
||||
// Explicit values (e.g. from the clear button) are written immediately; otherwise
|
||||
// commit the current live override, if any (e.g. when the dropdown closes).
|
||||
let effective = values;
|
||||
if (effective === undefined && columnKey in liveFiltersRef.current) {
|
||||
effective = liveFiltersRef.current[columnKey];
|
||||
}
|
||||
if (effective === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(params);
|
||||
const codec = codecs[columnKey];
|
||||
if (codec) {
|
||||
codec.encode(next, effective, prefix);
|
||||
} else {
|
||||
applyFilters(next, prefix, { [columnKey]: effective });
|
||||
}
|
||||
// Avoid pushing a duplicate history entry when nothing actually changed.
|
||||
if (next.toString() !== paramsString) {
|
||||
setParams(next);
|
||||
}
|
||||
},
|
||||
|
||||
pagination: paginationEnabled && page
|
||||
? { current: page.page, pageSize: page.pageSize }
|
||||
: false,
|
||||
};
|
||||
}
|
||||
@@ -60,18 +60,21 @@ 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));
|
||||
|
||||
/** Cache for the latest factorio release channels to avoid repeat calls to the controller */
|
||||
latestReleases = new lib.ValueCache(this.requestLatestReleases.bind(this));
|
||||
|
||||
declare connector: ControlConnector;
|
||||
|
||||
constructor(
|
||||
@@ -112,6 +115,10 @@ export class Control extends lib.Link {
|
||||
return this.send(new lib.FactorioVersionsRequest());
|
||||
}
|
||||
|
||||
requestLatestReleases() {
|
||||
return this.send(new lib.LatestReleasesRequest());
|
||||
}
|
||||
|
||||
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
|
||||
this.accountRoles = event.roles??null;
|
||||
this.emitAccountUpdate();
|
||||
|
||||
@@ -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.27",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,22 +20,22 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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:",
|
||||
|
||||
@@ -8,6 +8,8 @@ local v2_logistic_api = compat.version_ge("2.0.0")
|
||||
local v2_storage_api = compat.version_ge("2.0.0")
|
||||
local v2_remote_controller = compat.version_ge("2.0.0")
|
||||
local recipe_notifications_api = compat.version_ge("2.0.67")
|
||||
local v2_0_quick_bar_api = compat.version_ge("2.0.0")
|
||||
local v2_1_quick_bar_api = compat.version_ge("2.1.0")
|
||||
|
||||
function serialize.serialize_inventories(source, inventories)
|
||||
local serialized = {}
|
||||
@@ -194,6 +196,159 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
|
||||
end
|
||||
end
|
||||
|
||||
-- name is a custom type where quality is "normal" and comparator is "=" (most common case)
|
||||
--- @alias QuickBarSlotEncoded.name string
|
||||
--- @alias QuickBarSlotEncoded.filter { t: "f", n: string, q: string, c: string }
|
||||
--- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter
|
||||
|
||||
--- @param filter ItemFilter
|
||||
--- @return QuickBarSlotEncoded
|
||||
local function serialize_filter(filter)
|
||||
if filter.quality == "normal" and filter.comparator == "=" then
|
||||
return filter.name
|
||||
end
|
||||
|
||||
return {
|
||||
t = "f",
|
||||
n = filter.name,
|
||||
q = filter.quality,
|
||||
c = filter.comparator,
|
||||
}
|
||||
end
|
||||
|
||||
--- @param slot QuickBarSlot | ItemFilter | LuaItemPrototype
|
||||
--- @return QuickBarSlotEncoded | nil
|
||||
function serialize.serialize_quick_bar_slot(slot)
|
||||
if v2_1_quick_bar_api then
|
||||
-- 2.1 has a dedicated type for quick bar slots
|
||||
--- @cast slot QuickBarSlot
|
||||
if slot.type == "filter" then
|
||||
return serialize_filter(slot.filter)
|
||||
end
|
||||
|
||||
print("Warning: Unsupported quick bar slot type '" .. slot.type .. "'")
|
||||
return nil
|
||||
end
|
||||
|
||||
if v2_0_quick_bar_api then
|
||||
-- 2.0 gives us an item filter which supports quality
|
||||
--- @cast slot ItemFilter
|
||||
return serialize_filter(slot)
|
||||
end
|
||||
|
||||
-- pre 2.0 quality did not exist
|
||||
--- @cast slot LuaItemPrototype
|
||||
return slot.name
|
||||
end
|
||||
|
||||
--- @param entry string | QuickBarSlotEncoded
|
||||
--- @return QuickBarSlot | ItemWithQualityID | nil
|
||||
function serialize.deserialize_quick_bar_slot(entry)
|
||||
if v2_1_quick_bar_api then
|
||||
-- Return a quick bar slot
|
||||
if type(entry) == "string" then
|
||||
return {
|
||||
type = "filter",
|
||||
filter = {
|
||||
name = entry,
|
||||
quality = "normal",
|
||||
comparator = "=",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
if entry.t == "f" then
|
||||
return {
|
||||
type = "filter",
|
||||
filter = {
|
||||
name = entry.n,
|
||||
quality = entry.q,
|
||||
comparator = entry.c,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
print("Warning: Unsupported serialized quick bar slot type '" .. tostring(entry.t) .. "'")
|
||||
return nil
|
||||
end
|
||||
|
||||
if v2_0_quick_bar_api then
|
||||
-- Return a ItemIDAndQualityIDPair (member of ItemWithQualityID)
|
||||
if type(entry) == "string" then
|
||||
return {
|
||||
name = entry,
|
||||
quality = "normal",
|
||||
}
|
||||
end
|
||||
|
||||
-- entry.t == "f" is only remaining case for 2.0
|
||||
return {
|
||||
name = entry.n,
|
||||
quality = entry.q,
|
||||
}
|
||||
end
|
||||
|
||||
-- Return a string (member of ItemPrototypeIdentification)
|
||||
-- Pre 2.0 this was the only method of encoding used
|
||||
return entry
|
||||
end
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @return table<string, QuickBarSlotEncoded>?
|
||||
function serialize.serialize_quick_bar(player)
|
||||
local serialized
|
||||
local width = v2_1_quick_bar_api and player.quick_bar_width
|
||||
|
||||
for i = 1, 100 do
|
||||
local slot
|
||||
|
||||
if v2_1_quick_bar_api then
|
||||
local page = math.floor((i - 1) / width) + 1
|
||||
local page_slot = ((i - 1) % width) + 1
|
||||
slot = player.get_quick_bar_slot(page, page_slot)
|
||||
else
|
||||
--- @diagnostic disable-next-line: missing-parameter
|
||||
slot = player.get_quick_bar_slot(i)
|
||||
end
|
||||
|
||||
if slot ~= nil then
|
||||
local entry = serialize.serialize_quick_bar_slot(slot)
|
||||
if entry ~= nil then
|
||||
serialized = serialized or {}
|
||||
serialized[tostring(i)] = entry
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return serialized
|
||||
end
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @param serialized table<string, string | QuickBarSlotEncoded>?
|
||||
function serialize.deserialize_quick_bar(player, serialized)
|
||||
if not serialized then
|
||||
return
|
||||
end
|
||||
|
||||
local width = v2_1_quick_bar_api and player.quick_bar_width
|
||||
for i = 1, 100 do
|
||||
local entry = serialized[tostring(i)]
|
||||
if entry ~= nil then
|
||||
local slot = serialize.deserialize_quick_bar_slot(entry)
|
||||
if slot ~= nil then
|
||||
if v2_1_quick_bar_api then
|
||||
local page = math.floor((i - 1) / width) + 1
|
||||
local page_slot = ((i - 1) % width) + 1
|
||||
player.set_quick_bar_slot(page, page_slot, slot)
|
||||
else
|
||||
--- @diagnostic disable-next-line: param-type-mismatch
|
||||
player.set_quick_bar_slot(i, slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Crafting queue is a table with the following fields:
|
||||
-- crafting_queue
|
||||
-- ingredients
|
||||
@@ -439,6 +594,7 @@ end
|
||||
--- @field character table<string, any>?
|
||||
--- @field inventories table<string, table>?
|
||||
--- @field hotbar table<string, string>?
|
||||
--- @field quick_bar table<string, QuickBarSlotEncoded>?
|
||||
--- @field personal_logistic_slots table?
|
||||
--- @field crafting_queue table?
|
||||
--- @field recipe_notifications string?
|
||||
@@ -494,16 +650,8 @@ function serialize.serialize_player(player, failed_deserialization)
|
||||
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
|
||||
end
|
||||
|
||||
-- Serialize hotbar
|
||||
for i = 1, 100 do
|
||||
local slot = player.get_quick_bar_slot(i)
|
||||
if slot ~= nil and slot.name ~= nil then
|
||||
if not serialized.hotbar then
|
||||
serialized.hotbar = {}
|
||||
end
|
||||
serialized.hotbar[tostring(i)] = slot.name --[[@as string]]
|
||||
end
|
||||
end
|
||||
-- Serialize quick bar
|
||||
serialized.quick_bar = serialize.serialize_quick_bar(player)
|
||||
|
||||
-- Serialize crafting queue
|
||||
if player.character then
|
||||
@@ -615,14 +763,8 @@ function serialize.deserialize_player(player, serialized)
|
||||
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
|
||||
end
|
||||
|
||||
-- Deserialize hotbar
|
||||
if serialized.hotbar then
|
||||
for i = 1, 100 do
|
||||
if serialized.hotbar[tostring(i)] ~= nil then
|
||||
player.set_quick_bar_slot(i, serialized.hotbar[tostring(i)])
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Deserialize quick bar (named hotbar in old data)
|
||||
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
|
||||
|
||||
-- Deserialize crafting queue
|
||||
if player.character and serialized.crafting_queue then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-inventory_sync",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Danielv <danielv@danielv.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -20,22 +20,22 @@
|
||||
"url": "https://github.com/clusterio/factorioClusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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:",
|
||||
|
||||
@@ -3,11 +3,12 @@ import express, { type Request, type Response } from "express";
|
||||
import util from "util";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
const { basicType } = lib;
|
||||
|
||||
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
|
||||
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages";
|
||||
|
||||
|
||||
async function generateCode(length: number): Promise<string> {
|
||||
@@ -46,12 +47,33 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}, 60e3).unref();
|
||||
|
||||
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
|
||||
let servers: string[] = [];
|
||||
for (let instance of this.controller.instances.values()) {
|
||||
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
|
||||
servers.push(instance.config.get("factorio.settings")["name"] as string || "unnamed server");
|
||||
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
|
||||
|
||||
for (const instance of this.controller.instances.values()) {
|
||||
const pluginLoaded = instance.config.get("player_auth.load_plugin");
|
||||
const assignedHost = instance.config.get("instance.assigned_host");
|
||||
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const host = this.controller.hosts.get(assignedHost);
|
||||
if (!host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address = instance.gamePort !== undefined
|
||||
? `${host.publicAddress}:${instance.gamePort}`
|
||||
: host.publicAddress;
|
||||
|
||||
const settings = instance.config.get("factorio.settings");
|
||||
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
|
||||
servers.push({
|
||||
name: settings["name"] as string || "unnamed server",
|
||||
factorioVersion: instance.factorioVersion,
|
||||
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
res.send(servers);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"player_auth.code_length": number;
|
||||
"player_auth.code_timeout": number;
|
||||
"player_auth.show_connect_address": boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ export const plugin: lib.PluginDeclaration = {
|
||||
type: "number",
|
||||
initialValue: 120,
|
||||
},
|
||||
"player_auth.show_connect_address": {
|
||||
title: "Show Connect Address",
|
||||
description: "Show server connect addresses to unauthenticated users.",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
},
|
||||
|
||||
features: [
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
export class PlayerAuthServer {
|
||||
declare ["constructor"]: typeof PlayerAuthServer;
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
address: Type.Optional(Type.String()),
|
||||
factorioVersion: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public address?: string,
|
||||
public factorioVersion?: string,
|
||||
) {}
|
||||
|
||||
toJSON(): Static<typeof PlayerAuthServer.jsonSchema> {
|
||||
const json = { name: this.name } as Static<typeof PlayerAuthServer.jsonSchema>;
|
||||
if (this.address !== undefined) {
|
||||
json.address = this.address;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof PlayerAuthServer.jsonSchema>) {
|
||||
return new this(json.name, json.address, json.factorioVersion);
|
||||
}
|
||||
}
|
||||
|
||||
class FetchPlayerCodeResponse {
|
||||
constructor(
|
||||
public playerCode: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-player_auth",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"description": "Clusterio plugin authenticating logged in players to the web interface",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,13 +20,13 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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:",
|
||||
|
||||
@@ -7,9 +7,11 @@ const mock = require("../../../test/mock");
|
||||
const controller = require("../dist/node/controller");
|
||||
const instance = require("../dist/node/instance");
|
||||
const info = require("../dist/node/index").plugin;
|
||||
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages");
|
||||
const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages");
|
||||
const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common");
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
|
||||
function postJSON(url, body) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
@@ -19,6 +21,44 @@ function postJSON(url, body) {
|
||||
}
|
||||
|
||||
describe("player_auth", function() {
|
||||
describe("messages.js", function() {
|
||||
describe("PlayerAuthServer", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix(
|
||||
["server1", "server2"], // name
|
||||
[undefined, "127.0.0.1", "10.0.0.1:1234"], // address
|
||||
[undefined, "1.1.0", "2.0.0"], // version
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FetchPlayerCodeResponse", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix(
|
||||
["abc123", "player-code"], // playerCode
|
||||
["http://localhost", "https://example.com"], // controllerUrl
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FetchPlayerCodeRequest", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix(
|
||||
["player1", "player2"], // player
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetVerifyCodeRequest", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix(
|
||||
["player1", "player2"], // player
|
||||
["code123", "verify456"], // verifyCode
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller.js", function() {
|
||||
describe("generateCode()", function() {
|
||||
it("should generate a code of the given length", async function() {
|
||||
@@ -50,6 +90,7 @@ describe("player_auth", function() {
|
||||
controllerPlugin = await mock.createControllerPlugin(controller.ControllerPlugin, info);
|
||||
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_length", 10);
|
||||
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_timeout", 1);
|
||||
controllerPlugin.controller.mockConfigEntries.set("player_auth.show_connect_address", true);
|
||||
controllerUrl = await controllerPlugin.controller.startServer();
|
||||
});
|
||||
after(async function() {
|
||||
@@ -60,33 +101,52 @@ describe("player_auth", function() {
|
||||
|
||||
describe("/api/player_auth/servers", function() {
|
||||
it("should return a list of running servers with player_auth loaded", async function() {
|
||||
function addInstance(id, status, load, name) {
|
||||
function addInstance(id, status, load, name, version, gamePort, host=1) {
|
||||
controllerPlugin.controller.instances.records.set(id, {
|
||||
config: {
|
||||
get(field) {
|
||||
if (field === "player_auth.load_plugin") {
|
||||
return load;
|
||||
} else if (field === "factorio.settings") {
|
||||
}
|
||||
if (field === "factorio.settings") {
|
||||
return { name };
|
||||
} else if (field === "instance.id") {
|
||||
}
|
||||
if (field === "instance.id") {
|
||||
return id;
|
||||
}
|
||||
if (field === "instance.assigned_host") {
|
||||
return host;
|
||||
}
|
||||
throw new Error(`field ${field} not implemented`);
|
||||
},
|
||||
},
|
||||
status,
|
||||
gamePort,
|
||||
factorioVersion: version,
|
||||
});
|
||||
}
|
||||
addInstance(1, "running", true, "running loaded");
|
||||
addInstance(2, "stopped", true, "stopped loaded");
|
||||
addInstance(3, "running", false, "running unloaded");
|
||||
addInstance(4, "stopped", false, "stopped unloaded");
|
||||
addInstance(5, "running", true, undefined);
|
||||
|
||||
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
|
||||
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
|
||||
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
|
||||
addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197);
|
||||
addInstance(5, "running", true, undefined, "1.1.5", 34197);
|
||||
addInstance(6, "running", true, "no port", "1.1.6", undefined);
|
||||
addInstance(7, "running", true, "no host", "1.1.7", 34197, null);
|
||||
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
|
||||
addInstance(9, "running", true, "no version", undefined, 34197);
|
||||
|
||||
controllerPlugin.controller.hosts.set(1, {
|
||||
publicAddress: "127.0.0.1",
|
||||
});
|
||||
|
||||
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
|
||||
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
|
||||
for (let id of [1, 2, 3, 4, 5]) {
|
||||
controllerPlugin.controller.instances.records.delete(id);
|
||||
}
|
||||
assert.deepEqual(await result.json(), [
|
||||
{ name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" },
|
||||
{ name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" },
|
||||
{ name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" },
|
||||
{ name: "no version", address: "127.0.0.1:34197" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui";
|
||||
import { Button, Form, Input, Spin, Typography } from "antd";
|
||||
import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd";
|
||||
|
||||
import { PlayerAuthServer } from "../messages";
|
||||
import "./style.css";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -12,60 +13,95 @@ interface LoginFormProps {
|
||||
}
|
||||
|
||||
function LoginForm(props: LoginFormProps) {
|
||||
let [servers, setServers] = useState<string[] | null>(null);
|
||||
const [servers, setServers] = useState<PlayerAuthServer[] | null>(null);
|
||||
let [playerCode, setPlayerCode] = useState<string | null>(null);
|
||||
let [playerCodeError, setPlayerCodeError] = useState<string | null>(null);
|
||||
let [verifyCode, setVerifyCode] = useState<string | undefined>();
|
||||
let [verifyToken, setVerifyToken] = useState<string | null>(null);
|
||||
const [connectServer, setConnectServer] = useState<{ name: string, address: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/player_auth/servers`);
|
||||
if (response.ok) {
|
||||
setServers(await response.json());
|
||||
} else {
|
||||
setServers([]);
|
||||
let cancelled = false;
|
||||
async function loadServers() {
|
||||
try {
|
||||
const response = await fetch(`${webRoot}api/player_auth/servers`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!cancelled) {
|
||||
setServers(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && err instanceof Error) {
|
||||
setServers([]);
|
||||
notifyErrorHandler("Error fetching servers")(err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
loadServers();
|
||||
const interval = setInterval(loadServers, 15 * 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyToken) {
|
||||
let checkLoop = setInterval(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/player_auth/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
verify_token: verifyToken,
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
let json = await response.json();
|
||||
if (json.error) {
|
||||
// While there are other possibilites the most likely
|
||||
// cause of an error response here is that the code expired.
|
||||
setPlayerCodeError("Code expired");
|
||||
setVerifyToken(null);
|
||||
clearInterval(checkLoop);
|
||||
return;
|
||||
}
|
||||
if (json.verified) {
|
||||
props.setToken(json.token);
|
||||
}
|
||||
})().catch(err => {
|
||||
clearInterval(checkLoop);
|
||||
notifyErrorHandler("Error verifying code")(err);
|
||||
});
|
||||
}, 2000);
|
||||
return () => { clearInterval(checkLoop); };
|
||||
if (!verifyToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
let cancelled = false;
|
||||
async function verify() {
|
||||
try {
|
||||
const response = await fetch(`${webRoot}api/player_auth/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
verify_token: verifyToken,
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
setPlayerCodeError("Code expired");
|
||||
setVerifyToken(null);
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.verified) {
|
||||
props.setToken(json.token);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && err instanceof Error) {
|
||||
clearInterval(interval);
|
||||
notifyErrorHandler("Error verifying code")(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verify();
|
||||
const interval = setInterval(verify, 2 * 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [verifyToken]);
|
||||
|
||||
if (!servers) {
|
||||
@@ -74,12 +110,47 @@ function LoginForm(props: LoginFormProps) {
|
||||
|
||||
if (servers.length === 0) {
|
||||
return <Paragraph>
|
||||
There are no servers in the cluster currently running that can complete
|
||||
the login via Factorio. Please try again later.
|
||||
There are no running servers available for login via Factorio at the moment.
|
||||
Please try again later.
|
||||
</Paragraph>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Modal
|
||||
open={connectServer !== null}
|
||||
title={`Connect to ${connectServer?.name ?? ""}`}
|
||||
okText="Connect"
|
||||
cancelText="Back"
|
||||
onCancel={() => setConnectServer(null)}
|
||||
onOk={() => {
|
||||
if (!connectServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `steam://run/427520//--mp-connect=${connectServer.address}`;
|
||||
setConnectServer(null);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Paragraph>
|
||||
This button launches Factorio through Steam. It only works if
|
||||
you own and have installed the Steam version of Factorio, and
|
||||
Factorio is not already running.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
If you are using the standalone version, or Factorio is already
|
||||
running, use the following address with <b>Multiplayer → Connect to address</b>:
|
||||
</Paragraph>
|
||||
|
||||
<Typography.Paragraph
|
||||
copyable={{ text: connectServer?.address ?? "" }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{connectServer?.address}
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Paragraph>Login using your Factorio account is a 3 step process:</Paragraph>
|
||||
<style>
|
||||
</style>
|
||||
@@ -89,9 +160,41 @@ 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, padding: "8px 0" }}>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{servers.map(server => (
|
||||
<li key={server.name} style={{ marginBottom: 6 }}>
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}>
|
||||
<span>
|
||||
{server.name}
|
||||
{server.factorioVersion && (
|
||||
<Text type="secondary"> ({server.factorioVersion})</Text>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{server.address && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setConnectServer({
|
||||
name: server.name,
|
||||
address: server.address!,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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.27",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,22 +20,22 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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.27",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,17 +20,17 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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.27",
|
||||
"description": "Clusterio plugin for sharing storage between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -20,22 +20,22 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*"
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@types/express": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Input, Table, Typography } from "antd";
|
||||
import { Table } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
|
||||
useTableQueryState, useColumnSearch,
|
||||
} from "@clusterio/web_ui";
|
||||
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
|
||||
function useStorage(control: Control) {
|
||||
let plugin = control.plugins.get("subspace_storage") as WebPlugin;
|
||||
@@ -37,8 +36,12 @@ function StoragePage() {
|
||||
let itemMetadata = prototypes?.get("item");
|
||||
let fluidMetadata = prototypes?.get("fluid");
|
||||
let storage = useStorage(control);
|
||||
type ItemFilter = ([name, item]: [string, Item]) => boolean;
|
||||
let [filter, setFilter] = useState<null | ItemFilter>(null);
|
||||
const tableState = useTableQueryState<[string, Item]>({
|
||||
namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false,
|
||||
});
|
||||
const resourceSearch = useColumnSearch<[string, Item]>(
|
||||
tableState, "resource", item => getLocaleName(item[1].name), "Search"
|
||||
);
|
||||
|
||||
function getLocaleName(itemName: string) {
|
||||
let localeName = itemName;
|
||||
@@ -65,33 +68,34 @@ function StoragePage() {
|
||||
|
||||
let numberFormat = new Intl.NumberFormat("en-US");
|
||||
|
||||
// Build the regex the resource search uses: word-boundary anchored, spaces match any gap.
|
||||
function matchesResource(value: string | number | bigint | boolean, item: [string, Item]) {
|
||||
let search = String(value).trim();
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
search = search.replace(/(^| )(\w)/g, "$1\\b$2").replace(/ +/g, ".*");
|
||||
let filterExpr;
|
||||
try {
|
||||
filterExpr = new RegExp(search, "i");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return filterExpr.test(getLocaleName(item[1].name)) || filterExpr.test(item[1].name);
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Storage" }]}>
|
||||
<PageHeader title="Storage" />
|
||||
<Paragraph>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
onChange={(event) => {
|
||||
let search = event.target.value.trim();
|
||||
if (!search) {
|
||||
setFilter(null);
|
||||
return;
|
||||
}
|
||||
search = search.replace(/(^| )(\w)/g, "$1\\b$2");
|
||||
search = search.replace(/ +/g, ".*");
|
||||
let filterExpr = new RegExp(search, "i");
|
||||
setFilter(() => ((item: [string, Item]) => {
|
||||
let name = getLocaleName(item[1].name);
|
||||
return filterExpr.test(name) || filterExpr.test(item[1].name);
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Paragraph>
|
||||
<Table
|
||||
className="subspace-storage-storage"
|
||||
columns={[
|
||||
{
|
||||
title: "Resource",
|
||||
key: "resource",
|
||||
...resourceSearch,
|
||||
onFilter: matchesResource,
|
||||
filteredValue: tableState.filteredValue("resource"),
|
||||
sortOrder: tableState.sortOrder("resource"),
|
||||
sorter: (a, b) => {
|
||||
let aName = getLocaleName(a[1].name);
|
||||
let bName = getLocaleName(b[1].name);
|
||||
@@ -119,14 +123,15 @@ function StoragePage() {
|
||||
title: "Quantity",
|
||||
key: "quantity",
|
||||
align: "right",
|
||||
defaultSortOrder: "descend",
|
||||
sorter: (a, b) => a[1].count - b[1].count,
|
||||
sortOrder: tableState.sortOrder("quantity"),
|
||||
render: (_, item) => numberFormat.format(item[1].count),
|
||||
},
|
||||
]}
|
||||
dataSource={filter ? storage.filter(filter) : storage}
|
||||
dataSource={storage}
|
||||
rowKey={item => item[0]}
|
||||
pagination={false}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+16
-2
@@ -62,7 +62,11 @@ describe("host/server", function() {
|
||||
let installDir = path.join("test", "file", "factorio", "0.1.1");
|
||||
await assert.rejects(
|
||||
hostServer._findVersion(installDir, "0.1.2"),
|
||||
new Error("Unable to find Factorio version 0.1.2")
|
||||
new Error(
|
||||
`Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` +
|
||||
`install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -229,6 +233,16 @@ describe("host/server", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".setTargetVersion()", function() {
|
||||
it("should override the target version before init", function() {
|
||||
const fresh = new hostServer.FactorioServer(
|
||||
path.join("test", "file", "factorio"), writePath, {}
|
||||
);
|
||||
fresh.setTargetVersion("0.1.5");
|
||||
assert.equal(fresh._targetVersion, "0.1.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".init()", function() {
|
||||
it("should not throw on first call", async function() {
|
||||
await server.init();
|
||||
@@ -433,7 +447,7 @@ describe("host/server", function() {
|
||||
|
||||
assert.equal(fetchCalledWith, null);
|
||||
assert.ok(logLine !== null);
|
||||
assert.ok(logLine.endsWith("but must be manually downloaded"));
|
||||
assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux)."));
|
||||
});
|
||||
it("should do attempt to download on linux", async function() {
|
||||
let logLine = null;
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user