diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b8c286..4070f286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,49 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> +## Version 2.0.0-alpha.26 + +### Features + +- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913]() +- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913]() +- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913]() +- Added changelog viewer accessible from the version number in the web interface. [#907]() +- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914]() +- Added user status filtering (admin, whitelisted, banned) to the users table. [#905]() +- Added instance selector to users page to scope user stats by instance. [#905]() + +### Fixes + +- Online user table on instance page now correctly defaults to online only filter. [#909]() +- Fixed popup not opening when pressing kill within the instance control button. [#859]() +- Fix division by zero in CPU usage calculation. [#916]() +- Fixed permissions list not including `grantByDefault` in the json representation. [#913]() +- Fixed subscription resources being `synced = false` if the remote data was empty. [#913]() +- Fix incorrect use of `path.parse` within create template for plugins. [#919]() +- Fixed active filter icon behaviour in user search using empty string. [#917]() + +### Changes + +- Player auth now uses a scroll pane for listing instance names. [#909]() +- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913]() +- Unified instance and global user table columns for consistent behaviour. [#917]() +- Improved loading states for tables and inputs using synced state. [#917]() +- Refactored username rendering into a reusable component. [#917]() + +### Breaking Changes + +- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913]() +- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913]() + +### Meta + +- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803]() +- Updated change log creation to include new lines and user contributions. [#909]() + +Many thanks to the following for contributing to this release: +[@Cooldude2606]() + ## Version 2.0.0-alpha.25 ### Features diff --git a/packages/controller/package.json b/packages/controller/package.json index 2bf103c7..6e600ce9 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -3,7 +3,7 @@ "description": "Implementation of Clusterio controller", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index 5c1a2177..ba6ba39c 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -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", () => { @@ -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); + } } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a62cb353..6dfbe643 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -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 }); diff --git a/packages/create/package.json b/packages/create/package.json index 571e4296..66c34a3a 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -3,7 +3,7 @@ "description": "Installer for clusterio", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "create.js", "bin": { "clusteriocreate": "./create.js" diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 74ff843b..5474c9e4 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -3,7 +3,7 @@ "description": "Clusterio command line management tool", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", diff --git a/packages/host/package.json b/packages/host/package.json index edce1aaf..008c4f8c 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -3,7 +3,7 @@ "description": "Implementation of Clusterio host server", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 073abad1..ac72da58 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -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) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index c4ecfa05..32d65b52 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -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 { } } + /** + * 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 { 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; } diff --git a/packages/lib/package.json b/packages/lib/package.json index 31ee4de8..a0e67f81 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,7 +1,7 @@ { "name": "@clusterio/lib", "description": "Shared library for Clusterio", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "repository": "https://github.com/clusterio/clusterio", "license": "MIT", "scripts": { diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b8257ecd..ae2efbdb 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -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 { 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 { 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"); } }, }, diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 29daf36d..d8ddd4ae 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -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)), ]; /** @@ -437,6 +449,12 @@ export default class ModPack { ]); } + if (integerVersion >= integerPartialVersion("2.1")) { + defaultMods = defaultMods.concat([ + { name: "recycler", enabled: false, version: factorioVersion }, + ]); + } + return defaultMods; } diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 69ffb3a0..9075cfb9 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -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) { + return new this(json.maxAgeMs); + } + + static Response = plainJson(LatestReleasesSchema); +} diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 63297106..22ffdcd1 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -88,16 +88,30 @@ 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. + */ +const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/; + +export function isReleaseChannel(input: string): boolean { + 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 | "latest"; export const TargetVersionSchema = Type.Union([ PartialVersionSchema, Type.Literal("latest"), + Type.Unsafe(Type.String({ pattern: releaseChannelRegExp.source })), ]); export function isTargetVersion(input: string): input is TargetVersion { - return input === "latest" || partialVersionRegExp.test(input); + return input === "latest" || isReleaseChannel(input) || partialVersionRegExp.test(input); } /** diff --git a/packages/lib/src/external/LatestReleases.ts b/packages/lib/src/external/LatestReleases.ts new file mode 100644 index 00000000..91a0f322 --- /dev/null +++ b/packages/lib/src/external/LatestReleases.ts @@ -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 + +/** + * 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 { + 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; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts index 92cc1f2e..0bd258c4 100644 --- a/packages/lib/src/external/index.ts +++ b/packages/lib/src/external/index.ts @@ -3,3 +3,4 @@ * @module lib/external */ export * from "./FactorioVersions"; +export * from "./LatestReleases"; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 419385ad..d5aaa4d3 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -37,6 +37,7 @@ export const dataClasses: (RequestClass | EventClass) controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, controller.FactorioVersionsRequest, + controller.LatestReleasesRequest, subscriptions.SubscriptionRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index a93fd0c6..ce1bb44c 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -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", +}); diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 769e221f..66f9eb28 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -3,7 +3,7 @@ "description": "Clusterio web interface implementation", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "browser": "src/index.ts", "main": "dist/browser/src/index.js", "scripts": { diff --git a/packages/web_ui/src/components/AssignInstanceModal.tsx b/packages/web_ui/src/components/AssignInstanceModal.tsx index 6cb19383..8c1ba5aa 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.tsx +++ b/packages/web_ui/src/components/AssignInstanceModal.tsx @@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) { confirmLoading={applying} onOk={handleAssign} onCancel={handleCancel} - destroyOnClose + destroyOnHidden > Select a Host to assign this instance to. Assignment diff --git a/packages/web_ui/src/components/CreateSaveModal.tsx b/packages/web_ui/src/components/CreateSaveModal.tsx index 67dd7b61..8025af17 100644 --- a/packages/web_ui/src/components/CreateSaveModal.tsx +++ b/packages/web_ui/src/components/CreateSaveModal.tsx @@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) { confirmLoading={creatingSave} onOk={createSave} onCancel={handleCancel} - destroyOnClose + destroyOnHidden >
diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 9219218e..9eca290c 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -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"; @@ -22,6 +21,8 @@ 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; @@ -201,7 +202,6 @@ 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); @@ -211,6 +211,7 @@ export default function HostsPage() { namespace: "host", defaultSortKey: "name", }); const nameSearch = useColumnSearch(host => host.name, "Search hosts"); + const rowNav = useRowNavigation(); return strcmp(a.name, b.name), sortOrder: tableState.sortOrder("name"), filteredValue: tableState.filteredValue("name"), + className: "table-link-cell", + render: (_, host) => + {host.name} + , ...nameSearch, }, { @@ -325,11 +330,7 @@ export default function HostsPage() { rowKey={host => host.id} pagination={tableState.pagination} onChange={tableState.onChange} - onRow={(record, rowIndex) => ({ - onClick: event => { - navigate(`/hosts/${record.id}/view`); - }, - })} + onRow={record => rowNav(`/hosts/${record.id}/view`)} /> ; diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index cfa24922..b62f6970 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -53,7 +53,7 @@ function CreateInstanceButton(props: { instances: ReturnType { createInstance().catch(notifyErrorHandler("Error creating instance")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/LoadScenarioModal.tsx b/packages/web_ui/src/components/LoadScenarioModal.tsx index 15a03ce6..b83edb6c 100644 --- a/packages/web_ui/src/components/LoadScenarioModal.tsx +++ b/packages/web_ui/src/components/LoadScenarioModal.tsx @@ -103,7 +103,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) { confirmLoading={loadingScenario} onOk={loadScenario} onCancel={handleCancel} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index d2640d18..b6327a72 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1052,7 +1052,7 @@ function ExportButton(props: { modPack: lib.ModPack }) { open={open} onOk={close} onCancel={close} - destroyOnClose + destroyOnHidden footer={ diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 8ca6b83b..29fb95e3 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -18,6 +18,8 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import SectionHeader from "./SectionHeader"; import useTableQueryState from "../util/useTableQueryState"; +import useRowNavigation from "../util/useRowNavigation"; +import Link from "./Link"; import ModDetails from "./ModDetails"; import { Dropzone } from "./Dropzone"; import UploadButton from "./UploadButton"; @@ -507,7 +509,6 @@ 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({ @@ -516,6 +517,7 @@ export default function ModsPage() { const modTable = useTableQueryState({ namespace: "mod", defaultSortKey: "title", }); + const rowNav = useRowNavigation(); function actions(mod: lib.ModInfo) { return @@ -583,6 +585,13 @@ export default function ModsPage() { dataIndex: "name", sorter: (a, b) => strcmp(a.name, b.name), sortOrder: modPackTable.sortOrder("name"), + className: "table-link-cell", + render: (_, modPack) => + {modPack.name} + , }, { title: "Factorio Version", @@ -600,11 +609,7 @@ export default function ModsPage() { 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`)} /> diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index aa01c7b4..1a5a83e2 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -170,7 +170,7 @@ export function NpmButton(props: { okButtonProps={{disabled: formAction === undefined}} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/OverviewPage.tsx b/packages/web_ui/src/components/OverviewPage.tsx new file mode 100644 index 00000000..2a433aa3 --- /dev/null +++ b/packages/web_ui/src/components/OverviewPage.tsx @@ -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 + + + ; +} + +/** 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(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 + } href={grafanaUrl} target="_blank" rel="noreferrer"> + Grafana + + : undefined} + /> + + {canHosts && + + h.connected).length} + suffix={`/ ${hostList.length}`} /> + + } + {canInstances && + + i.status === "running").length} + suffix={`/ ${instanceList.length}`} /> + + } + {canUsers && + + isUserOnline(u)).length} + suffix={`/ ${userList.length}`} /> + + } + {canSystems && <> + + + ( + + )} /> + + + + s.cpuUsed))} + total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" /> + + + s.memoryUsed))} + total={sum(hostSystems.map(s => s.memoryCapacity))} format={lib.formatBytes} /> + + + s.diskUsed))} + total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} /> + + } + {canSaves && + + } + + {canLogs && <> + Recent errors + + } + + ; +} diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 13ee636d..1a8960f1 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -1,5 +1,4 @@ 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"; @@ -12,6 +11,8 @@ 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; @@ -24,7 +25,6 @@ type PluginRow = { export default function PluginsPage() { const control = useContext(ControlContext); - let navigate = useNavigate(); let [pluginList, setPluginList] = useState([]); const tableState = useTableQueryState({ namespace: "plugin", defaultSortKey: "name", @@ -32,6 +32,7 @@ export default function PluginsPage() { const nameSearch = useColumnSearch( plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins" ); + const rowNav = useRowNavigation(); useEffect(() => { (async () => { @@ -68,7 +69,13 @@ export default function PluginsPage() { { title: "Name", key: "name", - render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name), + className: "table-link-cell", + render: (_, plugin) => + {plugin.info ? plugin.info.title : plugin.meta.name} + , 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"), @@ -105,11 +112,7 @@ export default function PluginsPage() { rowKey={plugin => plugin.meta.name} pagination={tableState.pagination} onChange={tableState.onChange} - onRow={plugin => ({ - onClick: event => { - navigate(`/plugins/${plugin.meta.name}/view`); - }, - })} + onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)} /> ; } diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index 44b34e07..24f2c5eb 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -13,6 +13,8 @@ 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; @@ -47,7 +49,7 @@ function CreateRoleButton() { open={open} onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > @@ -63,10 +65,10 @@ function CreateRoleButton() { export default function RolesPage() { let account = useAccount(); - let navigate = useNavigate(); const [roles] = useRoles(); const tableState = useTableQueryState({ namespace: "role" }); const nameSearch = useColumnSearch(role => role.name, "Search roles"); + const rowNav = useRowNavigation(); return strcmp(a.name, b.name), sortOrder: tableState.sortOrder("name"), filteredValue: tableState.filteredValue("name"), + className: "table-link-cell", + render: (_, role) => + {role.name} + , ...nameSearch, }, { @@ -92,11 +98,7 @@ export default function RolesPage() { pagination={tableState.pagination} rowKey={role => role.id} onChange={tableState.onChange} - onRow={(role, rowIndex) => ({ - onClick: event => { - navigate(`/roles/${role.id}/view`); - }, - })} + onRow={role => rowNav(`/roles/${role.id}/view`)} /> ; diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 6219899e..0ad07eb2 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -120,7 +120,7 @@ function TransferModal(props: ModalProps) { open={open} onOk={() => form.submit()} onCancel={() => setOpen(false)} - destroyOnClose + destroyOnHidden > ["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 e.stopPropagation()}>{label}; +} 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(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: e.stopPropagation()}> + {account.name} + , + key: "user", + }, { label: Ctl Config + ; + return setDraggingProxy(1)} onDragLeave={() => setDraggingProxy(-1)} @@ -169,56 +224,56 @@ export default function SiteLayout() { setAboutOpen(wasAboutOpen); }} /> - 0}> -
-
- Clusterio - - +
- - - navigate(key)} - items={menuItems} - /> - + + {accountMenu} + + 0}> + + {isMobile + ? 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} + + : + {sidebarContent} + + } {combinedPages.map(({ path, sidebarPath, content }) => instanceName(id), + className: "table-link-cell", + render: (_, [id]) => + {instanceName(id)} + , sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])), sortOrder: statsTable.sortOrder("instance"), }, @@ -340,11 +346,7 @@ export default function UserViewPage() { pagination={statsTable.pagination} onChange={statsTable.onChange} rowKey={([id]) => id} - onRow={([id], rowIndex) => ({ - onClick: event => { - navigate(`/instances/${id}/view`); - }, - })} + onRow={([id]) => rowNav(`/instances/${id}/view`)} /> ; diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 7418a4c9..87399c80 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -50,7 +50,7 @@ function CreateUserButton() { open={open} onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > @@ -345,7 +345,7 @@ function BulkUserActionButton() { okButtonProps={{ disabled: formAction === undefined }} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 11d17163..6e2b41c7 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -1,7 +1,6 @@ import React from "react"; import { Table, Tag, type TablePaginationConfig } from "antd"; import { SearchOutlined } from "@ant-design/icons"; -import { useNavigate } from "react-router-dom"; import * as lib from "@clusterio/lib"; @@ -16,6 +15,7 @@ import { 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 */ @@ -35,7 +35,6 @@ export default function UsersTable( ) { const [roles, rolesSynced] = useRoles(); const [users, usersSynced] = useUsers(); - const navigate = useNavigate(); const tableState = useTableQueryState({ namespace: "user", defaultSortKey: "name", @@ -43,6 +42,7 @@ export default function UsersTable( pagination: pagination === false ? false : (pagination ?? { defaultPageSize: 50 }), filterCodecs: { name: userFilterCodec }, }); + const rowNav = useRowNavigation(); const {filterDropdown, filterDropdownProps} = useUserFilter(true); @@ -94,8 +94,11 @@ export default function UsersTable( { title: "Name", key: "name", + className: "table-link-cell", render: (_: any, user: lib.UserDetails) => ( - + + + ), sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name), sortOrder: tableState.sortOrder("name"), @@ -200,14 +203,7 @@ export default function UsersTable( 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`)} /> ); } diff --git a/packages/web_ui/src/components/system_metrics.tsx b/packages/web_ui/src/components/system_metrics.tsx index 9c051d12..753523c3 100644 --- a/packages/web_ui/src/components/system_metrics.tsx +++ b/packages/web_ui/src/components/system_metrics.tsx @@ -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})`; } diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 501b032e..2de8cedf 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -45,3 +45,4 @@ 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"; diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index f2187c45..cd2a7cd2 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -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: , + }, { path: "/controller", sidebarName: "Controller", diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index b1f53739..a26bb9fc 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -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; diff --git a/packages/web_ui/src/util/useLocalStorage.ts b/packages/web_ui/src/util/useLocalStorage.ts new file mode 100644 index 00000000..26a59cd9 --- /dev/null +++ b/packages/web_ui/src/util/useLocalStorage.ts @@ -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(key: string, defaultValue: T): [T, (value: T) => void] { + const [value, setValue] = useState(() => { + 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]; +} diff --git a/packages/web_ui/src/util/useRowNavigation.ts b/packages/web_ui/src/util/useRowNavigation.ts new file mode 100644 index 00000000..125b33c6 --- /dev/null +++ b/packages/web_ui/src/util/useRowNavigation.ts @@ -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 { + 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(); + } + }, + }; + }; +} diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 05747892..44069956 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -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(); diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index 8eb6babe..ad2ceea8 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-global_chat", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index 143e8301..4834eab0 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-inventory_sync", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Danielv ", "homepage": "https://github.com/clusterio/factorioClusterio#readme", diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index d9c387ae..fd61f03a 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-player_auth", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin authenticating logged in players to the web interface", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index ee361afe..4687d02f 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-research_sync", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for synchronizing research between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index 20020025..fd7d31a6 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-statistics_exporter", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for gathering statistics from Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 148775c8..d54bf1d2 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-subspace_storage", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for sharing storage between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/test/host/server.js b/test/host/server.js index ea19b133..adbec5e1 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -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; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index e44f673d..f1aa80c5 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -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 { diff --git a/test/lib/config/validators.test.js b/test/lib/config/validators.test.js index 949f3d0f..d77a5b50 100644 --- a/test/lib/config/validators.test.js +++ b/test/lib/config/validators.test.js @@ -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/ ); }); diff --git a/test/lib/external/LatestReleases.test.js b/test/lib/external/LatestReleases.test.js new file mode 100644 index 00000000..01d1d0df --- /dev/null +++ b/test/lib/external/LatestReleases.test.js @@ -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); + }); + }); +}); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 3042f82e..de74d61c 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -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 }); + } + }); }); });