From d250394580483fae96decd6c6e1b848503b137da Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:07:26 +0100 Subject: [PATCH] Show the mod pack an instance uses in the web UI (#1177) InstanceDetails now carries factorio.mod_pack_id as modPackId, so the instance list and pages can show it without fetching each config. The web UI resolves an unset value to the controller default and tags it "Default". The name links to the mod pack page. Closes #852 Co-authored-by: Claude Opus 5.5 --- packages/controller/src/InstanceRecord.ts | 1 + packages/lib/src/data/InstanceDetails.ts | 7 ++++ .../web_ui/src/components/InstanceList.tsx | 24 ++++++++++++- .../web_ui/src/components/InstanceModPack.tsx | 20 +++++++++++ .../src/components/InstanceViewPage.tsx | 16 ++++++++- packages/web_ui/src/util/instance.ts | 26 ++++++++++++++ test/controller/InstanceRecord.test.js | 11 ++++++ test/lib/data/InstanceDetails.test.js | 1 + test/web_ui/instance.js | 34 +++++++++++++++++++ 9 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/web_ui/src/components/InstanceModPack.tsx create mode 100644 test/web_ui/instance.js diff --git a/packages/controller/src/InstanceRecord.ts b/packages/controller/src/InstanceRecord.ts index 02f3aa4e..f1c6a410 100644 --- a/packages/controller/src/InstanceRecord.ts +++ b/packages/controller/src/InstanceRecord.ts @@ -85,6 +85,7 @@ export default class InstanceRecord { this.config.get("instance.exclude_from_start_all"), this.config.get("instance.is_template"), this.config.get("instance.template_id") ?? undefined, + this.config.get("factorio.mod_pack_id") ?? undefined, ); } diff --git a/packages/lib/src/data/InstanceDetails.ts b/packages/lib/src/data/InstanceDetails.ts index cad0761d..1d5b05b4 100644 --- a/packages/lib/src/data/InstanceDetails.ts +++ b/packages/lib/src/data/InstanceDetails.ts @@ -44,6 +44,8 @@ export default class InstanceDetails { public excludeFromStartAll = false, public isTemplate = false, public templateId?: number, + /** Value of factorio.mod_pack_id, undefined means the controller default is used */ + public modPackId?: number, ) {} get isDeleted() { @@ -65,6 +67,7 @@ export default class InstanceDetails { "excludeFromStartAll": Type.Optional(Type.Boolean()), "isTemplate": Type.Optional(Type.Boolean()), "templateId": Type.Optional(Type.Integer()), + "modPackId": Type.Optional(Type.Integer()), }); static fromJSON(json: Static) { @@ -80,6 +83,7 @@ export default class InstanceDetails { json.excludeFromStartAll, json.isTemplate, json.templateId, + json.modPackId, ); } @@ -114,6 +118,9 @@ export default class InstanceDetails { if (this.templateId !== undefined) { json.templateId = this.templateId; } + if (this.modPackId !== undefined) { + json.modPackId = this.modPackId; + } return json; } diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 02315695..bd54f153 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -7,12 +7,14 @@ import type { ColumnType } from "antd/es/table/interface"; import { useAccount } from "../model/account"; import { useSystems } from "../model/system"; import { useHosts } from "../model/host"; +import { useDefaultModPack, useModPacks } from "../model/mod_pack"; import { RestartRequired } from "./system_metrics"; import InstanceStatusTag from "./InstanceStatusTag"; +import InstanceModPack from "./InstanceModPack"; import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton"; import * as lib from "@clusterio/lib"; import Link from "./Link"; -import { instancePublicAddress } from "../util/instance"; +import { instanceModPack, instancePublicAddress } from "../util/instance"; import useTableQueryState from "../util/useTableQueryState"; import useColumnSearch from "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; @@ -29,6 +31,8 @@ export default function InstanceList(props: InstanceListProps) { let account = useAccount(); let [hosts] = useHosts(); const [systems] = useSystems(); + const [modPacks] = useModPacks(); + const defaultModPack = useDefaultModPack(); const tableState = useTableQueryState({ namespace: "instance", defaultSortKey: "name", }); @@ -44,6 +48,15 @@ export default function InstanceList(props: InstanceListProps) { return hosts.get(hostId)?.name ?? String(hostId); } + function modPackOf(instance: lib.InstanceDetails) { + return instanceModPack(instance.modPackId, modPacks, defaultModPack); + } + + function modPackName(instance: lib.InstanceDetails) { + const { id, modPack } = modPackOf(instance); + return modPack?.name ?? (id !== undefined ? String(id) : ""); + } + function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) { const v = instance.factorioVersion; if (v && lib.isPartialVersion(v)) { @@ -115,6 +128,15 @@ export default function InstanceList(props: InstanceListProps) { sortOrder: tableState.sortOrder("version"), responsive: ["xl"], }, + { + title: "Mod Pack", + key: "modPack", + className: "table-link-cell", + render: (_, instance) => , + sorter: (a, b) => strcmp(modPackName(a), modPackName(b)), + sortOrder: tableState.sortOrder("modPack"), + responsive: ["lg"], + }, { title: "Status", key: "status", diff --git a/packages/web_ui/src/components/InstanceModPack.tsx b/packages/web_ui/src/components/InstanceModPack.tsx new file mode 100644 index 00000000..ee9807df --- /dev/null +++ b/packages/web_ui/src/components/InstanceModPack.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Space, Tag, Tooltip } from "antd"; + +import Link from "./Link"; +import type { ResolvedModPack } from "../util/instance"; + +export default function InstanceModPack(props: { resolved: ResolvedModPack }) { + const { id, modPack, isDefault } = props.resolved; + if (id === undefined) { + return Controller default; + } + return + e.stopPropagation()}> + {modPack?.name ?? id} + + {isDefault && + Default + } + ; +} diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index f820520b..e6d6fae1 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -31,7 +31,9 @@ import { CreateInstanceButton } from "./InstancesPage"; import { useHost } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; import Link from "./Link"; -import { instancePublicAddress } from "../util/instance"; +import { instanceModPack, instancePublicAddress } from "../util/instance"; +import { useDefaultModPack, useModPacks } from "../model/mod_pack"; +import InstanceModPack from "./InstanceModPack"; import useLocalStorage from "../util/useLocalStorage"; import useResourceState from "../util/useResourceState"; import { MetricRelativeDate } from "./system_metrics"; @@ -60,9 +62,18 @@ function TemplateDescription(props: { instance: Readonly }) {props.instance.templateId !== undefined && } + + + ; } +function ModPackDescription(props: { modPackId?: number }) { + const [modPacks] = useModPacks(); + const defaultModPack = useDefaultModPack(); + return ; +} + function TemplateLink(props: { id: number }) { const [template] = useInstance(props.id); return {template?.name ?? props.id}; @@ -117,6 +128,9 @@ function InstanceDescription(props: InstanceDescriptionProps) { {instance.factorioVersion ?? "unknown"} + + + diff --git a/packages/web_ui/src/util/instance.ts b/packages/web_ui/src/util/instance.ts index b7f1f411..abf06622 100644 --- a/packages/web_ui/src/util/instance.ts +++ b/packages/web_ui/src/util/instance.ts @@ -9,3 +9,29 @@ export function instancePublicAddress(instance: lib.InstanceDetails, host?: lib. } return `${host.publicAddress}:${instance.gamePort}`; } + +export type ResolvedModPack = { + /** ID of the mod pack, undefined if the default is used and not known */ + id?: number; + modPack?: lib.ModPack; + isDefault: boolean; +}; + +/** Resolve the mod pack an instance starts with, falling back to the controller default */ +export function instanceModPack( + modPackId: number | undefined, + modPacks: ReadonlyMap, + defaultModPack?: lib.ModPack, +): ResolvedModPack { + if (modPackId !== undefined) { + return { id: modPackId, modPack: modPacks.get(modPackId), isDefault: false }; + } + if (!defaultModPack) { + return { isDefault: true }; + } + return { + id: defaultModPack.id, + modPack: modPacks.get(defaultModPack.id) ?? defaultModPack, + isDefault: true, + }; +} diff --git a/test/controller/InstanceRecord.test.js b/test/controller/InstanceRecord.test.js index 7b496bad..37216599 100644 --- a/test/controller/InstanceRecord.test.js +++ b/test/controller/InstanceRecord.test.js @@ -153,6 +153,17 @@ describe("controller/InstanceRecord", function () { assert.equal(details.startedAtMs, 100); assert.equal(details.updatedAtMs, 200); assert.equal(details.excludeFromStartAll, false); + assert.equal(details.modPackId, undefined); + }); + + it("should include the mod pack set in the config", function () { + const config = new lib.InstanceConfig("controller", { + "instance.id": 1, + "instance.name": "TestInstance", + "factorio.mod_pack_id": 3, + }); + const details = new InstanceRecord(config, "stopped").toInstanceDetails(); + assert.equal(details.modPackId, 3); }); }); diff --git a/test/lib/data/InstanceDetails.test.js b/test/lib/data/InstanceDetails.test.js index 2cec180c..094f1a3c 100644 --- a/test/lib/data/InstanceDetails.test.js +++ b/test/lib/data/InstanceDetails.test.js @@ -18,6 +18,7 @@ describe("lib/data/InstanceDetails", function () { [undefined, true], // excludeFromStartAll [undefined, true], // isTemplate [undefined, 7], // templateId + [undefined, 3], // modPackId )); }); diff --git a/test/web_ui/instance.js b/test/web_ui/instance.js new file mode 100644 index 00000000..9fb36a0d --- /dev/null +++ b/test/web_ui/instance.js @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; + +import { instanceModPack } from "../../packages/web_ui/src/util/instance.ts"; + +function modPack(id, name) { + return lib.ModPack.fromJSON({ id, name, factorio_version: "2.0" }); +} + +describe("web_ui/util/instance", function() { + describe("instanceModPack()", function() { + const base = modPack(1, "Base"); + const space = modPack(2, "Space Age"); + const modPacks = new Map([[1, base], [2, space]]); + + it("should use the mod pack set on the instance", function() { + assert.deepEqual(instanceModPack(2, modPacks, base), { id: 2, modPack: space, isDefault: false }); + }); + it("should keep the id of a mod pack that is not known", function() { + assert.deepEqual(instanceModPack(9, modPacks, base), { id: 9, modPack: undefined, isDefault: false }); + }); + it("should fall back to the controller default", function() { + assert.deepEqual(instanceModPack(undefined, modPacks, base), { id: 1, modPack: base, isDefault: true }); + }); + it("should prefer the subscribed copy of the default mod pack", function() { + const stale = modPack(1, "Old name"); + assert.equal(instanceModPack(undefined, modPacks, stale).modPack, base); + assert.equal(instanceModPack(undefined, new Map(), stale).modPack, stale); + }); + it("should mark the default as unknown when it is not available", function() { + assert.deepEqual(instanceModPack(undefined, modPacks, undefined), { isDefault: true }); + }); + }); +});