mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 23:24:52 +09:00
Merge pull request #780 from Cooldude2606/feature/instance-restart
Add instance restart
This commit is contained in:
17 files changed
+425
-219
No files matched your search
@@ -1038,17 +1038,32 @@ instanceCommands.add(new lib.Command({
|
||||
definition: ["start <instance>", "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 <instance>", "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 <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<typeof this.jsonSchema>) {
|
||||
return new this(json.save);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveDetails {
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
instance.InstanceRestartRequest,
|
||||
instance.InstanceSaveDetailsListRequest,
|
||||
instance.InstanceSaveDetailsUpdatesEvent,
|
||||
instance.InstanceCreateSaveRequest,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: (
|
||||
<Tooltip title={restartRequired ? "Restart Required" : null}>
|
||||
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
|
||||
</Tooltip>
|
||||
),
|
||||
onClick: () => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.controller.stop")) {
|
||||
actions.push({
|
||||
key: "stop",
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
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
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
|
||||
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<keyof typeof lib.levels>("info");
|
||||
@@ -34,43 +86,7 @@ export default function ControllerPage() {
|
||||
<PageHeader
|
||||
title="Controller"
|
||||
extra={<Space>
|
||||
{
|
||||
account.hasPermission("core.controller.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
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"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.controller.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }}/>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
<ControllerControlButton canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
hasNpmButtonPermission(true)
|
||||
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
|
||||
|
||||
@@ -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: (
|
||||
<Tooltip title={restartRequired ? "Restart Required" : null}>
|
||||
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
|
||||
</Tooltip>
|
||||
),
|
||||
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: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
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
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
export default function HostViewPage() {
|
||||
let params = useParams();
|
||||
let hostId = Number(params.id);
|
||||
@@ -50,6 +105,7 @@ export default function HostViewPage() {
|
||||
}
|
||||
|
||||
let hostButtons = <Space>
|
||||
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.host.revoke_token")
|
||||
&& <Popconfirm
|
||||
@@ -68,45 +124,6 @@ export default function HostViewPage() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
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"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting host"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
hasNpmButtonPermission(false)
|
||||
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
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 VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
type InstanceControlButtonProps = Omit<VariableDropdownButtonProps, "actions"> & {
|
||||
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<boolean>(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 actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
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 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
actions.push({
|
||||
key: "kill",
|
||||
danger: true,
|
||||
disabled: instanceStatus === "stopped",
|
||||
label: (<Popconfirm
|
||||
title={<>
|
||||
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
|
||||
</Popconfirm>),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>;
|
||||
}
|
||||
@@ -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) => <StartStopInstanceButton
|
||||
buttonProps={{ size: "small" }}
|
||||
instance={instance}
|
||||
/>,
|
||||
render: (_, instance) => <InstanceControlButton size="small" instance={instance} />,
|
||||
responsive: ["sm"],
|
||||
align: "right",
|
||||
width: 100,
|
||||
|
||||
@@ -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 <Space>
|
||||
{
|
||||
account.hasAnyPermission("core.instance.start", "core.instance.stop")
|
||||
&& <StartStopInstanceButton instance={instance} />
|
||||
account.hasAnyPermission(...InstanceControlButtonPermissions)
|
||||
&& <InstanceControlButton instance={instance} />
|
||||
}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -173,7 +160,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
{account.hasAnyPermission(
|
||||
"core.instance.export_data",
|
||||
"core.instance.extract_players",
|
||||
"core.instance.kill",
|
||||
"core.instance.delete",
|
||||
)
|
||||
&& <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
|
||||
|
||||
@@ -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 <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
title="Instances"
|
||||
extra={<>
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.instance.create") && <CreateInstanceButton instances={instances}/>}
|
||||
{account.hasPermission("core.instance.start")
|
||||
&& <Button onClick={e => instances.forEach(instance => {
|
||||
@@ -108,7 +108,7 @@ export default function InstancesPage() {
|
||||
}>
|
||||
Stop all
|
||||
</Button>}
|
||||
</>}
|
||||
</Space>}
|
||||
/>
|
||||
|
||||
<InstanceList instances={instances} />
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { ButtonProps } from "antd/es/button";
|
||||
|
||||
type StartStopInstanceButtonProps = {
|
||||
instance: lib.InstanceDetails;
|
||||
onFinish?: () => void;
|
||||
buttonProps?: ButtonProps;
|
||||
};
|
||||
export default function StartStopInstanceButton(props: StartStopInstanceButtonProps) {
|
||||
let control = useContext(ControlContext);
|
||||
let [switching, setSwitching] = useState<boolean>(false);
|
||||
|
||||
function onClick(event: React.MouseEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
setSwitching(true);
|
||||
let action;
|
||||
if (props.instance.status === "stopped") {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error starting instance")
|
||||
);
|
||||
|
||||
} else if (["starting", "running"].includes(props.instance.status)) {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStopRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error stopping instance")
|
||||
);
|
||||
|
||||
} else {
|
||||
setSwitching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
action.finally(() => {
|
||||
setSwitching(false);
|
||||
if (props.onFinish) {
|
||||
props.onFinish();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return <Button
|
||||
{...(props.buttonProps || {})}
|
||||
loading={switching}
|
||||
type="primary"
|
||||
disabled={!["starting", "running", "stopped"].includes(props.instance.status)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.instance.status === "stopped" ? "Start" : "Stop"}
|
||||
</Button>;
|
||||
}
|
||||
@@ -382,11 +382,11 @@ export default function UsersPage() {
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
extra={<>
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
|
||||
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
|
||||
? <BulkUserActionButton /> : undefined}
|
||||
</>}
|
||||
</Space>}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
|
||||
@@ -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
|
||||
? <Button {...buttonProps}>
|
||||
{mainAction.label}
|
||||
</Button>
|
||||
: <Dropdown.Button
|
||||
{...buttonProps}
|
||||
// stopPropagation is needed to prevent propagation to table rows
|
||||
menu={{ items: menuActions, onClick: (e: any) => e.domEvent.stopPropagation() }}
|
||||
buttonsRender={([left, right]) => [
|
||||
left, React.cloneElement(right as any, { onClick: (e: any) => e.stopPropagation() }),
|
||||
]}
|
||||
>
|
||||
{mainAction.label}
|
||||
</Dropdown.Button>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user