Add loading of web plugins in interface

Add separate BaseWebPlugin class which plugins can extend and expose
through a webEntrypoint in a similar fasion to the existing master,
instance and control entrypoints.

To simplify the logic for the rest of the web interface code the plugins
context is changed to be a mapping of loaded WebPlugin instances.  This
in turn means the plugins and plugin view pages fetches the plugin list
upon load in order to show plugins that failed to load (which
incidentally also gives them an up to date list).
This commit is contained in:
Hornwitser committed 2021-03-13 21:01:53 +01:00
1 parent 6c4e094d44
commit 5ce3fb6cc6
6 files changed
+140 -45

No files matched your search

+41
View File
@@ -423,6 +423,46 @@ class BaseControlPlugin {
async addCommands(rootCommand) { }
}
/**
* Base class for web interface plugins
*
* @static
*/
class BaseWebPlugin {
constructor(container, packageData, info, logger) {
/**
* Webpack container for this plugin
*/
this.container = container;
/**
* Contents of the plugin's package.json file
* @type {Object}
*/
this.package = packageData;
/**
* The plugin's own info module
*/
this.info = info;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
}
/**
* Called immediately after the class is instantiated.
*/
async init() { }
}
/**
* Attach plugin messages
*
@@ -489,6 +529,7 @@ module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
BaseWebPlugin,
attachPluginMessages,
invokeHook,
+2 -1
View File
@@ -275,7 +275,8 @@ app.get("/api/plugins", (req, res) => {
for (let pluginInfo of pluginInfos) {
let name = pluginInfo.name;
let loaded = masterPlugins.has(name);
plugins.push({ name, version: pluginInfo.version, loaded });
let enabled = loaded && masterConfig.group(pluginInfo.name).get("load_plugin");
plugins.push({ name, version: pluginInfo.version, enabled, loaded });
}
res.send(plugins);
});
+26 -16
View File
@@ -4,8 +4,10 @@ import React from "react";
import ReactDOM from "react-dom";
import libConfig from "@clusterio/lib/config";
import libPlugin from "@clusterio/lib/plugin";
import { ConsoleTransport, WebConsoleFormat, logger } from "@clusterio/lib/logging";
import basename from "./basename";
import App from "./components/App";
import { Control, ControlConnector } from "./util/websocket";
@@ -26,7 +28,7 @@ async function loadScript(url) {
}
async function loadPlugins() {
let response = await fetch(`${webRoot}api/plugins`);
let response = await fetch(`${basename}/api/plugins`);
let pluginList;
if (response.ok) {
pluginList = await response.json();
@@ -36,30 +38,37 @@ async function loadPlugins() {
pluginList = [];
}
let plugins = [];
let plugins = new Map();
await __webpack_init_sharing__("default");
for (let meta of pluginList) {
try {
await loadScript(`${webRoot}plugin/${meta.name}/remoteEntry.js`);
await loadScript(`${basename}/plugin/${meta.name}/remoteEntry.js`);
let container = window[`plugin_${meta.name}`];
if (!container) {
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
}
await container.init(__webpack_share_scopes__.default);
let pluginInfo = (await container.get("./info"))();
let pluginPackage = (await container.get("./package.json"))();
plugins.push({
meta,
info: pluginInfo,
package: pluginPackage,
container,
});
let WebPluginClass = libPlugin.BaseWebPlugin;
if (meta.enabled && pluginInfo.webEntrypoint) {
let webModule = (await container.get(pluginInfo.webEntrypoint))();
if (!webModule.WebPlugin) {
throw new Error("Plugin webEntrypoint does not export WebPlugin class");
}
WebPluginClass = webModule.WebPlugin;
}
let plugin = new WebPluginClass(container, pluginPackage, pluginInfo, logger);
await plugin.init();
plugins.set(pluginInfo.name, plugin);
} catch (err) {
logger.error(`Failed to load plugin info for ${meta.name}`);
if (err) {
logger.error(`Failed to load plugin ${meta.name}`);
if (err.stack) {
logger.error(err.stack);
}
plugins.push({
meta,
});
}
}
return plugins;
@@ -71,14 +80,15 @@ async function load() {
format: new WebConsoleFormat(),
}));
let plugins = await loadPlugins();
libConfig.registerPluginConfigGroups(plugins.filter(p => p.info).map(p => p.info));
let pluginInfos = [...plugins.values()].map(p => p.info);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10);
let control = new Control(controlConnector, []);
let control = new Control(controlConnector, plugins);
ReactDOM.render(<App control={control} plugins={plugins}/>, document.getElementById("root"));
}
@@ -1,7 +1,8 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Alert, Descriptions } from "antd";
import { Alert, Descriptions, Spin } from "antd";
import basename from "../basename";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
@@ -10,27 +11,43 @@ import { notifyErrorHandler } from "../util/notify";
export default function PluginViewPage() {
let params = useParams();
let plugins = useContext(PluginsContext);
let [pluginList, setPluginList] = useState(null);
let pluginName = params.name;
let plugin = plugins.find(p => p.meta.name === pluginName);
let pluginTitle = plugin.info ? plugin.info.title : plugin.meta.name;
useEffect(() => {
(async () => {
let response = await fetch(`${basename}/api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let plugin = plugins.get(pluginName);
let pluginTitle = plugin ? plugin.info.title : pluginName;
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!plugin) {
if (!pluginList) {
return <PageLayout nav={nav}>
<Descriptions borderd size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
let pluginMeta = pluginList.find(p => p.name === pluginName);
if (!pluginMeta) {
return <PageLayout nav={nav}>
<h2>Plugin not found</h2>
<p>Plugin with name {pluginName} was not found on the master server.</p>
</PageLayout>;
}
if (!plugin.info) {
if (!plugin) {
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded">{plugin.meta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
</Descriptions>
<Alert
style={{
@@ -50,13 +67,9 @@ export default function PluginViewPage() {
}
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded" span={2}>{plugin.meta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded" span={2}>{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Description" span={3}>{plugin.info.description}</Descriptions.Item>
{plugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={plugin.package.homepage}>{plugin.package.homepage}</a>
@@ -66,14 +79,14 @@ export default function PluginViewPage() {
</Descriptions.Item> : null}
<Descriptions.Item label="License" span={3}>{plugin.package.license}</Descriptions.Item>
</Descriptions>
{plugin.meta.version !== plugin.package.version ? <Alert
{pluginMeta.version !== plugin.package.version ? <Alert
style={{
marginTop: "1em",
}}
message="Version missmatch detected"
description={
`The version of the web interface module for this plugin (${plugin.package.version}) `+
`does not match the version running on the master server (${plugin.meta.version}), `+
`does not match the version running on the master server (${pluginMeta.version}), `+
"spurious errors may occur. This usually happens when the plugin is updated but the "+
"master server has not been restarted yet, but may also be due to an outdated build."
}
+32 -2
View File
@@ -1,7 +1,9 @@
import React, { useContext } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Table } from "antd";
import notify from "../util/notify";
import basename from "../basename";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
@@ -9,6 +11,34 @@ import PageLayout from "./PageLayout";
export default function PluginsPage() {
let plugins = useContext(PluginsContext);
let history = useHistory();
let [pluginList, setPluginList] = useState([]);
useEffect(() => {
(async () => {
let response = await fetch(`${basename}/api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let tableContents = [];
for (let meta of pluginList) {
if (plugins.has(meta.name)) {
let plugin = plugins.get(meta.name);
tableContents.push({
meta,
info: plugin.info,
package: plugin.package,
});
} else {
tableContents.push({
meta,
});
}
}
return <PageLayout nav={[{ name: "Plugins" }]}>
<h2>Plugins</h2>
@@ -39,7 +69,7 @@ export default function PluginsPage() {
responsive: ["sm"],
},
]}
dataSource={plugins}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
pagination={false}
onRow={plugin => ({
+5 -5
View File
@@ -36,17 +36,17 @@ export class ControlConnector extends libLink.WebSocketClientConnector {
* @static
*/
export class Control extends libLink.Link {
constructor(connector, controlPlugins) {
constructor(connector, plugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
* @type {Map<string, module:lib/plugin.BaseWebPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
this.plugins = plugins;
for (let plugin of plugins.values()) {
libPlugin.attachPluginMessages(this, plugin);
}
this.instanceLogHandlers = new Map();