mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 23:24:52 +09:00
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>
342 lines
11 KiB
TypeScript
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>;
|
|
}
|