From bca681b5ea350e483e1a31d5acaeb93be7ea74dd Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:52:03 +0000 Subject: [PATCH 1/2] Add a request to mark hosts and the controller as needing a restart Not every plugin arrives through PluginInstallRequest. Plugins checked out into external_plugins or installed by hand on the machine never flag a restart, so the web UI shows nothing and tooling that acts on the flag never fires. HostMarkRestartRequiredRequest and ControllerMarkRestartRequiredRequest set config.restartRequired, guarded by the existing restart permissions. They are exposed as "Mark Restart Required" in the control dropdown of the host and controller pages, hidden once the flag is set, and as mark-restart-required ctl commands. The mark shows up on the next system metrics poll. Co-Authored-By: Claude Fable 5.1 --- docs/managing-a-cluster.md | 8 ++++++++ packages/controller/src/ControlConnection.ts | 7 +++++++ packages/ctl/src/commands_controller.ts | 6 ++++++ packages/ctl/src/commands_host.ts | 10 ++++++++++ packages/host/src/Host.ts | 5 +++++ packages/lib/src/data/messages_controller.ts | 8 ++++++++ packages/lib/src/data/messages_host.ts | 8 ++++++++ packages/lib/src/link/messages.ts | 2 ++ .../web_ui/src/components/ControllerPage.tsx | 16 +++++++++++++++- packages/web_ui/src/components/HostViewPage.tsx | 15 +++++++++++++++ test/messages/controller.test.js | 9 +++++++++ test/messages/host.test.js | 7 +++++++ 12 files changed, 100 insertions(+), 1 deletion(-) diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 54dc1ce8..06483749 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -43,6 +43,14 @@ The controller also supports remote updates, following the same logic. The corre ctl> controller update [--restart] controller> config set controller.allow_remote_updates true/false +### Mark restart required + + ctl> host mark-restart-required + +Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command: + + ctl> controller mark-restart-required + ## Instances ### List Instances diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 091b7d01..f715bf1f 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -73,6 +73,9 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this)); this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this)); + this.handle( + lib.ControllerMarkRestartRequiredRequest, this.handleControllerMarkRestartRequiredRequest.bind(this) + ); this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this)); this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this)); this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this)); @@ -1297,6 +1300,10 @@ export default class ControlConnection extends BaseConnection { return usersToSend; } + async handleControllerMarkRestartRequiredRequest() { + this._controller.config.restartRequired = true; + } + async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) { if (!this._controller.config.get("controller.allow_remote_updates")) { throw new lib.RequestError("Remote updates are disabled on this machine"); diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts index 266fadb9..ad4a31cf 100644 --- a/packages/ctl/src/commands_controller.ts +++ b/packages/ctl/src/commands_controller.ts @@ -25,6 +25,12 @@ controllerCommands.add(new lib.Command({ await ctl.send(new lib.ControllerRestartRequest()); }, })); +controllerCommands.add(new lib.Command({ + definition: ["mark-restart-required", "Mark the controller as requiring a restart"], + handler: async function(_args: object, ctl: Ctl) { + await ctl.send(new lib.ControllerMarkRestartRequiredRequest()); + }, +})); controllerCommands.add(new lib.Command({ definition: ["update", "Update the controller", (yargs) => { yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index 8661dbe2..1f343181 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -33,6 +33,16 @@ hostCommands.add(new lib.Command({ }, })); +hostCommands.add(new lib.Command({ + definition: ["mark-restart-required ", "Mark the given host as requiring a restart", (yargs) => { + yargs.positional("host", { describe: "Host to mark", type: "string" }); + }], + handler: async function(args: { host: string }, ctl: Ctl) { + let hostId = await lib.resolveHost(ctl, args.host); + await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest()); + }, +})); + hostCommands.add(new lib.Command({ definition: [["list", "l"], "List hosts connected to the controller"], handler: async function(args: object, ctl: Ctl) { diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index a586a982..af99c2ca 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -390,6 +390,7 @@ export default class Host extends lib.Link { this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this)); this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this)); + this.handle(lib.HostMarkRestartRequiredRequest, this.handleHostMarkRestartRequiredRequest.bind(this)); this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this)); this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this)); this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this)); @@ -511,6 +512,10 @@ export default class Host extends lib.Link { this.shutdown(); } + async handleHostMarkRestartRequiredRequest() { + this.config.restartRequired = true; + } + async handleHostConfigGetRequest() { return this.config.toRemote("control"); } diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 25ca7789..732ff875 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -20,6 +20,14 @@ export class ControllerRestartRequest { static permission = "core.controller.restart" as const; } +export class ControllerMarkRestartRequiredRequest { + declare ["constructor"]: typeof ControllerMarkRestartRequiredRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.controller.restart" as const; +} + export class ControllerUpdateRequest { declare ["constructor"]: typeof ControllerUpdateRequest; static type = "request" as const; diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index aa4edbc9..41e8e107 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -20,6 +20,14 @@ export class HostRestartRequest { static permission = "core.host.restart" as const; } +export class HostMarkRestartRequiredRequest { + declare ["constructor"]: typeof HostMarkRestartRequiredRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "host" as const; + static permission = "core.host.restart" as const; +} + export class HostUpdateRequest { declare ["constructor"]: typeof HostUpdateRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index b3c856d5..448e4780 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -21,6 +21,7 @@ export const dataClasses: (RequestClass | EventClass) controller.ControllerStopRequest, controller.ControllerRestartRequest, + controller.ControllerMarkRestartRequiredRequest, controller.ControllerUpdateRequest, controller.ControllerConfigGetRequest, controller.ControllerConfigSetRequest, @@ -43,6 +44,7 @@ export const dataClasses: (RequestClass | EventClass) host.HostStopRequest, host.HostRestartRequest, + host.HostMarkRestartRequiredRequest, host.HostUpdateRequest, host.HostConfigGetRequest, host.HostConfigSetRequest, diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 1c74cbc9..a6ffabe0 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -16,7 +16,7 @@ import ControlContext from "./ControlContext"; import ControllerConfigTree from "./ControllerConfigTree"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; -import { notifyErrorHandler } from "../util/notify"; +import notify, { notifyErrorHandler } from "../util/notify"; import webUiPackage from "../../package.json"; import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; @@ -47,6 +47,20 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont }); } + if (account.hasPermission("core.controller.restart") && !restartRequired) { + actions.push({ + key: "mark-restart-required", + label: "Mark Restart Required", + onClick: () => { + control.send( + new lib.ControllerMarkRestartRequiredRequest() + ).then(() => { + notify("Controller marked as requiring a restart", "success"); + }).catch(notifyErrorHandler("Error marking controller")); + }, + }); + } + if (account.hasPermission("core.controller.stop")) { actions.push({ key: "stop", diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index c55eddd8..4d230ade 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -52,6 +52,21 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB }); } + if (account.hasPermission("core.host.restart") && !restartRequired) { + actions.push({ + key: "mark-restart-required", + label: "Mark Restart Required", + onClick: () => { + control.sendTo( + { hostId }, + new lib.HostMarkRestartRequiredRequest() + ).then(() => { + notify("Host marked as requiring a restart", "success"); + }).catch(notifyErrorHandler("Error marking host")); + }, + }); + } + if (account.hasPermission("core.host.stop")) { actions.push({ key: "stop", diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index b7bd90cc..5a3a3ab3 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -29,6 +29,15 @@ describe("messages/controller", function() { controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); }); + describe("ControllerMarkRestartRequiredRequest", function() { + it("runs", async function() { + await controlConnection.handleControllerMarkRestartRequiredRequest( + new lib.ControllerMarkRestartRequiredRequest() + ); + assert.equal(controller.config.restartRequired, true); + }); + }); + describe("ControllerUpdateRequest", function() { it("runs", async function() { controller.config.set("controller.allow_remote_updates", true); diff --git a/test/messages/host.test.js b/test/messages/host.test.js index 25310748..1b31129a 100644 --- a/test/messages/host.test.js +++ b/test/messages/host.test.js @@ -32,6 +32,13 @@ describe("messages/host", function() { }); }); + describe("HostMarkRestartRequiredRequest", function() { + it("runs", async function() { + await host.handleHostMarkRestartRequiredRequest(new lib.HostMarkRestartRequiredRequest()); + assert.equal(host.config.restartRequired, true); + }); + }); + describe("HostConfigGetRequest", function() { it("runs", async function() { const config = await host.handleHostConfigGetRequest(new lib.HostConfigGetRequest()); From 1299ea80b8be9efe423e9c043b33794c2dbf41e3 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:31:20 +0000 Subject: [PATCH 2/2] Cover the mark-restart-required ctl commands Co-Authored-By: Claude Fable 5.1 --- test/integration/clusterio.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 01cd0681..3274fade 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -550,6 +550,12 @@ describe("Integration of Clusterio", function() { }); }); + describe("controller mark-restart-required", function() { + it("runs", async function() { + await execCtl("controller mark-restart-required"); + }); + }); + describe("host list", function() { it("runs", async function() { await execCtl("host list"); @@ -635,6 +641,12 @@ describe("Integration of Clusterio", function() { }); }); + describe("host mark-restart-required", function() { + it("runs", async function() { + await execCtl("host mark-restart-required 4"); + }); + }); + describe("host generate-token", function() { it("runs", async function() { await execCtl("host generate-token --id 42");