Files
factorio-clusterio/packages/web_ui/src/components/InstanceViewPage.tsx
T
bbassieandClaude Opus 5.5 b5c372f109 Rename bases to templates and add per property inheritance
Review changes for #1142:
- base becomes template everywhere (is_template, template_id,
  --template, --is-template, --templates, InstanceDetails.isTemplate).
- Object fields inherit per property. inherited_fields lists the
  overridden properties for object fields and the template object is
  merged with them.
- Linking a template also inherits fields that hold the default value.
  The server name stays per instance.
- Ports are never inherited.
- InstanceDetails carries templateId.

Web UI: inherited values are locked until unlocked, overridden values
can be inherited again, templates are listed separately, the create
modal can create templates and instances from templates, and the
instance page has "Create template" which can link the source.

Part of #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:54:37 +00:00

342 lines
11 KiB
TypeScript

import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tag, Tooltip, 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";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import InstanceConfigTree from "./InstanceConfigTree";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
import LoadScenarioModal from "./LoadScenarioModal";
import SavesList from "./SavesList";
import OnlinePlayersList from "./OnlinePlayersList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance, useInstances } from "../model/instance";
import { CreateInstanceButton } from "./InstancesPage";
import { useHost } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import useLocalStorage from "../util/useLocalStorage";
import useResourceState from "../util/useResourceState";
import { MetricRelativeDate } from "./system_metrics";
type MenuItem = Required<MenuProps>["items"][number];
const { Title } = Typography;
// Remembers the console "Game/Instance" selection across reloads (defaults to game).
const consoleActionsOnlyKey = "instance-console-actions-only";
type InstanceDescriptionProps = {
host?: Readonly<lib.HostDetails>;
instance: Readonly<lib.InstanceDetails>;
};
function TemplateDescription(props: { instance: Readonly<lib.InstanceDetails> }) {
const [instances] = useInstances();
const inheritors = [...instances.values()].filter(i => i.templateId === props.instance.id);
return <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Inherited by">
{inheritors.length
? <Space wrap>
{inheritors.map(i => <Link key={i.id} to={`/instances/${i.id}/view`}>{i.name}</Link>)}
</Space>
: <em>No instances</em>}
</Descriptions.Item>
{props.instance.templateId !== undefined && <Descriptions.Item label="Template">
<TemplateLink id={props.instance.templateId} />
</Descriptions.Item>}
</Descriptions>;
}
function TemplateLink(props: { id: number }) {
const [template] = useInstance(props.id);
return <Link to={`/instances/${props.id}/view`}>{template?.name ?? props.id}</Link>;
}
function InstanceDescription(props: InstanceDescriptionProps) {
let account = useAccount();
const { host, instance } = props;
let assigned = instance.assignedHost !== undefined;
const publicAddress = instancePublicAddress(instance, host);
return <Descriptions
bordered
size="small"
column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}
>
<Descriptions.Item label="Host">
{!assigned
? <em>Unassigned</em>
: <Link to={`/hosts/${host?.id ?? instance.assignedHost}/view`}>
{host?.name ?? instance.assignedHost}
</Link>
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instance.id}
hostId={instance.assignedHost}
buttonProps={{
size: "small",
style: { float: "right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status!),
}}
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>
<Descriptions.Item label="Last Start Time">
<MetricRelativeDate timeMs={instance.startedAtMs}/>
</Descriptions.Item>
{instance.templateId !== undefined && <Descriptions.Item label="Template">
<TemplateLink id={instance.templateId} />
</Descriptions.Item>}
</Descriptions>;
}
function InstanceButtons(props: { instance: lib.InstanceDetails }) {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [exportingData, setExportingData] = useState(false);
let instance = props.instance;
let instanceId = instance.id!;
let [host] = useHost(instance.assignedHost);
const [instances] = useInstances();
const canCreate = account.hasPermission("core.instance.create");
let instanceButtonMenuItems: MenuItem[] = [];
if (account.hasPermission("core.instance.export_data")) {
instanceButtonMenuItems.push({
disabled: exportingData || instance.status !== "stopped",
key: "export",
label: "Export data",
});
}
if (account.hasPermission("core.instance.extract_players")) {
instanceButtonMenuItems.push({
disabled: instance.status !== "running",
key: "extract",
label: "Extract players from save",
});
}
if (account.hasPermission("core.instance.delete")) {
if (instanceButtonMenuItems.length) {
instanceButtonMenuItems.push({ type: "divider" });
}
instanceButtonMenuItems.push({
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status),
danger: true,
key: "delete",
icon: <DeleteOutlined />,
label: "Delete",
});
}
function confirmDelete() {
Modal.confirm({
autoFocusButton: "cancel",
content: "Permamently delete instance and server saves?",
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => {
control.send(
new lib.InstanceDeleteRequest(instanceId)
).then(() => {
navigate("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
},
});
}
let instanceButtonsMenuProps: MenuProps = {
items: instanceButtonMenuItems,
onClick: ({ key }: { key: string }) => {
if (key === "export") {
setExportingData(true);
control.sendTo(
{ instanceId },
new lib.InstanceExportDataRequest(),
).catch(
notifyErrorHandler("Error exporting data")
).finally(() => {
setExportingData(false);
});
} else if (key === "extract") {
control.sendTo(
{ instanceId },
new lib.InstanceExtractPlayersRequest(),
).catch(notifyErrorHandler("Error extracting player data"));
} else if (key === "delete") {
confirmDelete();
}
},
};
if (instance.isTemplate) {
return <Space wrap>
{canCreate && <CreateInstanceButton
instances={instances}
initial={{ template: instanceId }}
buttonText="Create instance from template"
/>}
{account.hasPermission("core.instance.delete") && <Button
danger
icon={<DeleteOutlined />}
onClick={confirmDelete}
>Delete</Button>}
</Space>;
}
return <Space wrap>
{
account.hasAnyPermission(...InstanceControlButtonPermissions)
&& <InstanceControlButton instance={instance} />
}
<Button
onClick={() => {
const address = instancePublicAddress(instance, host);
if (address) {
window.location.href = `steam://run/427520//--mp-connect=${address}`;
}
}}
disabled={instance.status !== "running" || !instancePublicAddress(instance, host)}
>
Connect via Steam
</Button>
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{canCreate && <CreateInstanceButton
instances={instances}
initial={{ instanceClone: instanceId, asTemplate: true, linkSource: true }}
buttonText="Create template"
buttonType="default"
/>}
{account.hasAnyPermission(
"core.instance.export_data",
"core.instance.extract_players",
"core.instance.delete",
)
&& <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
<Button>More <DownOutlined /></Button>
</Dropdown>}
</Space>;
}
export default function InstanceViewPage() {
let params = useParams();
let instanceId = Number(params.id);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("server");
const [actionsOnly, setActionsOnly] = useLocalStorage(consoleActionsOnlyKey, true);
let navigate = useNavigate();
let account = useAccount();
let [instance, synced, error] = useInstance(instanceId);
const state = useResourceState(instance, synced, error, instanceId);
let [host] = useHost(instance?.assignedHost);
let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }];
if (!instance) {
return <ResourceState
state={state} type="Instance" id={instanceId} nav={nav} listPath="/instances" error={error}
/>;
}
return <PageLayout nav={nav}>
<PageHeader
title={instance.name ?? ""}
status={instance.isTemplate
? <Tag color="purple">Template</Tag>
: <InstanceStatusTag status={instance.status} />}
extra={<InstanceButtons instance={instance} />}
/>
{instance.isTemplate
? <TemplateDescription instance={instance} />
: <InstanceDescription host={host} instance={instance} />}
<ResourceNotes resourceType="instance" resourceId={instanceId} />
{!instance.isTemplate && <>
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe")
&& <SavesList instance={instance} />
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
{
account.hasPermission("core.log.follow")
&& <Flex align="center" gap="middle">
<Tooltip
title={
"Game filters to in-game actions and errors: player chat, joins, leaves, etc." +
"Instance is the full unfiltered log including the engine log."
}
>
<Segmented
value={actionsOnly ? "game" : "instance"}
onChange={value => setActionsOnly(value === "game")}
options={[
{ label: "Game", value: "game" },
{ label: "Instance", value: "instance" },
]}
/>
</Tooltip>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["http"]}
/>
</Flex>
}
</Flex>
}
{
account.hasPermission("core.log.follow")
&& <LogConsole instances={[instanceId]} maxLevel={maxLevel} actionsOnly={actionsOnly}/>
}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance.status !== "running"} />
}
</>}
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
<PluginExtra component="InstanceViewPage" instance={instance} />
</PageLayout>;
}