Files
factorio-clusterio/packages/web_ui/src/components/InstanceViewPage.tsx
T
Danielv123 2e1710d3a9 Show the same page for missing, deleted and unloadable resources
Every view page handled a resource that can't be shown its own way, and
users without the subscribe permission were left on a spinner forever.

Adds useResourceState and a ResourceState component, used by the host,
instance, mod pack, icon reference, user, role and plugin pages and
exported for plugin pages. The single item hooks now also return the
subscription error.

Resolves #1165
2026-11-01 17:40:22 +01:00

284 lines
9.1 KiB
TypeScript

import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, 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 } from "../model/instance";
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 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>
</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);
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",
});
}
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") {
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"));
},
});
}
},
};
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} />}
{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={<InstanceStatusTag status={instance.status} />}
extra={<InstanceButtons instance={instance} />}
/>
<InstanceDescription host={host} instance={instance} />
<ResourceNotes resourceType="instance" resourceId={instanceId} />
{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>;
}