From e3c18db7b8a6b619e821a2b4b7441e6674bc2c6b Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:01:35 +0000 Subject: [PATCH 1/3] Reload the web interface when the controller's plugins change The web interface loads plugin bundles, registers their messages and adds their config fields once in bootstrap, and has no way to undo any of it. A controller restarted to pick up a plugin being installed, enabled or disabled comes back running a different set, and a controller started in recovery mode runs none of them while still accepting control connections, so open pages carry on sending messages it no longer handles. The controller already states the plugins it is running in the handshake it sends on every connection, which is what hosts track as serverPlugins and use to decide what to forward and which instance plugins to load. Compare that against the set the page loaded and stop rendering on a mismatch, asking to be reloaded instead. The comparison is against loaded rather than enabled because loaded is what the handshake reports and what the controller is actually running; enabled also takes load_plugin into account, which only applies from the next restart and would otherwise latch a permanent mismatch. Closes #804 --- packages/web_ui/src/bootstrap.tsx | 10 ++++--- packages/web_ui/src/components/App.tsx | 41 ++++++++++++++++++++++++-- packages/web_ui/src/util/pluginSet.ts | 26 ++++++++++++++++ packages/web_ui/src/util/websocket.ts | 2 ++ 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 packages/web_ui/src/util/pluginSet.ts diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index b4954413..8ef588bd 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -11,6 +11,7 @@ import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion"; import { Control, ControlConnector } from "./util/websocket"; +import { loadedPluginSetKey } from "./util/pluginSet"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -29,7 +30,7 @@ async function loadScript(url: string) { return result; } -async function loadPluginInfos(): Promise { +async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> { let response = await fetch(`${webRoot}api/plugins`); let pluginList: lib.PluginWebApi[]; if (response.ok) { @@ -39,6 +40,7 @@ async function loadPluginInfos(): Promise { logger.error("Failed to get plugin data, running without plugins"); pluginList = []; } + let pluginSetKey = loadedPluginSetKey(pluginList); let pluginInfos: lib.PluginWebpackEnvInfo[] = []; await __webpack_init_sharing__("default"); @@ -73,7 +75,7 @@ async function loadPluginInfos(): Promise { } } } - return pluginInfos; + return [pluginInfos, pluginSetKey]; } async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Control) { @@ -132,13 +134,13 @@ export default async function bootstrap() { level: "verbose", format: new WebConsoleFormat(), })); - let pluginInfos = await loadPluginInfos(); + let [pluginInfos, pluginSetKey] = await loadPluginInfos(); lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); let wsUrl = new URL(webRoot, document.location.href); let controlConnector = new ControlConnector(wsUrl.href, 120); - let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p]))); + let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])), pluginSetKey); control.plugins = await loadPlugins(pluginInfos, control); control.inputComponents = inputComponentsFromPlugins(control.plugins); diff --git a/packages/web_ui/src/components/App.tsx b/packages/web_ui/src/components/App.tsx index e4dcfdc6..d0a3cc65 100644 --- a/packages/web_ui/src/components/App.tsx +++ b/packages/web_ui/src/components/App.tsx @@ -9,8 +9,9 @@ import ErrorBoundary from "./ErrorBoundary"; import SiteLayout from "./SiteLayout"; import ControlContext from "./ControlContext"; import LoginForm from "./LoginForm"; +import { pluginSetKey } from "../util/pluginSet"; -import { Card, ConfigProvider, Spin, Typography, theme } from "antd"; +import { Button, Card, ConfigProvider, Spin, Typography, theme } from "antd"; const { Paragraph } = Typography; @@ -28,11 +29,28 @@ function ErrorCard(props: ErrorProps) { } +function PluginsChangedCard() { + return
+ +

Reload required

