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] 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);