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