+ + The plugins the controller is running have changed since this page was opened. Plugin code, + messages and config fields are loaded once when the page starts and cannot be swapped out + while it runs, so this page has been stopped to keep it from acting on plugins that are no + longer there. + + +
+
; +} + + type AppProps = { control: Control; }; export default function App(props: AppProps) { let [connected, setConnected] = useState(false); + let [pluginsChanged, setPluginsChanged] = useState(false); let [token, setToken] = useState(localStorage.getItem("controller_token") || null); let connector = props.control.connector; @@ -73,6 +91,22 @@ export default function App(props: AppProps) { }; }, [token, props.control]); + // The controller reports the plugins it is running in the handshake it + // sends on every connection, so restarting it to install, enable or + // disable a plugin, or into recovery mode, is caught on reconnect. + useEffect(() => { + function onHello(data: any) { + if (pluginSetKey(data.plugins) !== props.control.pluginSetKey) { + setPluginsChanged(true); + } + } + + connector.on("hello", onHello); + return () => { + connector.off("hello", onHello); + }; + }, [props.control]); + useEffect(() => { if (token && !connected) { if (props.control.loggingOut) { @@ -86,7 +120,10 @@ export default function App(props: AppProps) { }, [token, connected]); let page; - if (connected) { + if (pluginsChanged) { + page = ; + + } else if (connected) { page = ; } else if (token) { diff --git a/packages/web_ui/src/util/pluginSet.ts b/packages/web_ui/src/util/pluginSet.ts new file mode 100644 index 00000000..190fe2c2 --- /dev/null +++ b/packages/web_ui/src/util/pluginSet.ts @@ -0,0 +1,26 @@ +import type { PluginWebApi } from "@clusterio/lib"; + +/** + * Canonical key for a set of plugins loaded on the controller + * + * @param plugins - name to version mapping of loaded plugins. + * @returns value comparable with === against another key. + */ +export function pluginSetKey(plugins: Record) { + return JSON.stringify(Object.entries(plugins).sort(([a], [b]) => a.localeCompare(b))); +} + +/** + * Canonical key for the plugins loaded when this page was started + * + * @param pluginList - plugin list as served by /api/plugins. + * @returns value comparable with === against a key from the handshake. + */ +export function loadedPluginSetKey(pluginList: PluginWebApi[]) { + // The handshake reports the plugins the controller is running, so + // compare against loaded rather than enabled, which also takes the + // config into account and only applies from the next restart. + return pluginSetKey(Object.fromEntries( + pluginList.filter(plugin => plugin.loaded).map(plugin => [plugin.name, plugin.version]) + )); +} diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 44069956..b4afb44c 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -80,6 +80,8 @@ export class Control extends lib.Link { constructor( connector: ControlConnector, public pluginInfos = new Map(), + /** Key of the plugin set this interface was loaded against */ + public pluginSetKey = "", ) { super(connector); From 1e4b586fa3f9387f7aacfcc2bdd9cfd4630dc82e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:33:09 +0000 Subject: [PATCH 2/3] Distinguish a plugin that was never loaded from one that failed The plugin list reported "Error loading module" for anything without a loaded package, which includes plugins the controller has picked up since the page was opened. Nothing had been tried for those, so reporting a failure was both wrong and unhelpful: the fix is to reload, not to investigate. Report those as needing a reload instead. Plugins the controller cannot serve at all are excluded during startup before they reach the loaded plugin map, so those are checked for first and still count as an error. Fixes #729 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/PluginsPage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 399a92de..ddf80c29 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -88,6 +88,14 @@ export default function PluginsPage() { if (!plugin.meta.enabled) { return <> Disabled on controller; } + // Plugins are loaded once when the page starts, so one the + // controller has picked up since then was never attempted + // rather than attempted and failed. A plugin the controller + // cannot serve at all is excluded before it gets that far, + // and is a real error. + if (!plugin.meta.web.error && !control.pluginInfos.has(plugin.meta.name)) { + return <> Reload page to load; + } if (!plugin.package) { return <> Error loading module; } From 499d2199336ac288fd12b64343372cca6fb445e9 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:42:40 +0100 Subject: [PATCH 3/3] Remove overly verbose comments --- packages/web_ui/src/components/App.tsx | 11 ++++------- packages/web_ui/src/components/PluginsPage.tsx | 7 +------ packages/web_ui/src/util/pluginSet.ts | 6 ++---- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/web_ui/src/components/App.tsx b/packages/web_ui/src/components/App.tsx index d0a3cc65..3b69114b 100644 --- a/packages/web_ui/src/components/App.tsx +++ b/packages/web_ui/src/components/App.tsx @@ -34,10 +34,8 @@ function PluginsChangedCard() {

Reload required

- The plugins the controller is running have changed since this page was opened. Plugin code, - messages and config fields are loaded once when the page starts and cannot be swapped out - while it runs, so this page has been stopped to keep it from acting on plugins that are no - longer there. + The plugins the controller is running have changed since this page was opened. + So the page has been closed and must be reloaded to continue functioning as expected.
@@ -91,9 +89,8 @@ export default function App(props: AppProps) { }; }, [token, props.control]); - // The controller reports the plugins it is running in the handshake it - // sends on every connection, so restarting it to install, enable or - // disable a plugin, or into recovery mode, is caught on reconnect. + // The controller reports the plugins it is running in the handshake + // This is used to detect when plugins change and a restart is required useEffect(() => { function onHello(data: any) { if (pluginSetKey(data.plugins) !== props.control.pluginSetKey) { diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index ddf80c29..16a0dc7d 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -88,11 +88,6 @@ export default function PluginsPage() { if (!plugin.meta.enabled) { return <> Disabled on controller; } - // Plugins are loaded once when the page starts, so one the - // controller has picked up since then was never attempted - // rather than attempted and failed. A plugin the controller - // cannot serve at all is excluded before it gets that far, - // and is a real error. if (!plugin.meta.web.error && !control.pluginInfos.has(plugin.meta.name)) { return <> Reload page to load; } @@ -100,7 +95,7 @@ export default function PluginsPage() { return <> Error loading module; } if (plugin.package.version !== plugin.meta.version) { - return "Version missmatched"; + return <> Version mis-matched; } return plugin.package.version; }, diff --git a/packages/web_ui/src/util/pluginSet.ts b/packages/web_ui/src/util/pluginSet.ts index 190fe2c2..76ef9406 100644 --- a/packages/web_ui/src/util/pluginSet.ts +++ b/packages/web_ui/src/util/pluginSet.ts @@ -1,7 +1,8 @@ import type { PluginWebApi } from "@clusterio/lib"; /** - * Canonical key for a set of plugins loaded on the controller + * Canonical key for a set of plugins loaded on the controller. + * It is assumed that all plugins provided are currently loaded. * * @param plugins - name to version mapping of loaded plugins. * @returns value comparable with === against another key. @@ -17,9 +18,6 @@ export function pluginSetKey(plugins: Record) { * @returns value comparable with === against a key from the handshake. */ export function loadedPluginSetKey(pluginList: PluginWebApi[]) { - // The handshake reports the plugins the controller is running, so - // compare against loaded rather than enabled, which also takes the - // config into account and only applies from the next restart. return pluginSetKey(Object.fromEntries( pluginList.filter(plugin => plugin.loaded).map(plugin => [plugin.name, plugin.version]) ));