Merge remote-tracking branch 'upstream/master' into feature/table-url-state

# Conflicts:
#	packages/web_ui/src/components/HostsPage.tsx
#	packages/web_ui/src/components/InstanceList.tsx
#	packages/web_ui/src/components/ModsPage.tsx
#	packages/web_ui/src/components/PluginsPage.tsx
#	packages/web_ui/src/components/RolesPage.tsx
#	packages/web_ui/src/components/UserViewPage.tsx
#	packages/web_ui/src/components/UsersTable.tsx
#	packages/web_ui/src/index.ts
This commit is contained in:
bbassie committed 2026-06-29 22:37:03 +00:00
commit 0874c26208
56 files changed
+961 -232

No files matched your search

+43
View File
@@ -31,6 +31,49 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -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);
}
}
+22
View File
@@ -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 });
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
+15
View File
@@ -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) {
+31 -3
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
+11 -2
View File
@@ -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");
}
},
},
+19 -1
View File
@@ -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;
}
+23 -1
View File
@@ -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);
}
+16 -2
View File
@@ -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<TargetVersion>(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);
}
/**
+62
View File
@@ -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;
}
+1
View File
@@ -3,3 +3,4 @@
* @module lib/external
*/
export * from "./FactorioVersions";
export * from "./LatestReleases";
+1
View File
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
controller.LatestReleasesRequest,
subscriptions.SubscriptionRequest,
+6
View File
@@ -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 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -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">
+8 -7
View File
@@ -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<lib.HostDetails>(host => host.name, "Search hosts");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
@@ -226,6 +227,10 @@ export default function HostsPage() {
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,
},
{
@@ -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`)}
/>
<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,5 +1,4 @@
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";
@@ -16,6 +15,7 @@ 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;
@@ -27,13 +27,13 @@ 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>(instance => instance.name, "Search instances");
const rowNav = useRowNavigation();
function hostName(hostId?: number) {
if (hostId === undefined) {
@@ -56,18 +56,23 @@ export default function InstanceList(props: InstanceListProps) {
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)),
@@ -137,10 +142,6 @@ export default function InstanceList(props: InstanceListProps) {
rowKey={instance => instance["id"]}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/instances/${record.id}/view`);
},
})}
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">
@@ -1052,7 +1052,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>
+11 -6
View File
@@ -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<lib.ModPack>({
@@ -516,6 +517,7 @@ export default function ModsPage() {
const modTable = useTableQueryState<lib.ModInfo>({
namespace: "mod", defaultSortKey: "title",
});
const rowNav = useRowNavigation();
function actions(mod: lib.ModInfo) {
return <Space>
@@ -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) => <Link
to={`/mods/mod-packs/${modPack.id}/view`}
style={{ color: "inherit" }}
>
{modPack.name}
</Link>,
},
{
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`)}
/>
<SectionHeader title="Stored Mods" extra={<Space wrap>
<SearchModsButton />
+1 -1
View File
@@ -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>;
}
+11 -8
View File
@@ -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<PluginWebApi[]>([]);
const tableState = useTableQueryState<PluginRow>({
namespace: "plugin", defaultSortKey: "name",
@@ -32,6 +32,7 @@ export default function PluginsPage() {
const nameSearch = useColumnSearch<PluginRow>(
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) => <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"),
@@ -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`)}
/>
</PageLayout>;
}
+9 -7
View File
@@ -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
>
<Form form={form}>
<Form.Item name="roleName" label="Name">
@@ -63,10 +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>(role => role.name, "Search roles");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Roles" }]}>
<PageHeader
@@ -81,6 +83,10 @@ export default function RolesPage() {
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,
},
{
@@ -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`)}
/>
<PluginExtra component="RolesPage" />
</PageLayout>;
+1 -1
View File
@@ -120,7 +120,7 @@ function TransferModal(props: ModalProps) {
open={open}
onOk={() => form.submit()}
onCancel={() => setOpen(false)}
destroyOnClose
destroyOnHidden
>
<Form
form={form}
+107 -52
View File
@@ -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
@@ -17,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";
@@ -32,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);
@@ -302,7 +305,10 @@ export default function UserViewPage() {
{
title: "Instance",
key: "instance",
render: (_, [id]) => instanceName(id),
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"),
},
@@ -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`)}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
+2 -2
View File
@@ -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">
+7 -11
View File
@@ -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<lib.UserDetails>({
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) => (
<Username user={user} withStatus />
<Link to={`/users/${user.name}/view`} style={{ color: "inherit" }}>
<Username user={user} withStatus />
</Link>
),
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`)}
/>
);
}
@@ -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})`;
}
+1
View File
@@ -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";
+6
View File
@@ -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",
+31 -79
View File
@@ -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;
@@ -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();
}
},
};
};
}
+7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.25",
"version": "2.0.0-alpha.26",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+16 -2
View File
@@ -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;
+22
View File
@@ -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 {
+1 -1
View File
@@ -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/
);
});
+88
View File
@@ -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);
});
});
});
+48
View File
@@ -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 });
}
});
});
});