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
This commit is contained in:
bbassie committed 2026-08-10 10:01:35 +00:00
1 parent 5e0ab3423f
commit e3c18db7b8
4 files changed
+73 -6

No files matched your search

+6 -4
View File
@@ -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<lib.PluginWebpackEnvInfo[]> {
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<lib.PluginWebpackEnvInfo[]> {
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<lib.PluginWebpackEnvInfo[]> {
}
}
}
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);
+39 -2
View File
@@ -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 <div className="login-container">
<Card>
<h1>Reload required</h1>
<Paragraph>
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.
</Paragraph>
<Button type="primary" onClick={() => window.location.reload()}>Reload</Button>
</Card>
</div>;
}
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 = <PluginsChangedCard />;
} else if (connected) {
page = <SiteLayout/>;
} else if (token) {
+26
View File
@@ -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<string, string>) {
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])
));
}
+2
View File
@@ -80,6 +80,8 @@ export class Control extends lib.Link {
constructor(
connector: ControlConnector,
public pluginInfos = new Map<string, lib.PluginWebpackEnvInfo>(),
/** Key of the plugin set this interface was loaded against */
public pluginSetKey = "",
) {
super(connector);