mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
82
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 | ||
|
|
262357acef | ||
|
|
0482f4ae08 | ||
|
|
16ce748ffa | ||
|
|
2c199028ea | ||
|
|
22b16a7ac2 | ||
|
|
f2ccde0dd6 |
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,44 @@ 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
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
@@ -17,7 +17,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"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:",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -17,7 +17,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
|
||||
@@ -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.26",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -18,7 +18,7 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
|
||||
@@ -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.26",
|
||||
"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 },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
|
||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data";
|
||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data";
|
||||
import isDeepStrictEqual from "./is_deep_strict_equal";
|
||||
import { RequestError } from "./errors";
|
||||
import { logger } from "./logging";
|
||||
@@ -14,25 +14,27 @@ export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => v
|
||||
* When isEmpty() is true, no strings match this filter.
|
||||
* Otherwise, literal string matching is performed against a set.
|
||||
*/
|
||||
export class SubscriptionFilters {
|
||||
export class SubscriptionFilters extends Set<string> {
|
||||
private constructor(
|
||||
private _all: boolean,
|
||||
private _filters: Set<string>,
|
||||
) {}
|
||||
_filters: Iterable<string> = [],
|
||||
) {
|
||||
super(_filters);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Array(Type.String());
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(false, new Set(json));
|
||||
return new this(false, json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return [...this._filters];
|
||||
return [...this];
|
||||
}
|
||||
|
||||
/** Creates a subscription filter accepting all strings */
|
||||
static all() {
|
||||
return new this(true, new Set());
|
||||
return new this(true);
|
||||
}
|
||||
|
||||
isAll() {
|
||||
@@ -41,11 +43,11 @@ export class SubscriptionFilters {
|
||||
|
||||
/** Creates a subscription filter rejecting all strings */
|
||||
static empty() {
|
||||
return new this(false, new Set());
|
||||
return new this(false);
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !this._all && this._filters.size === 0;
|
||||
return !this._all && this.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,20 +69,20 @@ export class SubscriptionFilters {
|
||||
if (this._all) {
|
||||
return "[SubscriptionFilters All]";
|
||||
}
|
||||
if (this._filters.size === 0) {
|
||||
if (this.size === 0) {
|
||||
return "[SubscriptionFilters Empty]";
|
||||
}
|
||||
return `[SubscriptionFilters Set<${this._filters.size}>]`;
|
||||
return `[SubscriptionFilters Set<${this.size}>]`;
|
||||
}
|
||||
|
||||
/** Returns true if this filter accepts the given string */
|
||||
accepts(value: string) {
|
||||
return this._all || this._filters.has(value);
|
||||
return this._all || this.has(value);
|
||||
}
|
||||
|
||||
/** Returns true if this filter extends the other */
|
||||
extends(other: this) {
|
||||
return this._all || (!other._all && [...other._filters.values()].every(value => this._filters.has(value)));
|
||||
return this._all || (!other._all && [...other.values()].every(value => this.has(value)));
|
||||
}
|
||||
|
||||
/** Returns true if there is overlap between two filters */
|
||||
@@ -91,8 +93,8 @@ export class SubscriptionFilters {
|
||||
if (other._all) {
|
||||
return !this.isEmpty();
|
||||
}
|
||||
for (const filter of other._filters) {
|
||||
if (this._filters.has(filter)) {
|
||||
for (const filter of other) {
|
||||
if (this.has(filter)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -106,11 +108,11 @@ export class SubscriptionFilters {
|
||||
}
|
||||
if (other._all) {
|
||||
this._all = true;
|
||||
this._filters.clear();
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
for (const filter of other._filters) {
|
||||
this._filters.add(filter);
|
||||
for (const filter of other) {
|
||||
this.add(filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,14 +124,14 @@ export class SubscriptionFilters {
|
||||
subtract(other: this) {
|
||||
if (other._all) {
|
||||
this._all = false;
|
||||
this._filters.clear();
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
if (this._all) {
|
||||
return; // Cannot subtract from all
|
||||
}
|
||||
for (const filter of other._filters) {
|
||||
this._filters.delete(filter);
|
||||
for (const filter of other) {
|
||||
this.delete(filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,8 +408,10 @@ export class EventSubscriber<E, S = null> {
|
||||
lastUpdatedMs = -1;
|
||||
/** True if this subscriber is currently synced with the source */
|
||||
synced = 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 */
|
||||
private _snapshot: readonly [S, boolean] = [this.makeSnapshot(), false];
|
||||
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 */
|
||||
private _callbacks = new Array<EventSubscriberCallback<E>>();
|
||||
@@ -555,10 +559,14 @@ export class EventSubscriber<E, S = null> {
|
||||
* Obtain a snapshot of the current state of the tracked resource
|
||||
* @returns tuple of values map snapshot and synced property.
|
||||
*/
|
||||
getSnapshot(): readonly [S, boolean] {
|
||||
if (this._snapshotLastUpdatedMs !== this.lastUpdatedMs || this._snapshot[1] !== 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._snapshot = [this.makeSnapshot(), this.synced, this.error];
|
||||
}
|
||||
return this._snapshot;
|
||||
}
|
||||
@@ -581,6 +589,7 @@ export class EventSubscriber<E, S = null> {
|
||||
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
|
||||
entry.name, action, this.lastUpdatedMs, filters
|
||||
));
|
||||
this.error = null;
|
||||
if (!updatesSent) {
|
||||
this.synced = this._hasSubscriptions();
|
||||
this._notify(null);
|
||||
@@ -589,6 +598,9 @@ export class EventSubscriber<E, S = null> {
|
||||
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
|
||||
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
this.error = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,25 +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, DownloadOutlined } from "@ant-design/icons";
|
||||
import { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd";
|
||||
import { UserOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons";
|
||||
|
||||
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 { 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";
|
||||
|
||||
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) {
|
||||
@@ -42,6 +52,13 @@ 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);
|
||||
@@ -74,7 +91,13 @@ export default function SiteLayout() {
|
||||
}
|
||||
},
|
||||
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 />} />
|
||||
@@ -110,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) {
|
||||
@@ -126,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)}
|
||||
@@ -169,56 +224,56 @@ export default function SiteLayout() {
|
||||
setAboutOpen(wasAboutOpen);
|
||||
}}
|
||||
/>
|
||||
<DraggingContext.Provider value={dragging > 0}>
|
||||
<Header className="header">
|
||||
<div className="site-logo" />
|
||||
<span className="site-name">Clusterio</span>
|
||||
<Tooltip
|
||||
title="View changelog"
|
||||
placement="bottom"
|
||||
align={{ offset: [0, -10] }}
|
||||
>
|
||||
<span
|
||||
className="site-version"
|
||||
style={{ cursor: "pointer" }}
|
||||
<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}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</Flex>
|
||||
{accountMenu}
|
||||
</Header>
|
||||
<DraggingContext.Provider value={dragging > 0}>
|
||||
<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>;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useRef } from "react";
|
||||
import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd";
|
||||
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 {
|
||||
@@ -24,6 +26,43 @@ function decodeFilter(value: string): UserFilter {
|
||||
}
|
||||
}
|
||||
|
||||
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"; }
|
||||
@@ -41,7 +80,7 @@ function matchesTriState(value: boolean, filter?: boolean) {
|
||||
}
|
||||
|
||||
export function onFilterUser(
|
||||
value: string | number | boolean,
|
||||
value: boolean | React.Key,
|
||||
record: UserDetails
|
||||
) {
|
||||
const filter = decodeFilter(String(value));
|
||||
@@ -80,32 +119,29 @@ export function Username({
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserFilter(withStatus?: boolean) {
|
||||
export function useUserFilter(
|
||||
tableState: TableQueryState<UserDetails>,
|
||||
columnKey: string,
|
||||
withStatus?: boolean,
|
||||
) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
function filterDropdown({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: {
|
||||
selectedKeys: string[];
|
||||
setSelectedKeys: (keys: string[]) => void;
|
||||
confirm: FilterDropdownProps["confirm"];
|
||||
clearFilters: FilterDropdownProps["clearFilters"];
|
||||
}) {
|
||||
close,
|
||||
}: FilterDropdownProps) {
|
||||
const filter: UserFilter = selectedKeys[0]
|
||||
? decodeFilter(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 });
|
||||
if (encoded === "{}") {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
} else {
|
||||
setSelectedKeys([encoded]);
|
||||
}
|
||||
confirm({ closeDropdown: false });
|
||||
const values = encoded === "{}" ? null : [encoded];
|
||||
setSelectedKeys(values ?? []);
|
||||
tableState.setFilter(columnKey, values);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,12 +152,11 @@ export function useUserFilter(withStatus?: boolean) {
|
||||
placeholder="Search username"
|
||||
value={filter.search}
|
||||
allowClear
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
onPressEnter={e => {
|
||||
close();
|
||||
tableState.commitFilter(columnKey);
|
||||
}}
|
||||
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
|
||||
/>
|
||||
|
||||
{withStatus &&<>
|
||||
@@ -176,6 +211,8 @@ export function useUserFilter(withStatus?: boolean) {
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
} else {
|
||||
tableState.commitFilter(columnKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,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">
|
||||
@@ -345,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">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Table, Tag } from "antd";
|
||||
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";
|
||||
|
||||
@@ -13,8 +12,10 @@ import {
|
||||
useUsers, getUserStats, isUserOnline,
|
||||
} from "../model/user";
|
||||
|
||||
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
|
||||
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, stats will be filtered to that instance only */
|
||||
@@ -22,19 +23,28 @@ export interface UsersTableProps {
|
||||
/** 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) {
|
||||
export default function UsersTable(
|
||||
{ instanceId, onlyOnline = false, pagination, size }: UsersTableProps
|
||||
) {
|
||||
const [roles, rolesSynced] = useRoles();
|
||||
const [users, usersSynced] = useUsers();
|
||||
const navigate = useNavigate();
|
||||
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 {filterDropdown, filterDropdownProps} = useUserFilter(true);
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", true);
|
||||
|
||||
const data = [...users.values()];
|
||||
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
|
||||
@@ -84,11 +94,15 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
className: "table-link-cell",
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
<Username user={user} withStatus />
|
||||
<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 }} />
|
||||
),
|
||||
@@ -101,8 +115,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
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) => (
|
||||
@@ -124,6 +139,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
const statsB = getUserStats(b, instanceId);
|
||||
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
|
||||
},
|
||||
sortOrder: tableState.sortOrder("onlineTime"),
|
||||
},
|
||||
{
|
||||
title: "Join Count",
|
||||
@@ -136,56 +152,58 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
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,
|
||||
filters: durationFilters,
|
||||
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
|
||||
// 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 breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847
|
||||
// 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" }}
|
||||
loading={!usersSynced || !rolesSynced}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
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})`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,9 @@ export class Control extends lib.Link {
|
||||
/** 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
|
||||
@@ -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.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/factorioClusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
|
||||
@@ -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.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
|
||||
@@ -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,39 @@ function LoginForm(props: LoginFormProps) {
|
||||
Start Factorio, join one of the following multiplayer servers
|
||||
and type <Text keyboard>/web-login</Text> into the chat:
|
||||
</Paragraph>
|
||||
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16 }}>
|
||||
<ul>
|
||||
{servers.map(text => <li key={text}>{text}</li>)}
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-subspace_storage",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"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,7 +20,7 @@
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
@@ -704,6 +704,28 @@ describe("Integration of Clusterio", function() {
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
}
|
||||
});
|
||||
it("should resolve a release channel target when starting", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
// "experimental" is resolved to a concrete version through the
|
||||
// controller's latest-releases data before the server starts.
|
||||
// Whether that version is installed (or downloadable) depends on
|
||||
// the host's Factorio install - CI uses a fixed direct install -
|
||||
// so the start may succeed or fail, but the channel must resolve
|
||||
// and the instance must reach a terminal state rather than hang.
|
||||
await execCtl("instance config set 44 factorio.version experimental");
|
||||
await execCtl(`instance ${cmd} test`).catch(() => {});
|
||||
const status = (await getInstances()).get(44).status;
|
||||
assert(
|
||||
["running", "stopped"].includes(status),
|
||||
`instance stuck in unexpected state '${status}'`
|
||||
);
|
||||
|
||||
} finally {
|
||||
await execCtl("instance stop 44").catch(() => {});
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
}
|
||||
});
|
||||
it("should not leave the instance in the stopping state if it fails", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
@@ -1128,8 +1150,15 @@ describe("Integration of Clusterio", function() {
|
||||
await execCtl("instance start 44");
|
||||
const instance = (await getInstances()).get(44);
|
||||
const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0");
|
||||
const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, "");
|
||||
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
|
||||
const isV2_1 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.1");
|
||||
let exchangeString = testStrings.modified;
|
||||
if (isV2_1) {
|
||||
exchangeString = testStrings.modified_v2_1;
|
||||
} else if (isV2) {
|
||||
exchangeString = testStrings.modified_v2;
|
||||
}
|
||||
// eslint-disable-next-line max-len
|
||||
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString.replace(/[\n\r]+/g, "")}"`;
|
||||
await execCtl("instance stop 44");
|
||||
|
||||
await execCtl(`instance load-scenario test ${args}`);
|
||||
|
||||
@@ -363,7 +363,11 @@ before(async function() {
|
||||
|
||||
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
|
||||
|
||||
haveFactorioInstall = (await _listFactorioVersions(factorioDir)).versions.size > 0;
|
||||
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
|
||||
const latestFactorioVersion = [...factorioVersions.values()]
|
||||
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
|
||||
haveFactorioInstall = factorioVersions.size > 0;
|
||||
|
||||
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
await execCtlProcess("host create-config --id 4 --name host --generate-token");
|
||||
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
|
||||
@@ -382,8 +386,11 @@ before(async function() {
|
||||
const testPack = lib.ModPack.fromJSON({});
|
||||
testPack.id = 12;
|
||||
testPack.name = "subspace_storage-pack";
|
||||
testPack.factorioVersion = "2.0.0";
|
||||
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "2.0.20" });
|
||||
testPack.factorioVersion = latestFactorioVersion;
|
||||
|
||||
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
|
||||
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.0.${major}${minor}` });
|
||||
|
||||
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
|
||||
await control.sendTo(
|
||||
"controller",
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("lib/ModStore", function () {
|
||||
|
||||
await assert.rejects(
|
||||
modStore.downloadMods(modsToDownload, username, token, factorioVersion),
|
||||
/Fetch: https:\/\/mods\.factorio\.com\/api\/mods.* returned 503 Service Unavailable/
|
||||
/Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/
|
||||
);
|
||||
assert.equal(modStore.files.size, 0, "No mods should be added on API error");
|
||||
});
|
||||
|
||||
@@ -176,6 +176,28 @@ describe("lib/config/classes", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("get filepath", function() {
|
||||
it("returns the current filepath for the config", function() {
|
||||
const testInstance = new TestConfig("local", undefined, "foo/bar");
|
||||
assert.equal(testInstance.filepath, "foo/bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set filepath", function() {
|
||||
it("sets the current filepath for the config", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
assert.equal(testInstance.filepath, undefined);
|
||||
testInstance.filepath = "foo/bar";
|
||||
assert.equal(testInstance.filepath, "foo/bar");
|
||||
});
|
||||
it("sets the dirty flag", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
assert.equal(testInstance.dirty, false);
|
||||
testInstance.filepath = "foo/bar";
|
||||
assert.equal(testInstance.dirty, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function() {
|
||||
it("should serialize a basic config", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("lib/config/definitions/validators", function() {
|
||||
// invalid case
|
||||
assert.throws(
|
||||
() => config.set("factorio.version", "invalidValue"),
|
||||
/Value must be be 'latest', or match X.Y, or match X.Y.Z/
|
||||
/Value must be 'latest', a release channel, or match X.Y or X.Y.Z/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -217,23 +217,29 @@ describe("lib/data/ModInfo", function() {
|
||||
const dependency = new ModDependency("! my-mod");
|
||||
assert.equal(dependency.isSatisfied(mods), false);
|
||||
});
|
||||
it("should pass for optional / hidden being missing", function() {
|
||||
it("should pass for optional / hidden / recommended being missing", function() {
|
||||
const dependencyOptional = new ModDependency("? not-present");
|
||||
assert.equal(dependencyOptional.isSatisfied(mods), true);
|
||||
const dependencyHidden = new ModDependency("(?) not-present");
|
||||
assert.equal(dependencyHidden.isSatisfied(mods), true);
|
||||
const dependencyRecommended = new ModDependency("+ not-present");
|
||||
assert.equal(dependencyRecommended.isSatisfied(mods), true);
|
||||
});
|
||||
it("should pass for optional / hidden being present", function() {
|
||||
it("should pass for optional / hidden / recommended being present", function() {
|
||||
const dependencyOptional = new ModDependency("? my-mod");
|
||||
assert.equal(dependencyOptional.isSatisfied(mods), true);
|
||||
const dependencyHidden = new ModDependency("(?) my-mod");
|
||||
assert.equal(dependencyHidden.isSatisfied(mods), true);
|
||||
const dependencyRecommended = new ModDependency("+ my-mod");
|
||||
assert.equal(dependencyRecommended.isSatisfied(mods), true);
|
||||
});
|
||||
it("should fail for optional / hidden being present but wrong version", function() {
|
||||
it("should fail for optional / hidden / recommended being present but wrong version", function() {
|
||||
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
|
||||
assert.equal(dependencyOptional.isSatisfied(mods), false);
|
||||
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
|
||||
assert.equal(dependencyHidden.isSatisfied(mods), false);
|
||||
const dependencyRecommended = new ModDependency("+ my-mod > 2.0.0");
|
||||
assert.equal(dependencyRecommended.isSatisfied(mods), false);
|
||||
});
|
||||
it("should pass for unordered / required being present", function() {
|
||||
const dependencyOptional = new ModDependency("~ my-mod");
|
||||
@@ -258,19 +264,29 @@ describe("lib/data/ModInfo", function() {
|
||||
it("should return true iff the type is incompatible", function() {
|
||||
const dependencyIncompatible = new ModDependency("! my-mod");
|
||||
assert.equal(dependencyIncompatible.incompatible, true);
|
||||
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) {
|
||||
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "+ my-mod"]) {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.incompatible, false, test);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("recommended", function() {
|
||||
it("should return true iff the type is recommended", function() {
|
||||
const dependencyIncompatible = new ModDependency("+ my-mod");
|
||||
assert.equal(dependencyIncompatible.recommended, true);
|
||||
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "! my-mod"]) {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.recommended, false, test);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("required", function() {
|
||||
it("should return true iff the dependency is required", function() {
|
||||
for (const test of ["my-mod", "~ my-mod"]) {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.required, true, test);
|
||||
}
|
||||
for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) {
|
||||
for (const test of ["! my-mod", "? my-mod", "(?) my-mod", "+ my-mod"]) {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.required, false, test);
|
||||
}
|
||||
@@ -282,7 +298,7 @@ describe("lib/data/ModInfo", function() {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.optional, true, test);
|
||||
}
|
||||
for (const test of ["! my-mod", "my-mod", "~ my-mod"]) {
|
||||
for (const test of ["! my-mod", "my-mod", "~ my-mod", "+ my-mod"]) {
|
||||
const dependency = new ModDependency(test);
|
||||
assert.equal(dependency.optional, false, test);
|
||||
}
|
||||
@@ -341,6 +357,39 @@ describe("lib/data/ModInfo", function() {
|
||||
dependencies: ["base", ""],
|
||||
}), /my-mod/); // check the mod name is included in the error message
|
||||
});
|
||||
it("should keep the raw mod version but normalise it for comparison", function() {
|
||||
const mod = ModInfo.fromJSON({
|
||||
name: "my-mod",
|
||||
version: " 1.2.3-extra",
|
||||
factorio_version: "1.1.0",
|
||||
});
|
||||
// The version is kept verbatim so it still matches the mod's file name,
|
||||
// but is read leniently the way the game does when compared.
|
||||
assert.equal(mod.version, " 1.2.3-extra");
|
||||
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion);
|
||||
// factorio_version is not part of any file name, so it is reduced to major.minor.
|
||||
assert.equal(mod.factorioVersion, "1.1");
|
||||
});
|
||||
it("should keep leading zeros in the version so the file name matches", function() {
|
||||
// Mirrors the mods.factorio.com/mod/leading-zeros case: the version has
|
||||
// a zero-padded component that the file name keeps verbatim.
|
||||
const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" });
|
||||
assert.equal(mod.version, "1.01.0");
|
||||
assert.equal(mod.filename, "leading-zeros_1.01.0.zip");
|
||||
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion);
|
||||
});
|
||||
it("should throw for an unparseable mod version", function() {
|
||||
assert.throws(
|
||||
() => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }),
|
||||
/Invalid mod version/
|
||||
);
|
||||
});
|
||||
it("should throw for an unparseable factorio_version", function() {
|
||||
assert.throws(
|
||||
() => ModInfo.fromJSON({ name: "my-mod", factorio_version: "not-a-version" }),
|
||||
/Invalid factorio_version/
|
||||
);
|
||||
});
|
||||
it("should sort integer mod versions lexicographically", function() {
|
||||
let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
|
||||
let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"];
|
||||
|
||||
@@ -17,36 +17,53 @@ describe("lib/data/version", function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("isApiVersion()", function() {
|
||||
it("should correctly validate input strings", function() {
|
||||
const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"];
|
||||
for (const test of valid) {
|
||||
assert.equal(lib.isApiVersion(test), true, test);
|
||||
}
|
||||
const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"];
|
||||
describe("normaliseSourceVersion()", function() {
|
||||
it("should normalise lenient version strings the way the game reads them", function() {
|
||||
// Major and minor required, patch optional and defaulting to 0.
|
||||
assert.equal(lib.normaliseSourceVersion("1.1"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50"), "1.1.50");
|
||||
assert.equal(lib.normaliseSourceVersion("0.18"), "0.18.0");
|
||||
// Leading whitespace and zeros are dropped.
|
||||
assert.equal(lib.normaliseSourceVersion(" 1.1"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("01.02.03"), "1.2.3");
|
||||
assert.equal(lib.normaliseSourceVersion("1. 1. 50"), "1.1.50");
|
||||
// Anything after the patch (or a non-numeric patch) is ignored.
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50-beta"), "1.1.50");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.x"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50.60"), "1.1.50");
|
||||
});
|
||||
it("should return undefined when no major.minor can be read", function() {
|
||||
const invalid = ["1", "", "latest", "abc", ".1.1", "x.y"];
|
||||
for (const test of invalid) {
|
||||
assert.equal(lib.isApiVersion(test), false, test);
|
||||
assert.equal(lib.normaliseSourceVersion(test), undefined, test);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("normaliseApiVersion()", function() {
|
||||
it("should normalise accepted versions", function() {
|
||||
assert.equal(lib.normaliseApiVersion("0.13"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("1.1"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("2.0"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0");
|
||||
describe("normaliseMajorMinorVersion()", function() {
|
||||
it("should return the major.minor", function() {
|
||||
assert.equal(lib.normaliseMajorMinorVersion("1.1"), "1.1");
|
||||
assert.equal(lib.normaliseMajorMinorVersion("1.1.50"), "1.1");
|
||||
assert.equal(lib.normaliseMajorMinorVersion("0.18.0"), "0.18");
|
||||
});
|
||||
it("should throw for invalid versions", function() {
|
||||
const invalid = ["0.12", "0", "", "latest"];
|
||||
for (const test of invalid) {
|
||||
assert.throws(() => lib.normaliseApiVersion(test), undefined, test);
|
||||
});
|
||||
describe("isMajorMinorVersion()", function() {
|
||||
it("should correctly validate input strings", function() {
|
||||
const valid = ["1.1", "0.18", "2.0", "10.20"];
|
||||
const invalid = ["1", "1.1.0", "1.1.50", "latest", "1.", ".1", ""];
|
||||
for (const version of valid) {
|
||||
assert(lib.isMajorMinorVersion(version), `${version} should be valid`);
|
||||
}
|
||||
for (const version of invalid) {
|
||||
assert(!lib.isMajorMinorVersion(version), `${version} should be invalid`);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("integerMajorMinorVersion()", function() {
|
||||
it("should sort versions lexicographically", function() {
|
||||
let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"];
|
||||
let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"];
|
||||
unsortedVersions.sort((a, b) => lib.integerMajorMinorVersion(a) - lib.integerMajorMinorVersion(b));
|
||||
assert.deepEqual(unsortedVersions, sortedVersions);
|
||||
});
|
||||
});
|
||||
describe("isFullVersion()", function() {
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
const assert = require("assert").strict;
|
||||
|
||||
const { fetchLatestReleases, resolveReleaseChannel } = require("@clusterio/lib");
|
||||
const { slowTest } = require("../../integration");
|
||||
|
||||
describe("LatestReleases", function() {
|
||||
describe("fetchLatestReleases", function() {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const sample = {
|
||||
experimental: { alpha: "2.1.8", demo: "2.0.77", expansion: "2.1.8", headless: "2.1.8" },
|
||||
stable: { alpha: "2.0.77", demo: "2.0.77", expansion: "2.0.77", headless: "2.0.77" },
|
||||
};
|
||||
|
||||
function mockFetch(json) {
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => json,
|
||||
});
|
||||
}
|
||||
|
||||
it("fetches and returns the releases json", async function() {
|
||||
mockFetch(sample);
|
||||
|
||||
const releases = await fetchLatestReleases();
|
||||
assert.deepEqual(releases, sample);
|
||||
});
|
||||
it("throws on HTTP error status", async function() {
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await assert.rejects(fetchLatestReleases(), /HTTP 500 Server Error/);
|
||||
});
|
||||
it("throws on network error", async function() {
|
||||
global.fetch = async () => {
|
||||
throw new Error("Network Error");
|
||||
};
|
||||
|
||||
await assert.rejects(fetchLatestReleases(), /Network Error/);
|
||||
});
|
||||
it("fetches from the live api", async function() {
|
||||
slowTest(this);
|
||||
global.fetch = originalFetch;
|
||||
|
||||
const releases = await fetchLatestReleases();
|
||||
assert.ok(releases.stable, "expected a stable channel");
|
||||
assert.ok(releases.experimental, "expected an experimental channel");
|
||||
assert.equal(typeof releases.stable.headless, "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReleaseChannel", function() {
|
||||
const releases = {
|
||||
experimental: { alpha: "2.1.8", demo: "2.0.77", headless: "2.1.8" },
|
||||
stable: { alpha: "2.0.77", headless: "2.0.77" },
|
||||
// A channel without a headless build, to exercise the fallback.
|
||||
oddball: { alpha: "1.2.3" },
|
||||
};
|
||||
|
||||
it("returns the headless build version by default", function() {
|
||||
assert.equal(resolveReleaseChannel(releases, "stable"), "2.0.77");
|
||||
assert.equal(resolveReleaseChannel(releases, "experimental"), "2.1.8");
|
||||
});
|
||||
it("returns the requested build version", function() {
|
||||
assert.equal(resolveReleaseChannel(releases, "experimental", "demo"), "2.0.77");
|
||||
});
|
||||
it("falls back to the first build when headless is missing", function() {
|
||||
assert.equal(resolveReleaseChannel(releases, "oddball"), "1.2.3");
|
||||
});
|
||||
it("returns undefined for an unknown channel", function() {
|
||||
assert.equal(resolveReleaseChannel(releases, "nightly"), undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,16 @@ describe("lib/factorio/exchange_string", function() {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse post v2.1 strings", function() {
|
||||
assert.deepEqual(lib.readMapExchangeString(testStrings.modified_v2_1), {
|
||||
version: [2, 1, 9, 3],
|
||||
unknown: 0,
|
||||
map_gen_settings: testSettings.modified_v2_1_map_gen_settings,
|
||||
map_settings: testSettings.modified_v2_1_map_settings,
|
||||
checksum: 3180296799,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed strings", function() {
|
||||
assert.throws(
|
||||
() => lib.readMapExchangeString("<<blah>>"),
|
||||
|
||||
@@ -467,6 +467,154 @@ const modified_v2_map_settings = {
|
||||
},
|
||||
};
|
||||
|
||||
const modified_v2_1_map_gen_settings = {
|
||||
"autoplace_controls": {
|
||||
"coal": { "frequency": 1, "size": 1, "richness": 1 },
|
||||
"copper-ore": { "frequency": 0.5, "size": 1, "richness": 1 },
|
||||
"crude-oil": { "frequency": 0.5, "size": 1, "richness": 4 },
|
||||
"enemy-base": { "frequency": 2, "size": 1.5, "richness": 1 },
|
||||
"iron-ore": { "frequency": 1.5, "size": 1, "richness": 1 },
|
||||
"nauvis_cliff": { "frequency": 1, "size": 1, "richness": 1 },
|
||||
"rocks": { "frequency": 1, "size": 1, "richness": 1 },
|
||||
"starting_area_moisture": { "frequency": 1, "size": 1, "richness": 1 },
|
||||
"stone": { "frequency": 2, "size": 1, "richness": 1 },
|
||||
"trees": { "frequency": 1.3333333730697632, "size": 0.3333333432674408, "richness": 1 },
|
||||
"uranium-ore": { "frequency": 1, "size": 1.3333333730697632, "richness": 1 },
|
||||
"water": { "frequency": 0.5, "size": 0.3333333432674408, "richness": 1 },
|
||||
},
|
||||
"autoplace_settings": {},
|
||||
"default_enable_all_autoplace_controls": true,
|
||||
"seed": 3378377395,
|
||||
"width": 2000000,
|
||||
"height": 2000000,
|
||||
"area_to_generate_at_start": {
|
||||
"left_top": { "x": -224, "y": -224 },
|
||||
"right_bottom": { "x": 224, "y": 224 },
|
||||
"orientation": { "x": 0, "y": -32767 },
|
||||
},
|
||||
"starting_area": 0.3333333432674408,
|
||||
"peaceful_mode": false,
|
||||
"no_enemies_mode": false,
|
||||
"starting_points": [
|
||||
{ "x": 0, "y": 0 },
|
||||
],
|
||||
"property_expression_names": {
|
||||
"control:aux:bias": "-0.200000",
|
||||
"control:aux:frequency": "2.000000",
|
||||
"control:moisture:bias": "-0.200000",
|
||||
"control:moisture:frequency": "0.333333",
|
||||
"elevation": "elevation_lakes",
|
||||
},
|
||||
"cliff_settings": {
|
||||
"name": "cliff",
|
||||
"control": "",
|
||||
"cliff_elevation_0": 10,
|
||||
"cliff_elevation_interval": 40,
|
||||
"richness": 1,
|
||||
"cliff_smoothing": 1,
|
||||
},
|
||||
"territory_settings": null,
|
||||
};
|
||||
|
||||
const modified_v2_1_map_settings = {
|
||||
"pollution": {
|
||||
"enabled": true,
|
||||
"diffusion_ratio": 0.02,
|
||||
"min_to_diffuse": 15,
|
||||
"ageing": 1.5,
|
||||
"expected_max_per_chunk": 150,
|
||||
"min_to_show_per_chunk": 50,
|
||||
"min_pollution_to_damage_trees": 3785,
|
||||
"pollution_with_max_forest_damage": 150,
|
||||
"pollution_per_tree_damage": 50,
|
||||
"pollution_restored_per_tree_damage": 3714,
|
||||
"max_pollution_to_restore_trees": 20,
|
||||
"enemy_attack_pollution_consumption_modifier": 1,
|
||||
},
|
||||
"enemy_evolution": {
|
||||
"enabled": true,
|
||||
"time_factor": 0.00002,
|
||||
"destroy_factor": 0.002,
|
||||
"pollution_factor": 0.000012,
|
||||
},
|
||||
"enemy_expansion": {
|
||||
"enabled": true,
|
||||
"max_expansion_distance": 13,
|
||||
"min_expansion_distance": 3,
|
||||
"friendly_base_influence_radius": 6,
|
||||
"enemy_building_influence_radius": 3,
|
||||
"building_coefficient": 0.5,
|
||||
"other_base_coefficient": 3,
|
||||
"neighbouring_chunk_coefficient": 0.5,
|
||||
"neighbouring_base_chunk_coefficient": 0.5,
|
||||
"max_colliding_tiles_coefficient": 0.8,
|
||||
"settler_group_min_size": 5,
|
||||
"settler_group_max_size": 20,
|
||||
"evolution_group_size_factor": 4,
|
||||
"min_expansion_cooldown": 169200,
|
||||
"max_expansion_cooldown": 414000,
|
||||
},
|
||||
"unit_group": {
|
||||
"min_group_gathering_time": 3600,
|
||||
"max_group_gathering_time": 36000,
|
||||
"max_wait_time_for_late_members": 7200,
|
||||
"max_group_radius": 30,
|
||||
"min_group_radius": 5,
|
||||
"max_member_speedup_when_behind": 1.4,
|
||||
"max_member_slowdown_when_ahead": 0.6,
|
||||
"max_group_slowdown_factor": 0.3,
|
||||
"max_group_member_fallback_factor": 3,
|
||||
"member_disown_distance": 10,
|
||||
"tick_tolerance_when_member_arrives": 60,
|
||||
"max_gathering_unit_groups": 30,
|
||||
"max_unit_group_size": 200,
|
||||
},
|
||||
"path_finder": {
|
||||
"fwd2bwd_ratio": 5,
|
||||
"goal_pressure_ratio": 2,
|
||||
"use_path_cache": true,
|
||||
"max_steps_worked_per_tick": 1000,
|
||||
"max_work_done_per_tick": 8000,
|
||||
"short_cache_size": 5,
|
||||
"long_cache_size": 25,
|
||||
"short_cache_min_cacheable_distance": 10,
|
||||
"short_cache_min_algo_steps_to_cache": 50,
|
||||
"long_cache_min_cacheable_distance": 30,
|
||||
"cache_max_connect_to_cache_steps_multiplier": 100,
|
||||
"cache_accept_path_start_distance_ratio": 0.2,
|
||||
"cache_accept_path_end_distance_ratio": 0.15,
|
||||
"negative_cache_accept_path_start_distance_ratio": 0.3,
|
||||
"negative_cache_accept_path_end_distance_ratio": 0.3,
|
||||
"cache_path_start_distance_rating_multiplier": 10,
|
||||
"cache_path_end_distance_rating_multiplier": 20,
|
||||
"stale_enemy_with_same_destination_collision_penalty": 30,
|
||||
"ignore_moving_enemy_collision_distance": 5,
|
||||
"enemy_with_different_destination_collision_penalty": 30,
|
||||
"general_entity_collision_penalty": 10,
|
||||
"general_entity_subsequent_collision_penalty": 3,
|
||||
"extended_collision_penalty": 3,
|
||||
"max_clients_to_accept_any_new_request": 10,
|
||||
"max_clients_to_accept_short_new_request": 100,
|
||||
"direct_distance_to_consider_short_request": 100,
|
||||
"short_request_max_steps": 1000,
|
||||
"short_request_ratio": 0.5,
|
||||
"min_steps_to_check_path_find_termination": 2000,
|
||||
"start_to_goal_cost_multiplier_to_terminate_path_find": 2000,
|
||||
"overload_levels": [0, 100, 500],
|
||||
"overload_multipliers": [2, 3, 4],
|
||||
"negative_path_cache_delay_interval": 20,
|
||||
},
|
||||
"max_failed_behavior_count": 3,
|
||||
"difficulty_settings": {
|
||||
"technology_price_multiplier": 12,
|
||||
"spoil_time_modifier": 1,
|
||||
},
|
||||
"asteroids": {
|
||||
"spawning_rate": 1,
|
||||
"max_ray_portals_expanded_per_tick": 100,
|
||||
},
|
||||
};
|
||||
|
||||
const modified_space_age_map_gen_settings = {
|
||||
autoplace_controls: {
|
||||
"aquilo_crude_oil": { frequency: 1, size: 1, richness: 1 },
|
||||
@@ -553,5 +701,7 @@ module.exports = {
|
||||
modified_map_settings,
|
||||
modified_v2_map_gen_settings,
|
||||
modified_v2_map_settings,
|
||||
modified_v2_1_map_gen_settings,
|
||||
modified_v2_1_map_settings,
|
||||
modified_space_age_map_gen_settings,
|
||||
};
|
||||
@@ -49,6 +49,22 @@ Ln6IET6h2alhiMgvQ3SGxnDDDd32daT/yCD5fQ8R3WMEyeN/PENaNmy
|
||||
E5utYOI24z/cT+oQf48FdgozaFZg6qX+1KqW+GYyy4qNZgFNY/0IAHj
|
||||
RI5Q/27QvL<<<`;
|
||||
|
||||
const modified_v2_1 = `
|
||||
>>>eNp1U89rE0EUftM0NYlVc4iCIjWHHkRICNFTkezoRTx4ELxKnGwm
|
||||
unSz087uVlsP5uDBg1CQXvSiVyt6EY9CwIu9if4DlXqwt1aK9CDEeZm
|
||||
dyZrgg/f49nu/37BTQCAPGYDZaVcwH6DnGC24YmmJy4qQHAAsnXdl3O
|
||||
YV4fkplhZ4wLurlRYLMZgC9IeunCdFoCv0bYXZgMUrXth0fa/TSXfMS
|
||||
uEuhmnmVBgxGXnB3SaTnDW7wgujGKulksJIBEnThIkk5+GbzU1HaQOZ
|
||||
o7FkgRd39SQ95HXkfRZxiXuYSCDvf93e6j2eA9TBIygPBqgKbas4VCA
|
||||
9HU0UmUi26IogksJfYPGDhZbHwnylVq3XUE6mXR3Jl2MeuKu5erX2r9
|
||||
ssN5Z+ZsI/qlGrXhxKnvt8hUWeCE5Y1PTZIg+zyZGhfEXpVXs4QsjD0
|
||||
rtr39c2HKJXqNIEHCZMv2WY6wl49pb+zzVtXHDJgD1HNdm/t/Pkw+FB
|
||||
g/x5vf/1RuuOQ279KC6HF3YbynkMj5lBM2ORzt02U+XoOKPAi+couw7
|
||||
JYkZplFakZG9jCkjt5gyQ4nHFv3qqTPmsjWiYeiVKOkP57RB9xB0Dvk
|
||||
00n6fkMnaZQ/MZTXbUFKhaRYN1Sug54z09ClH5dUjP0MYvvcaWafsp1
|
||||
X9skPmJ46b3GGNscC4FCtiwbc3PTPrUX46YL/qSDp8BMOqAJI8C+u/S
|
||||
peyl8e7GC3Ce2le3AIs019Y//gU3lOV1<<<`;
|
||||
|
||||
const modified_space_age = `
|
||||
>>>eNp1VEFoE0EUnUkb27TWBomFSqkBexAhMVZPRbJbL+LBg+BV1slm
|
||||
kgzd7LYzs9HYgz304EEQpJf2Yq9W9CIevBW8WFAQPXmr1IOCh1aL9CD
|
||||
@@ -74,5 +90,6 @@ module.exports = {
|
||||
default: default_,
|
||||
modified,
|
||||
modified_v2,
|
||||
modified_v2_1,
|
||||
modified_space_age,
|
||||
};
|
||||
@@ -792,7 +792,10 @@ describe("lib/subscriptions", function() {
|
||||
});
|
||||
|
||||
describe("class EventSubscriber", function() {
|
||||
let mockControl, eventSubscriber;
|
||||
/** @type {MockControl} */
|
||||
let mockControl;
|
||||
/** @type {lib.EventSubscriber} */
|
||||
let eventSubscriber;
|
||||
|
||||
beforeEach(function() {
|
||||
mockControl = new MockControl(new MockConnector(
|
||||
@@ -1120,6 +1123,25 @@ describe("lib/subscriptions", function() {
|
||||
}
|
||||
assert.equal(logged, undefined);
|
||||
});
|
||||
|
||||
it("should expose errors through the snapshot", async function() {
|
||||
eventSubscriber.control.sendTo = function() {
|
||||
throw new lib.PermissionError("Permission denied");
|
||||
};
|
||||
|
||||
const unsubscribe = eventSubscriber.subscribe(() => {});
|
||||
await new Promise(r => setImmediate(r));
|
||||
const [,, error1] = eventSubscriber.getSnapshot();
|
||||
assert.ok(error1 instanceof lib.PermissionError);
|
||||
assert.equal(error1.message, "Permission denied");
|
||||
assert.equal(error1.code, "PermissionError");
|
||||
|
||||
eventSubscriber.control.sendTo = function() {};
|
||||
unsubscribe();
|
||||
await new Promise(r => setImmediate(r));
|
||||
const [,, error2] = eventSubscriber.getSnapshot();
|
||||
assert.deepEqual(error2, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Controller, ControlConnection } = require("@clusterio/controller");
|
||||
@@ -416,5 +419,50 @@ describe("messages/controller", function() {
|
||||
assert.deepEqual(versions3, versions);
|
||||
assert.equal(callCount, 2);
|
||||
});
|
||||
it("round trips LatestReleasesRequest", function() {
|
||||
testRoundTripJsonSerialisable(lib.LatestReleasesRequest, [[], [0], [60000]]);
|
||||
});
|
||||
it("caches the latest releases and persists them for offline use", async function() {
|
||||
const releases = {
|
||||
stable: { headless: "2.0.77" },
|
||||
experimental: { headless: "2.1.8" },
|
||||
};
|
||||
const databaseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-test-"));
|
||||
controller.config.set("controller.database_directory", databaseDirectory);
|
||||
const cachePath = path.join(databaseDirectory, "latest-releases.json");
|
||||
const originalFetch = global.fetch;
|
||||
try {
|
||||
let callCount = 0;
|
||||
global.fetch = async () => {
|
||||
callCount += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => releases };
|
||||
};
|
||||
|
||||
// First call fetches from the api and persists the result to disk
|
||||
const fetched = await controlConnection.handleLatestReleasesRequest(
|
||||
new lib.LatestReleasesRequest(0)
|
||||
);
|
||||
assert.deepEqual(fetched, releases);
|
||||
assert.equal(callCount, 1);
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(cachePath, "utf8")), releases);
|
||||
|
||||
// When the api is unavailable the persisted copy is served instead
|
||||
global.fetch = async () => { throw new Error("Network Error"); };
|
||||
const cached = await controlConnection.handleLatestReleasesRequest(
|
||||
new lib.LatestReleasesRequest(0)
|
||||
);
|
||||
assert.deepEqual(cached, releases);
|
||||
|
||||
// With no persisted copy and an unavailable api an empty object is returned
|
||||
await fs.rm(cachePath);
|
||||
const empty = await controlConnection.handleLatestReleasesRequest(
|
||||
new lib.LatestReleasesRequest(0)
|
||||
);
|
||||
assert.deepEqual(empty, {});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
await fs.rm(databaseDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -187,14 +187,16 @@ describe("messages/mod", function() {
|
||||
});
|
||||
});
|
||||
describe("handle", function() {
|
||||
const factorioVersions = ["1.0", "1.1", "2.0"];
|
||||
const factorioVersions = ["1.0", "1.1", "2.0", "2.1"];
|
||||
it("resolves dependencies", async function() {
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz"]);
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz", "+ qux"]);
|
||||
setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]);
|
||||
setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []);
|
||||
setPortalModRelease("bar", "1.0.0", factorioVersion, []);
|
||||
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
|
||||
setPortalModRelease("qux", "1.0.0", factorioVersion, ["qux-dep"]);
|
||||
setPortalModRelease("qux-dep", "1.0.0", factorioVersion, []);
|
||||
|
||||
setPortalModRelease("root-2", "1.0.0", factorioVersion, ["foo", "bar-2"]);
|
||||
setPortalModRelease("bar-2", "1.0.0", factorioVersion, []);
|
||||
@@ -213,7 +215,7 @@ describe("messages/mod", function() {
|
||||
const depIds = new Set(result.dependencies.map(mod => mod.id));
|
||||
assert.deepEqual(depIds, new Set([
|
||||
"root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0", "bar_1.0.0",
|
||||
"root-2_1.0.0", "bar-2_1.0.0",
|
||||
"root-2_1.0.0", "bar-2_1.0.0", "qux_1.0.0", "qux-dep_1.0.0",
|
||||
]));
|
||||
}
|
||||
});
|
||||
@@ -307,6 +309,24 @@ describe("messages/mod", function() {
|
||||
assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"]));
|
||||
}
|
||||
});
|
||||
it("ignores missing optional dependencies", async function() {
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["? foo"]);
|
||||
|
||||
const result = await controlConnection.handleModDependencyResolveRequest(
|
||||
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.errors, new Map([
|
||||
["base", "notFound"],
|
||||
]));
|
||||
|
||||
const depIds = new Set(result.dependencies.map(mod => mod.id));
|
||||
assert.deepEqual(depIds, new Set([
|
||||
"root_1.0.0",
|
||||
]));
|
||||
}
|
||||
});
|
||||
it("does not ignore optional dependencies when required by another", async function() {
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "? baz"]);
|
||||
@@ -325,6 +345,44 @@ describe("messages/mod", function() {
|
||||
assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0", "baz_1.0.0"]));
|
||||
}
|
||||
});
|
||||
it("ignores missing recommended dependencies", async function() {
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["+ foo"]);
|
||||
|
||||
const result = await controlConnection.handleModDependencyResolveRequest(
|
||||
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.errors, new Map([
|
||||
["base", "notFound"],
|
||||
]));
|
||||
|
||||
const depIds = new Set(result.dependencies.map(mod => mod.id));
|
||||
assert.deepEqual(depIds, new Set([
|
||||
"root_1.0.0",
|
||||
]));
|
||||
}
|
||||
});
|
||||
it("does not ignore recommended dependencies when required by another", async function() {
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
setPortalModRelease("root", "1.0.0", factorioVersion, ["+ baz", "foo"]);
|
||||
setPortalModRelease("foo", "1.0.0", factorioVersion, ["baz"]);
|
||||
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
|
||||
|
||||
const result = await controlConnection.handleModDependencyResolveRequest(
|
||||
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.errors, new Map([
|
||||
["base", "notFound"],
|
||||
]));
|
||||
|
||||
const depIds = new Set(result.dependencies.map(mod => mod.id));
|
||||
assert.deepEqual(depIds, new Set([
|
||||
"root_1.0.0", "foo_1.0.0", "baz_1.0.0",
|
||||
]));
|
||||
}
|
||||
});
|
||||
it("prefers the locally installed version if matching", async function() {
|
||||
controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" }));
|
||||
for (const factorioVersion of factorioVersions) {
|
||||
@@ -426,7 +484,7 @@ describe("messages/mod", function() {
|
||||
"pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3",
|
||||
"pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics",
|
||||
"pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics",
|
||||
"pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics",
|
||||
"pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", "enable-all-feature-flags",
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user