Give templates their own table and keep rcon_password per instance

The templates table shows name, inheritor count and a create instance
action instead of the instance status and start/stop columns.
factorio.rcon_password joins the ports as never inherited.

Part of #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-11-02 11:54:37 +00:00
1 parent b5c372f109
commit b584991f5c
6 files changed
+66 -11

No files matched your search

+1 -1
View File
@@ -537,7 +537,7 @@ ID of the template to inherit config values from.
Inherited fields take their value from the template and follow it when it changes, setting a field on this instance stops inheriting it.
Object fields like `factorio.settings` are inherited per property, properties set on this instance override the ones from the template.
When set on an existing instance fields with the same value as the template or the default value become inherited, except the server name in `factorio.settings`.
`instance.name`, `instance.is_template`, `instance.template_id`, the port fields and hidden fields are never inherited.
`instance.name`, `instance.is_template`, `instance.template_id`, the port fields, `factorio.rcon_password` and hidden fields are never inherited.
A template may itself inherit from another template, but not from itself through a chain of templates.
The fields currently inherited are stored in the hidden `instance.inherited_fields` field.
In the web interface inherited values are locked and must be unlocked before they can be changed.
+2 -2
View File
@@ -542,10 +542,10 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
return config;
}
/** Fields that are never inherited from a template, ports would clash on a shared host */
/** Fields that are never inherited from a template, ports clash on a shared host and secrets stay per instance */
static uninheritableFields = [
"instance.name", "instance.is_template", "instance.template_id",
"factorio.game_port", "factorio.rcon_port", "factorio.lua_udp_port",
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
];
/** True if the field can take its value from a template */
@@ -23,7 +23,6 @@ type InstanceListProps = {
instances: ReadonlyMap<number, Readonly<lib.InstanceDetails>>;
size?: SizeType;
hideAssignedHost?: boolean;
tableNamespace?: string;
};
export default function InstanceList(props: InstanceListProps) {
@@ -31,7 +30,7 @@ export default function InstanceList(props: InstanceListProps) {
let [hosts] = useHosts();
const [systems] = useSystems();
const tableState = useTableQueryState<lib.InstanceDetails>({
namespace: props.tableNamespace ?? "instance", defaultSortKey: "name",
namespace: "instance", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.InstanceDetails>(
tableState, "name", instance => instance.name, "Search instances"
@@ -1,6 +1,7 @@
import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Checkbox, Form, Input, Modal, Select, Space, Tooltip, Typography } from "antd";
import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Tooltip, Typography } from "antd";
import type { ColumnType } from "antd/es/table/interface";
import * as lib from "@clusterio/lib";
@@ -13,6 +14,8 @@ import { useHosts } from "../model/host";
import { useInstanceConfig, useInstances } from "../model/instance";
import { useSaves } from "../model/saves";
import InstanceList from "./InstanceList";
import Link from "./Link";
import useRowNavigation from "../util/useRowNavigation";
import { notifyErrorHandler } from "../util/notify";
import type { Control } from "../util/websocket";
@@ -92,6 +95,7 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
initial?: CreateInstanceInitial;
buttonText?: string;
buttonType?: "primary" | "default";
buttonSize?: "small";
}) {
let control = useContext(ControlContext);
let account = useAccount();
@@ -195,6 +199,7 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
return <>
<Button
type={buttonType}
size={props.buttonSize}
onClick={() => {
setOpen(true);
}}
@@ -281,12 +286,59 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
</>;
}
function TemplateList(props: { instances: ReturnType<typeof useInstances>[0] }) {
const account = useAccount();
const rowNav = useRowNavigation();
const all = [...props.instances.values()];
const inheritors = (id: number) => all.filter(instance => instance.templateId === id).length;
const columns: ColumnType<lib.InstanceDetails>[] = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
render: (_, template) => <Link to={`/instances/${template.id}/view`} style={{ color: "inherit" }}>
{template.name}
</Link>,
},
{
title: "Inherited by",
key: "inheritors",
render: (_, template) => `${inheritors(template.id)} instance(s)`,
sorter: (a, b) => inheritors(a.id) - inheritors(b.id),
},
];
if (account.hasPermission("core.instance.create")) {
columns.push({
key: "action",
align: "right",
// Clicks inside the modal bubble through the React tree to the row
render: (_, template) => <span onClick={e => e.stopPropagation()}>
<CreateInstanceButton
instances={props.instances}
initial={{ template: template.id }}
buttonText="Create instance"
buttonType="default"
buttonSize="small"
/>
</span>,
});
}
return <Table
columns={columns}
dataSource={all.filter(instance => instance.isTemplate)}
rowKey={template => template.id}
pagination={false}
onRow={record => rowNav(`/instances/${record.id}/view`)}
/>;
}
export default function InstancesPage() {
let control = useContext(ControlContext);
let account = useAccount();
let [instances] = useInstances();
const plainInstances = new Map([...instances].filter(([, instance]) => !instance.isTemplate));
const templates = new Map([...instances].filter(([, instance]) => instance.isTemplate));
const hasTemplates = plainInstances.size !== instances.size;
return <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
@@ -321,9 +373,9 @@ export default function InstancesPage() {
/>
<InstanceList instances={plainInstances} />
{templates.size > 0 && <>
{hasTemplates && <>
<Typography.Title level={5} style={{ marginTop: 16 }}>Templates</Typography.Title>
<InstanceList instances={templates} hideAssignedHost tableNamespace="template" />
<TemplateList instances={instances} />
</>}
<PluginExtra component="InstancesPage" />
</PageLayout>;
+4 -1
View File
@@ -433,11 +433,14 @@ describe("controller/InstanceManager", function () {
assert.equal(child.config.isInherited("factorio.game_port"), false);
assert.equal(child.config.isInherited("instance.name"), false);
});
it("should never inherit ports", function () {
it("should never inherit ports or the rcon password", function () {
template.config.set("factorio.rcon_password", "secret");
const config = new lib.InstanceConfig("controller", { "instance.id": 12, "instance.template_id": 10 });
instances.applyTemplate(config, ["instance.id", "instance.template_id"]);
assert.equal(config.get("factorio.game_port"), null);
assert.equal(config.isInherited("factorio.game_port"), false);
assert.equal(config.get("factorio.rcon_password"), null);
assert.equal(config.isInherited("factorio.rcon_password"), false);
});
it("should inherit factorio.settings per property with its own server name", function () {
const settings = child.config.get("factorio.settings");
+2 -1
View File
@@ -14,7 +14,8 @@ describe("lib/config/definitions", function() {
for (const name of [
"instance.name", "instance.id", "instance.assigned_host", "instance.is_template",
"instance.template_id", "instance.inherited_fields", "factorio.host_assigned_game_port",
"factorio.game_port", "factorio.rcon_port", "factorio.lua_udp_port", "no.field",
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
"no.field",
]) {
assert.equal(lib.InstanceConfig.isInheritable(name), false, name);
}