From 2baa2e50acc4cea045ae448ec1aa809d1b521011 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:18:57 +0100 Subject: [PATCH 1/8] Add InstanceRestartRequest --- packages/host/src/Instance.ts | 9 +++++++++ packages/lib/src/data/messages_instance.ts | 20 ++++++++++++++++++++ packages/lib/src/link/messages.ts | 1 + packages/lib/src/permissions.ts | 5 +++++ 4 files changed, 35 insertions(+) diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 48f675a3..0a47c0b2 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -289,6 +289,7 @@ export default class Instance extends lib.Link { this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this)); this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this)); this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this)); + this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this)); this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this)); this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this)); this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this)); @@ -1300,6 +1301,14 @@ end`.replace(/\r?\n/g, " "); } } + async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) { + await this.stop(); + // Stopping invalidates this instance object, a new one must be created by the host + // To do this we simulate a waking an instance and sending a start request + const instanceConnection = await this._host._connectInstance(this.id); + await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save)); + } + async handleInstanceStopRequest() { await this.stop(); } diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 63643343..19a34eba 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -284,6 +284,26 @@ export class InstanceStartRequest { } } +export class InstanceRestartRequest { + declare ["constructor"]: typeof InstanceRestartRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "instance" as const; + static permission = "core.instance.restart" as const; + + constructor( + public save?: string, + ) { } + + static jsonSchema = Type.Object({ + "save": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.save); + } +} + export class SaveDetails { constructor( public instanceId: number, diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 3fd30de6..65505bac 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -63,6 +63,7 @@ export const dataClasses: (RequestClass | EventClass) instance.InstanceAssignRequest, instance.InstanceMetricsRequest, instance.InstanceStartRequest, + instance.InstanceRestartRequest, instance.InstanceSaveDetailsListRequest, instance.InstanceSaveDetailsUpdatesEvent, instance.InstanceCreateSaveRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 23ac4b31..fb7395b9 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -307,6 +307,11 @@ definePermission({ title: "Start instance", description: "Start instances.", }); +definePermission({ + name: "core.instance.restart", + title: "Restart instance", + description: "Restart instances.", +}); definePermission({ name: "core.instance.load_scenario", title: "Load scenario", From e0278d03310becfe845e9b73606e21982a2f7157 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:34:41 +0100 Subject: [PATCH 2/8] Add instance restart button --- .../src/components/InstanceControlButton.tsx | 127 ++++++++++++++++++ .../web_ui/src/components/InstanceList.tsx | 9 +- .../src/components/InstanceViewPage.tsx | 20 +-- .../components/StartStopInstanceButton.tsx | 61 --------- packages/web_ui/src/index.ts | 2 +- 5 files changed, 134 insertions(+), 85 deletions(-) create mode 100644 packages/web_ui/src/components/InstanceControlButton.tsx delete mode 100644 packages/web_ui/src/components/StartStopInstanceButton.tsx diff --git a/packages/web_ui/src/components/InstanceControlButton.tsx b/packages/web_ui/src/components/InstanceControlButton.tsx new file mode 100644 index 00000000..dcc401b9 --- /dev/null +++ b/packages/web_ui/src/components/InstanceControlButton.tsx @@ -0,0 +1,127 @@ +import React, { useContext, useState } from "react"; +import { Button, MenuProps } from "antd"; + +import * as lib from "@clusterio/lib"; + +import ControlContext from "./ControlContext"; +import { useAccount } from "../model/account"; +import { notifyErrorHandler } from "../util/notify"; +import Dropdown, { DropdownButtonProps } from "antd/es/dropdown"; + +type InstanceControlButtonProps = DropdownButtonProps & { + instance: lib.InstanceDetails; + onFinish?: () => void; +}; + +export const InstanceControlButtonPermissions = [ + "core.instance.start", "core.instance.restart", "core.instance.stop", "core.instance.kill", +] as const; + +export default function InstanceControlButton(props: InstanceControlButtonProps) { + const account = useAccount(); + const control = useContext(ControlContext); + const [switching, setSwitching] = useState(false); + + const instanceId = props.instance.id; + const instanceStatus = props.instance.status; + const showStart = ["stopped", "stopping"].includes(instanceStatus); + const showStop = ["running", "starting"].includes(instanceStatus); + + function actionFinish() { + setSwitching(false); + if (props.onFinish) { + props.onFinish(); + } + } + + const onStopStartClick: DropdownButtonProps["onClick"] = (event) => { + event.stopPropagation(); + setSwitching(true); + + if (showStart) { + control.sendTo( + { instanceId }, + new lib.InstanceStartRequest(undefined), + ).catch( + notifyErrorHandler("Error starting instance") + ).finally(actionFinish); + + } else { + control.sendTo( + { instanceId }, + new lib.InstanceStopRequest(), + ).catch( + notifyErrorHandler("Error stopping instance") + ).finally(actionFinish); + + } + }; + + const onMenuClick: MenuProps["onClick"] = (event) => { + event.domEvent.stopPropagation(); + setSwitching(true); + + switch (event.key) { + case "restart": + control.sendTo( + { instanceId }, + new lib.InstanceRestartRequest(undefined), + ).catch( + notifyErrorHandler("Error restarting instance") + ).finally(actionFinish); + break; + + case "kill": + control.sendTo( + { instanceId }, + new lib.InstanceKillRequest(), + ).catch( + notifyErrorHandler("Error killing instance") + ).finally(actionFinish); + break; + + default: + notifyErrorHandler("Unknown control action"); + } + }; + + const extraControls: MenuProps["items"] = []; + if (account.hasPermission("core.instance.restart")) { + extraControls.push({ + key: "restart", + label: "Restart", + disabled: instanceStatus !== "running", + }); + } + if (account.hasPermission("core.instance.kill")) { + extraControls.push({ + key: "kill", + label: "Kill", + disabled: instanceStatus === "stopped", + }); + } + + const buttonProps: DropdownButtonProps = { + ...props, + type: "primary", + loading: switching, + onClick: onStopStartClick, + disabled: !(showStart || showStop), + danger: showStop !== switching, // Used as an XOR, also below + }; + + return extraControls.length > 0 + ? [ // This is needed to prevent propagation to the table row + left, React.cloneElement(right as any, { onClick: (e: any) => e.stopPropagation() }), + ]} + > + {showStart !== switching ? "Start" : "Stop"} + + : ; + +} diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 3c3a67ab..fe299f00 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -8,7 +8,7 @@ import type { ColumnsType } from "antd/es/table"; import { useAccount } from "../model/account"; import { useHosts } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; -import StartStopInstanceButton from "./StartStopInstanceButton"; +import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton"; import * as lib from "@clusterio/lib"; import Link from "./Link"; import { instancePublicAddress } from "../util/instance"; @@ -98,13 +98,10 @@ export default function InstanceList(props: InstanceListProps) { }, ]; - if (account.hasAnyPermission("core.instance.start", "core.instance.stop")) { + if (account.hasAnyPermission(...InstanceControlButtonPermissions)) { columns.push({ key: "action", - render: (_, instance) => , + render: (_, instance) => , responsive: ["sm"], align: "right", width: 100, diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index b2ad9d8d..cf1d76dc 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -15,7 +15,7 @@ import InstanceConfigTree from "./InstanceConfigTree"; import LogConsole, { SelectMaxLogLevel } from "./LogConsole"; import InstanceRcon from "./InstanceRcon"; import AssignInstanceModal from "./AssignInstanceModal"; -import StartStopInstanceButton from "./StartStopInstanceButton"; +import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton"; import LoadScenarioModal from "./LoadScenarioModal"; import SavesList from "./SavesList"; import { notifyErrorHandler } from "../util/notify"; @@ -91,13 +91,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { label: "Extract players from save", }); } - if (account.hasPermission("core.instance.kill")) { - instanceButtonMenuItems.push({ - disabled: ["unknown", "unassigned", "stopped"].includes(instance.status!), - key: "kill", - label: "Kill process", - }); - } if (account.hasPermission("core.instance.delete")) { if (instanceButtonMenuItems.length) { instanceButtonMenuItems.push({ type: "divider" }); @@ -130,12 +123,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { new lib.InstanceExtractPlayersRequest(), ).catch(notifyErrorHandler("Error extracting player data")); - } else if (key === "kill") { - control.sendTo( - { instanceId }, - new lib.InstanceKillRequest(), - ).catch(notifyErrorHandler("Error killing instance")); - } else if (key === "delete") { Modal.confirm({ autoFocusButton: "cancel", @@ -155,8 +142,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { }; return { - account.hasAnyPermission("core.instance.start", "core.instance.stop") - && + account.hasAnyPermission(...InstanceControlButtonPermissions) + && } ; -} diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 2053560a..748e201f 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -22,7 +22,7 @@ export { default as AssignInstanceModal } from "./components/AssignInstanceModal export { default as InstanceList } from "./components/InstanceList"; export { default as InstanceRcon } from "./components/InstanceRcon"; export { default as InstanceStatusTag, statusColors } from "./components/InstanceStatusTag"; -export { default as StartStopInstanceButton } from "./components/StartStopInstanceButton"; +export { default as InstanceControlButton, InstanceControlButtonPermissions } from "./components/InstanceControlButton"; export { default as PageHeader } from "./components/PageHeader"; export { default as PageLayout } from "./components/PageLayout"; From f11d91bd6be87c1bf7bec3586ea1c6fb13fb8ae5 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:38:06 +0100 Subject: [PATCH 3/8] Add missing space to instances and users list --- packages/web_ui/src/components/InstancesPage.tsx | 6 +++--- packages/web_ui/src/components/UsersPage.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index c15243c5..8a7968e8 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -1,6 +1,6 @@ import React, { useContext, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Form, Input, Modal, Select, Tooltip } from "antd"; +import { Button, Form, Input, Modal, Select, Space, Tooltip } from "antd"; import * as lib from "@clusterio/lib"; @@ -82,7 +82,7 @@ export default function InstancesPage() { return + extra={ {account.hasPermission("core.instance.create") && } {account.hasPermission("core.instance.start") && } - } + } /> diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 40fafc7d..6f77f409 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -382,11 +382,11 @@ export default function UsersPage() { return + extra={ {account.hasPermission("core.user.create") ? : undefined} {account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export") ? : undefined} - } + } /> Date: Tue, 15 Jul 2025 00:46:28 +0100 Subject: [PATCH 4/8] Make the control buttons consistent --- .../web_ui/src/components/ControllerPage.tsx | 96 +++++++----- .../web_ui/src/components/HostViewPage.tsx | 95 ++++++----- .../src/components/InstanceControlButton.tsx | 147 +++++++++--------- .../src/components/VariableDropdownButton.tsx | 43 +++++ 4 files changed, 226 insertions(+), 155 deletions(-) create mode 100644 packages/web_ui/src/components/VariableDropdownButton.tsx diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 97a2aa43..9e241bb5 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -1,5 +1,5 @@ import React, {useContext, useState} from "react"; -import { Button, Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd"; +import { Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -19,13 +19,65 @@ import PageLayout from "./PageLayout"; import { notifyErrorHandler } from "../util/notify"; import webUiPackage from "../../package.json"; import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; +import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; const { Title } = Typography; +type ControllerControlButtonProps = { canRestart?: boolean, restartRequired?: boolean }; + +function ControllerControlButton({ canRestart, restartRequired }: ControllerControlButtonProps) { + const account = useAccount(); + const control = useContext(ControlContext); + const actions: VariableDropdownButtonProps["actions"] = []; + + if (account.hasPermission("core.controller.restart") && canRestart) { + actions.push({ + key: "restart", + label: ( + + Restart {restartRequired ? : undefined} + + ), + onClick: () => { + control.send( + new lib.ControllerRestartRequest() + ).catch(notifyErrorHandler("Error restarting controller")); + }, + }); + } + + if (account.hasPermission("core.controller.stop")) { + actions.push({ + key: "stop", + danger: true, + label: ( + + Stopping the controller will leave the cluster inoperable until someone with + access to the system it runs on manually starts it again.
+ Are you sure you want to stop the controller? + } + placement="bottomRight" + okText="Stop" + okButtonProps={{ danger: true }} + onConfirm={() => { + control.send( + new lib.ControllerStopRequest() + ).catch(notifyErrorHandler("Error stopping controller")); + }} + > + Stop +
+ ), + }); + } + + return ; +} + export default function ControllerPage() { - let account = useAccount(); - const control = useContext(ControlContext); + const account = useAccount(); const [systems] = useSystems(); const system = systems.get("controller"); const [maxLevel, setMaxLevel] = useState("info"); @@ -34,43 +86,7 @@ export default function ControllerPage() { - { - account.hasPermission("core.controller.stop") - && - Stopping the controller will leave the cluster inoperable until someone with - access to the system it runs on manually starts it again.
- Are you sure you want to stop the controller? - } - placement="bottomRight" - okText="Stop" - okButtonProps={{ danger: true }} - onConfirm={() => { - control.send( - new lib.ControllerStopRequest() - ).catch(notifyErrorHandler("Error stopping controller")); - }} - > - -
- } - { - account.hasPermission("core.controller.restart") - && - - - } + { hasNpmButtonPermission(true) && diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index bd689ee4..93374cfa 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -22,9 +22,64 @@ import { import { formatTimestamp } from "../util/time_format"; import { useSystems } from "../model/system"; import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; +import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; const { Title } = Typography; +type HostControlButtonProps = { hostId: number, canRestart?: boolean, restartRequired?: boolean }; + +function HostControlButton({ hostId, canRestart, restartRequired }: HostControlButtonProps) { + const account = useAccount(); + const control = useContext(ControlContext); + const actions: VariableDropdownButtonProps["actions"] = []; + + if (account.hasPermission("core.host.restart") && canRestart) { + actions.push({ + key: "restart", + label: ( + + Restart {restartRequired ? : undefined} + + ), + onClick: () => { + control.sendTo( + { hostId }, + new lib.HostRestartRequest() + ).catch(notifyErrorHandler("Error restarting host")); + }, + }); + } + + if (account.hasPermission("core.host.stop")) { + actions.push({ + key: "stop", + danger: true, + label: ( + + Stopping this host will leave it inaccessible until someone with + access to the system it runs on manually starts it again.
+ Are you sure you want to stop the host? + } + placement="bottomRight" + okText="Stop" + okButtonProps={{ danger: true }} + onConfirm={() => { + control.sendTo( + { hostId }, + new lib.HostStopRequest() + ).catch(notifyErrorHandler("Error stopping host")); + }} + > + Stop +
+ ), + }); + } + + return ; +} + export default function HostViewPage() { let params = useParams(); let hostId = Number(params.id); @@ -50,6 +105,7 @@ export default function HostViewPage() { } let hostButtons = + { account.hasPermission("core.host.revoke_token") && } - { - account.hasPermission("core.host.stop") - && - Stopping this host will leave it inaccessible until someone with - access to the system it runs on manually starts it again.
- Are you sure you want to stop the host? - } - placement="bottomRight" - okText="Stop" - okButtonProps={{ danger: true }} - onConfirm={() => { - control.sendTo( - { hostId }, - new lib.HostStopRequest() - ).catch(notifyErrorHandler("Error stopping host")); - }} - > - -
- } - { - account.hasPermission("core.host.restart") - && - - - } { hasNpmButtonPermission(false) && diff --git a/packages/web_ui/src/components/InstanceControlButton.tsx b/packages/web_ui/src/components/InstanceControlButton.tsx index dcc401b9..2f1636e3 100644 --- a/packages/web_ui/src/components/InstanceControlButton.tsx +++ b/packages/web_ui/src/components/InstanceControlButton.tsx @@ -1,14 +1,14 @@ import React, { useContext, useState } from "react"; -import { Button, MenuProps } from "antd"; +import { Popconfirm } from "antd"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { useAccount } from "../model/account"; import { notifyErrorHandler } from "../util/notify"; -import Dropdown, { DropdownButtonProps } from "antd/es/dropdown"; +import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; -type InstanceControlButtonProps = DropdownButtonProps & { +type InstanceControlButtonProps = Omit & { instance: lib.InstanceDetails; onFinish?: () => void; }; @@ -34,94 +34,89 @@ export default function InstanceControlButton(props: InstanceControlButtonProps) } } - const onStopStartClick: DropdownButtonProps["onClick"] = (event) => { - event.stopPropagation(); - setSwitching(true); + const actions: VariableDropdownButtonProps["actions"] = []; - if (showStart) { - control.sendTo( - { instanceId }, - new lib.InstanceStartRequest(undefined), - ).catch( - notifyErrorHandler("Error starting instance") - ).finally(actionFinish); + if (account.hasPermission("core.instance.start") && showStart !== switching) { + actions.push({ + key: "start", + label: "Start", + onClick: () => { + setSwitching(true); + control.sendTo( + { instanceId }, + new lib.InstanceStartRequest(undefined), + ).catch( + notifyErrorHandler("Error starting instance") + ).finally(actionFinish); + }, + }); - } else { - control.sendTo( - { instanceId }, - new lib.InstanceStopRequest(), - ).catch( - notifyErrorHandler("Error stopping instance") - ).finally(actionFinish); + } else if (account.hasPermission("core.instance.stop")) { // else if because it is stop or start + actions.push({ + key: "stop", + label: "Stop", + danger: true, + onClick: () => { + setSwitching(true); + control.sendTo( + { instanceId }, + new lib.InstanceStopRequest(), + ).catch( + notifyErrorHandler("Error stopping instance") + ).finally(actionFinish); + }, + }); + } - } - }; - - const onMenuClick: MenuProps["onClick"] = (event) => { - event.domEvent.stopPropagation(); - setSwitching(true); - - switch (event.key) { - case "restart": + if (account.hasPermission("core.instance.restart")) { + actions.push({ + key: "restart", + label: "Restart", + disabled: instanceStatus !== "running", + onClick: () => { + setSwitching(true); control.sendTo( { instanceId }, new lib.InstanceRestartRequest(undefined), ).catch( notifyErrorHandler("Error restarting instance") ).finally(actionFinish); - break; - - case "kill": - control.sendTo( - { instanceId }, - new lib.InstanceKillRequest(), - ).catch( - notifyErrorHandler("Error killing instance") - ).finally(actionFinish); - break; - - default: - notifyErrorHandler("Unknown control action"); - } - }; - - const extraControls: MenuProps["items"] = []; - if (account.hasPermission("core.instance.restart")) { - extraControls.push({ - key: "restart", - label: "Restart", - disabled: instanceStatus !== "running", + }, }); } + if (account.hasPermission("core.instance.kill")) { - extraControls.push({ + actions.push({ key: "kill", - label: "Kill", + danger: true, disabled: instanceStatus === "stopped", + label: ( + Killing this instance may leave you save and player data corrupted. + Are you sure you want to kill the instance? + } + placement="bottomRight" + okText="Kill" + okButtonProps={{ danger: true }} + onConfirm={() => { + control.sendTo( + { instanceId }, + new lib.InstanceKillRequest(), + ).catch( + notifyErrorHandler("Error killing instance") + ).finally(actionFinish); + }} + > + Kill + ), }); } - const buttonProps: DropdownButtonProps = { - ...props, - type: "primary", - loading: switching, - onClick: onStopStartClick, - disabled: !(showStart || showStop), - danger: showStop !== switching, // Used as an XOR, also below - }; - - return extraControls.length > 0 - ? [ // This is needed to prevent propagation to the table row - left, React.cloneElement(right as any, { onClick: (e: any) => e.stopPropagation() }), - ]} - > - {showStart !== switching ? "Start" : "Stop"} - - : ; - + return ; } diff --git a/packages/web_ui/src/components/VariableDropdownButton.tsx b/packages/web_ui/src/components/VariableDropdownButton.tsx new file mode 100644 index 00000000..c7078784 --- /dev/null +++ b/packages/web_ui/src/components/VariableDropdownButton.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Button, Dropdown } from "antd"; + +import { MenuItemType } from "antd/es/menu/interface"; +import { DropdownButtonProps } from "antd/es/dropdown"; + +export type VariableDropdownButtonProps = DropdownButtonProps & { + actions: (MenuItemType & { onClick?: () => void })[], +}; + +export default function VariableDropdownButton(props: VariableDropdownButtonProps) { + const menuActions = props.actions; + const mainAction = menuActions.shift(); + if (!mainAction) { + return <>; + } + + const buttonProps: DropdownButtonProps = { + ...props, + danger: mainAction.danger, + onClick: (event) => { + event.stopPropagation(); + if (mainAction.onClick) { + mainAction.onClick(); + } + }, + }; + + return menuActions.length === 0 + ? + : e.domEvent.stopPropagation() }} + buttonsRender={([left, right]) => [ + left, React.cloneElement(right as any, { onClick: (e: any) => e.stopPropagation() }), + ]} + > + {mainAction.label} + ; +} From 449bd68fab3e67b414a8ed606d7d65b6e6579674 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 16 Jul 2025 00:06:44 +0100 Subject: [PATCH 5/8] Add instance restart to ctl --- packages/ctl/src/commands.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 5da90701..3255b881 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1038,17 +1038,32 @@ instanceCommands.add(new lib.Command({ definition: ["start ", "Start instance", (yargs) => { yargs.positional("instance", { describe: "Instance to start", type: "string" }); yargs.options({ - "save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" }, + "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, }); }], handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); + const instanceId = await lib.resolveInstance(control, args.instance); await control.setLogSubscriptions({ instanceIds: [instanceId] }); await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save)); control.keepOpen = args.keepOpen; }, })); +instanceCommands.add(new lib.Command({ + definition: ["restart ", "Restart instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to restart", type: "string" }); + yargs.options({ + "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { + const instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save)); + control.keepOpen = args.keepOpen; + }, +})); instanceCommands.add(new lib.Command({ definition: ["load-scenario ", "Start instance by loading a scenario", (yargs) => { From 88ca4563a83e511b5af405373c25cae5dc25cd71 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:25:27 +0100 Subject: [PATCH 6/8] Add tests --- packages/host/src/Instance.ts | 3 + test/integration/clusterio.js | 130 ++++++++++++++++++++++------------ test/messages/host.test.js | 2 +- 3 files changed, 87 insertions(+), 48 deletions(-) diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 0a47c0b2..cd9985d2 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -1302,6 +1302,9 @@ end`.replace(/\r?\n/g, " "); } async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) { + if (this._status !== "running") { + throw new Error("Instance is not running"); + } await this.stop(); // Stopping invalidates this instance object, a new one must be created by the host // To do this we simulate a waking an instance and sending a start request diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 1b100bc7..ce6b8099 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -529,56 +529,92 @@ describe("Integration of Clusterio", function() { }); }); - describe("instance start", function() { - it("should not hang if factorio version does not exist", async function() { - slowTest(this); - try { - await execCtl("instance config set 44 factorio.version 0.1.2"); - await assert.rejects(execCtl("instance start test")); + for (let cmd of ["start", "restart"]) { + describe(`instance ${cmd}`, function() { + async function prepareToStart() { + if (cmd === "restart") { + await execCtl("instance start test"); + } + } + after(async function() { + // It is expected that after these tests the instance is running, I dislike this dependency + // So this is here to make sure the other tests can continue any of these ones fail + const instances = await getInstances(); + if (instances.get(44).status !== "running") { + await execCtl("instance start test"); + } + }); + it("should not hang if factorio version does not exist", async function() { + slowTest(this); + try { + await execCtl("instance config set 44 factorio.version 0.1.2"); + await assert.rejects(execCtl(`instance ${cmd} test`)); - } finally { - await execCtl("instance config set 44 factorio.version latest"); + } finally { + await execCtl("instance config set 44 factorio.version latest"); + } + }); + it("should not leave the instance in the stopping state if it fails", async function() { + slowTest(this); + try { + await execCtl("instance config set 44 factorio.game_port 100000"); + await assert.rejects(execCtl(`instance ${cmd} test`)); + await checkInstanceStatus(44, "stopped"); + + } finally { + await execCtl("instance config set 44 factorio.game_port"); + } + }); + it("starts the given save", async function() { + slowTest(this); + await prepareToStart(); + await execCtl(`instance ${cmd} test --save world.zip`); + await checkInstanceStatus(44, "running"); + }); + it("allows having a separate console log", async function() { + slowTest(this); + await execCtl("instance stop 44"); + await execCtl("instance config set 44 factorio.console_logging true"); + await prepareToStart(); + await execCtl(`instance ${cmd} test`); + await checkInstanceStatus(44, "running"); + const checkMessage = `check${Date.now()}`; + await sendRcon(44, checkMessage); + const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log")); + assert(consoleLog.includes(checkMessage)); + await execCtl("instance config set 44 factorio.console_logging false"); + }); + it("copies the save if an autosave is the target", async function() { + slowTest(this); + await execCtl("instance stop 44"); + let savesDir = path.join("temp", "test", "instances", "test", "saves"); + await fs.copy(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip")); + await prepareToStart(); + await execCtl(`instance ${cmd} test`); + let saves = await getControl().sendTo({ instanceId: 44 }, new lib.InstanceSaveDetailsListRequest()); + let running = saves.find(s => s.loaded); + assert(running.name !== "_autosave1.zip"); + }); + if (cmd === "restart") { + it("fails if the server is not running", async function() { + await execCtl("instance stop test"); + await checkInstanceStatus(44, "stopped"); + await assert.rejects( + execCtl("instance restart test"), + /Instance is not running/, + ); + await execCtl("instance start test"); + }); + } else { + it("fails if the server is running", async function() { + await assert.rejects( + execCtl("instance start test"), + /Instance is already running./, + ); + }); } }); - it("should not leave the instance in the stopping state if it fails", async function() { - slowTest(this); - try { - await execCtl("instance config set 44 factorio.game_port 100000"); - await assert.rejects(execCtl("instance start test")); - await checkInstanceStatus(44, "stopped"); - - } finally { - await execCtl("instance config set 44 factorio.game_port"); - } - }); - it("starts the given save", async function() { - slowTest(this); - await execCtl("instance start test --save world.zip"); - await checkInstanceStatus(44, "running"); - }); - it("allows having a separate console log", async function() { - slowTest(this); - await execCtl("instance stop 44"); - await execCtl("instance config set 44 factorio.console_logging true"); - await execCtl("instance start test"); - await checkInstanceStatus(44, "running"); - const checkMessage = `check${Date.now()}`; - await sendRcon(44, checkMessage); - const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log")); - assert(consoleLog.includes(checkMessage)); - await execCtl("instance config set 44 factorio.console_logging false"); - }); - it("copies the save if an autosave is the target", async function() { - slowTest(this); - await execCtl("instance stop 44"); - let savesDir = path.join("temp", "test", "instances", "test", "saves"); - await fs.copy(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip")); - await execCtl("instance start test"); - let saves = await getControl().sendTo({ instanceId: 44 }, new lib.InstanceSaveDetailsListRequest()); - let running = saves.find(s => s.loaded); - assert(running.name !== "_autosave1.zip"); - }); - }); + } describe("instance send-rcon", function() { this.afterAll(async function() { diff --git a/test/messages/host.test.js b/test/messages/host.test.js index 2de73067..faf3093d 100644 --- a/test/messages/host.test.js +++ b/test/messages/host.test.js @@ -4,7 +4,7 @@ const lib = require("@clusterio/lib"); const { Host } = require("@clusterio/host"); -describe("messages/controller", function() { +describe("messages/host", function() { /** @type {Host} */ let host; From a464b61d39043d319b8e4cbb80349fd8e2ccc77f Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:41:23 +0100 Subject: [PATCH 7/8] Remove redundant status check --- packages/host/src/Instance.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index cd9985d2..0a47c0b2 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -1302,9 +1302,6 @@ end`.replace(/\r?\n/g, " "); } async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) { - if (this._status !== "running") { - throw new Error("Instance is not running"); - } await this.stop(); // Stopping invalidates this instance object, a new one must be created by the host // To do this we simulate a waking an instance and sending a start request From 28f21bfb70782ef67f53327cdbffe8129efeb3a2 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:16:29 +0100 Subject: [PATCH 8/8] Fix mutating actions prop --- packages/web_ui/src/components/VariableDropdownButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web_ui/src/components/VariableDropdownButton.tsx b/packages/web_ui/src/components/VariableDropdownButton.tsx index c7078784..3f5f4fb5 100644 --- a/packages/web_ui/src/components/VariableDropdownButton.tsx +++ b/packages/web_ui/src/components/VariableDropdownButton.tsx @@ -9,7 +9,7 @@ export type VariableDropdownButtonProps = DropdownButtonProps & { }; export default function VariableDropdownButton(props: VariableDropdownButtonProps) { - const menuActions = props.actions; + const menuActions = [...props.actions]; const mainAction = menuActions.shift(); if (!mainAction) { return <>;