mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
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:
commit
0874c26208
56 files changed
+961
-232
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -190,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
||||
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
|
||||
return [path.join(factorioDir, "data"), directVersion];
|
||||
}
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
throw new Error(
|
||||
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
|
||||
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise assume it is a directory of multiple versions
|
||||
@@ -952,6 +956,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
@@ -987,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (!installedVersions.versions.has(latestVersion.version)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct || process.platform !== "linux") {
|
||||
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"version": "2.0.0-alpha.26",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,62 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersion, FullVersionSchema } from "../data/version";
|
||||
|
||||
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
|
||||
|
||||
/**
|
||||
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
|
||||
* for a single release channel.
|
||||
*/
|
||||
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
|
||||
|
||||
/**
|
||||
* The latest released version of each build, keyed by release channel name
|
||||
* (e.g. "stable" and "experimental").
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
*
|
||||
* @returns The latest version of each build keyed by release channel
|
||||
* @throws Network errors and non-ok status responses
|
||||
*/
|
||||
export async function fetchLatestReleases(): Promise<LatestReleases> {
|
||||
let res;
|
||||
|
||||
try {
|
||||
res = await fetch(LATEST_RELEASES_URL);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return await res.json() as LatestReleases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a release channel to a concrete factorio version
|
||||
*
|
||||
* @param releases Releases data as returned by {@link fetchLatestReleases}
|
||||
* @param channel Release channel name, e.g. "stable" or "experimental"
|
||||
* @param build Build to read the version of, defaults to the headless build
|
||||
* @returns The version for the channel, or undefined if it cannot be resolved
|
||||
*/
|
||||
export function resolveReleaseChannel(
|
||||
releases: LatestReleases,
|
||||
channel: string,
|
||||
build = "headless",
|
||||
): FullVersion | undefined {
|
||||
const builds = releases[channel];
|
||||
if (!builds) {
|
||||
return undefined;
|
||||
}
|
||||
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
|
||||
}
|
||||
Vendored
+1
@@ -3,3 +3,4 @@
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
export * from "./LatestReleases";
|
||||
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
controller.LatestReleasesRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
|
||||
@@ -524,3 +524,9 @@ definePermission({
|
||||
title: "Get factorio versions",
|
||||
description: "Get list of all factorio versions from Wube",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.external.get_latest_releases",
|
||||
title: "Get factorio releases",
|
||||
description: "Get the latest stable and experimental factorio releases from Wube",
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -170,7 +170,7 @@ export function NpmButton(props: {
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Row, Col, Card, Statistic, Progress, Button, Typography } from "antd";
|
||||
import { LineChartOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useUsers, isUserOnline } from "../model/user";
|
||||
import { useSystems } from "../model/system";
|
||||
import { useSaves } from "../model/saves";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import LogConsole from "./LogConsole";
|
||||
import { MetricRelativeDate } from "./system_metrics";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
||||
|
||||
/** A resource card showing used / total with a usage bar. */
|
||||
function ResourceCard(
|
||||
props: { title: string, used: number, total: number, format: (n: number) => string, unit?: string }
|
||||
) {
|
||||
const percent = props.total > 0 ? Math.round((props.used / props.total) * 100) : 0;
|
||||
const suffix = `/ ${props.format(props.total)}${props.unit ? ` ${props.unit}` : ""}`;
|
||||
return <Card>
|
||||
<Statistic title={props.title} value={props.format(props.used)} suffix={suffix} />
|
||||
<Progress percent={percent} size="small" />
|
||||
</Card>;
|
||||
}
|
||||
|
||||
/** Format a core count: a fraction for partial use, a whole number for the total. */
|
||||
const formatCores = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||
|
||||
export default function OverviewPage() {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [hosts] = useHosts();
|
||||
const [instances] = useInstances();
|
||||
const [users] = useUsers();
|
||||
const [systems] = useSystems();
|
||||
const [saves] = useSaves();
|
||||
const [grafanaUrl, setGrafanaUrl] = useState<string | null>(null);
|
||||
|
||||
const canHosts = account.hasPermission("core.host.subscribe");
|
||||
const canSystems = account.hasPermission("core.system.subscribe");
|
||||
const canInstances = account.hasPermission("core.instance.subscribe");
|
||||
const canUsers = account.hasPermission("core.user.subscribe");
|
||||
const canSaves = account.hasPermission("core.instance.save.subscribe");
|
||||
const canLogs = account.hasPermission("core.log.follow");
|
||||
const canConfig = account.hasPermission("core.controller.get_config");
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig) {
|
||||
return undefined;
|
||||
}
|
||||
let canceled = false;
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(serialized => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
const config = lib.ControllerConfig.fromJSON(serialized, "control");
|
||||
const url = config.get("controller.grafana_url");
|
||||
setGrafanaUrl(typeof url === "string" ? url : null);
|
||||
}).catch(() => { /* link stays hidden if the config can't be read */ });
|
||||
return () => { canceled = true; };
|
||||
}, [control, canConfig]);
|
||||
|
||||
const hostList = [...hosts.values()];
|
||||
const instanceList = [...instances.values()];
|
||||
const userList = [...users.values()];
|
||||
// Resource totals across the connected host machines (the controller is shown only for uptime).
|
||||
const hostSystems = [...systems.values()].filter(system => system.id !== "controller");
|
||||
const controllerSystem = systems.get("controller");
|
||||
const saveList = [...saves.values()];
|
||||
const loadedSaveSize = sum(saveList.filter(save => save.loaded).map(save => save.size));
|
||||
const totalSaveSize = sum(saveList.map(save => save.size));
|
||||
|
||||
const colProps = { xs: 24, sm: 12, lg: 6 };
|
||||
|
||||
return <PageLayout nav={[{ name: "Overview" }]}>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
extra={grafanaUrl
|
||||
? <Button icon={<LineChartOutlined />} href={grafanaUrl} target="_blank" rel="noreferrer">
|
||||
Grafana
|
||||
</Button>
|
||||
: undefined}
|
||||
/>
|
||||
<Row gutter={[16, 16]}>
|
||||
{canHosts && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Hosts connected" value={hostList.filter(h => h.connected).length}
|
||||
suffix={`/ ${hostList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canInstances && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Instances running" value={instanceList.filter(i => i.status === "running").length}
|
||||
suffix={`/ ${instanceList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canUsers && <Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Users online" value={userList.filter(u => isUserOnline(u)).length}
|
||||
suffix={`/ ${userList.length}`} />
|
||||
</Card>
|
||||
</Col>}
|
||||
{canSystems && <>
|
||||
<Col {...colProps}>
|
||||
<Card>
|
||||
<Statistic title="Controller running since" formatter={() => (
|
||||
<MetricRelativeDate timeMs={controllerSystem?.processStartedAtMs} compact />
|
||||
)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="CPU" used={sum(hostSystems.map(s => s.cpuUsed))}
|
||||
total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" />
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="Memory" used={sum(hostSystems.map(s => s.memoryUsed))}
|
||||
total={sum(hostSystems.map(s => s.memoryCapacity))} format={lib.formatBytes} />
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<ResourceCard title="Disk" used={sum(hostSystems.map(s => s.diskUsed))}
|
||||
total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} />
|
||||
</Col>
|
||||
</>}
|
||||
{canSaves && <Col {...colProps}>
|
||||
<ResourceCard title="Saves loaded" used={loadedSaveSize} total={totalSaveSize}
|
||||
format={lib.formatBytes} />
|
||||
</Col>}
|
||||
</Row>
|
||||
{canLogs && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Recent errors</Title>
|
||||
<LogConsole all maxLevel="warn" />
|
||||
</>}
|
||||
<PluginExtra component="OverviewPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -120,7 +120,7 @@ function TransferModal(props: ModalProps) {
|
||||
open={open}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -50,7 +50,7 @@ function CreateUserButton() {
|
||||
open={open}
|
||||
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="userName" label="Name">
|
||||
@@ -345,7 +345,7 @@ function BulkUserActionButton() {
|
||||
okButtonProps={{ disabled: formAction === undefined }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Table, Tag, 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})`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -2,6 +2,7 @@ import React, { ReactElement } from "react";
|
||||
|
||||
import { PluginPage, UserAccount } from "./BaseWebPlugin";
|
||||
|
||||
import OverviewPage from "./components/OverviewPage";
|
||||
import ControllerPage from "./components/ControllerPage";
|
||||
import HostsPage from "./components/HostsPage";
|
||||
import HostViewPage from "./components/HostViewPage";
|
||||
@@ -21,6 +22,11 @@ import IconReferencePage from "./components/IconReferencePage";
|
||||
// When adding or editing page paths here the corresponding webRoutes entry
|
||||
// in packages/controller/src/routes.ts also needs to be updated.
|
||||
export const pages: PluginPage[] = [
|
||||
{
|
||||
path: "/",
|
||||
sidebarName: "Overview",
|
||||
content: <OverviewPage />,
|
||||
},
|
||||
{
|
||||
path: "/controller",
|
||||
sidebarName: "Controller",
|
||||
|
||||
@@ -10,6 +10,14 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Make a table link-cell's link fill the cell width, so the whole column
|
||||
width is clickable and right-click "open in new tab"-able, not just the
|
||||
text. Uses display:block rather than an overlay so sibling content (e.g.
|
||||
the restart-required icon) stays interactive. */
|
||||
.table-link-cell a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -33,17 +41,13 @@ body {
|
||||
}
|
||||
|
||||
.site-breadcrumb {
|
||||
margin: 16px 0 16px 52px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.site-layout-content-container {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.site-breadcrumb {
|
||||
margin-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -56,13 +60,30 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.site-layout-sider {
|
||||
z-index: 0;
|
||||
/* Top bar hosting the menu toggle and branding, sticky above the content. */
|
||||
.site-layout-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-inline: 16px;
|
||||
background: #001529;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-left: 250px;
|
||||
padding-right: 64px;
|
||||
/* Keep the sidebar fixed in place (below the header) while content scrolls. */
|
||||
.site-layout-sider {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
height: calc(100vh - 64px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Snap the sider open/closed instead of animating its width — the width
|
||||
transition reflowed the content area and caused styling glitches. */
|
||||
.ant-layout-sider.site-layout-sider {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -105,75 +126,6 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 112px;
|
||||
font-size: 18pt;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.site-version {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 120px;
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
position: absolute;
|
||||
left: 36px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-image: url("./images/logo.png");
|
||||
background-size: 64px;
|
||||
}
|
||||
|
||||
@media not all and (min-width: 576px) {
|
||||
.header {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-version {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account-dropdown-header {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 18px;
|
||||
line-height: 72px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.account-dropdown-header::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
transition: all 0.3s;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.account-dropdown-header:hover::after {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.error-traceback code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,9 @@ export class Control extends lib.Link {
|
||||
/** Cache for factorio versions to avoid repeat calls to the controller */
|
||||
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
|
||||
|
||||
/** Cache for the latest factorio release channels to avoid repeat calls to the controller */
|
||||
latestReleases = new lib.ValueCache(this.requestLatestReleases.bind(this));
|
||||
|
||||
declare connector: ControlConnector;
|
||||
|
||||
constructor(
|
||||
@@ -112,6 +115,10 @@ export class Control extends lib.Link {
|
||||
return this.send(new lib.FactorioVersionsRequest());
|
||||
}
|
||||
|
||||
requestLatestReleases() {
|
||||
return this.send(new lib.LatestReleasesRequest());
|
||||
}
|
||||
|
||||
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
|
||||
this.accountRoles = event.roles??null;
|
||||
this.emitAccountUpdate();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.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,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,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,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,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,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
@@ -62,7 +62,11 @@ describe("host/server", function() {
|
||||
let installDir = path.join("test", "file", "factorio", "0.1.1");
|
||||
await assert.rejects(
|
||||
hostServer._findVersion(installDir, "0.1.2"),
|
||||
new Error("Unable to find Factorio version 0.1.2")
|
||||
new Error(
|
||||
`Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` +
|
||||
`install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -229,6 +233,16 @@ describe("host/server", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".setTargetVersion()", function() {
|
||||
it("should override the target version before init", function() {
|
||||
const fresh = new hostServer.FactorioServer(
|
||||
path.join("test", "file", "factorio"), writePath, {}
|
||||
);
|
||||
fresh.setTargetVersion("0.1.5");
|
||||
assert.equal(fresh._targetVersion, "0.1.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".init()", function() {
|
||||
it("should not throw on first call", async function() {
|
||||
await server.init();
|
||||
@@ -433,7 +447,7 @@ describe("host/server", function() {
|
||||
|
||||
assert.equal(fetchCalledWith, null);
|
||||
assert.ok(logLine !== null);
|
||||
assert.ok(logLine.endsWith("but must be manually downloaded"));
|
||||
assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux)."));
|
||||
});
|
||||
it("should do attempt to download on linux", async function() {
|
||||
let logLine = null;
|
||||
|
||||
@@ -704,6 +704,28 @@ describe("Integration of Clusterio", function() {
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
}
|
||||
});
|
||||
it("should resolve a release channel target when starting", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
// "experimental" is resolved to a concrete version through the
|
||||
// controller's latest-releases data before the server starts.
|
||||
// Whether that version is installed (or downloadable) depends on
|
||||
// the host's Factorio install - CI uses a fixed direct install -
|
||||
// so the start may succeed or fail, but the channel must resolve
|
||||
// and the instance must reach a terminal state rather than hang.
|
||||
await execCtl("instance config set 44 factorio.version experimental");
|
||||
await execCtl(`instance ${cmd} test`).catch(() => {});
|
||||
const status = (await getInstances()).get(44).status;
|
||||
assert(
|
||||
["running", "stopped"].includes(status),
|
||||
`instance stuck in unexpected state '${status}'`
|
||||
);
|
||||
|
||||
} finally {
|
||||
await execCtl("instance stop 44").catch(() => {});
|
||||
await execCtl("instance config set 44 factorio.version latest");
|
||||
}
|
||||
});
|
||||
it("should not leave the instance in the stopping state if it fails", async function() {
|
||||
slowTest(this);
|
||||
try {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user