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 }); + }); + }); +});