From 5ce3fb6cc6102209dfcb19552bbc4bde28e3f2ba Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 11 Mar 2021 13:20:43 +0100 Subject: [PATCH] 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). --- packages/lib/plugin.js | 41 ++++++++++++++ packages/master/master.js | 3 +- packages/web_ui/src/bootstrap.jsx | 42 ++++++++------ .../web_ui/src/components/PluginViewPage.jsx | 55 ++++++++++++------- .../web_ui/src/components/PluginsPage.jsx | 34 +++++++++++- packages/web_ui/src/util/websocket.jsx | 10 ++-- 6 files changed, 140 insertions(+), 45 deletions(-) diff --git a/packages/lib/plugin.js b/packages/lib/plugin.js index 4dce8257..68687718 100644 --- a/packages/lib/plugin.js +++ b/packages/lib/plugin.js @@ -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, diff --git a/packages/master/master.js b/packages/master/master.js index b7af2e5d..7c1ce2a6 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -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); }); diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index c84405b7..0db610cb 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -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(, document.getElementById("root")); } diff --git a/packages/web_ui/src/components/PluginViewPage.jsx b/packages/web_ui/src/components/PluginViewPage.jsx index ae847103..dbca70c8 100644 --- a/packages/web_ui/src/components/PluginViewPage.jsx +++ b/packages/web_ui/src/components/PluginViewPage.jsx @@ -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 + + + ; + } + + let pluginMeta = pluginList.find(p => p.name === pluginName); + if (!pluginMeta) { return

Plugin not found

Plugin with name {pluginName} was not found on the master server.

; } - if (!plugin.info) { + if (!plugin) { return - - {plugin.meta.version} - {plugin.meta.loaded ? "Yes" : "No"} + + {pluginMeta.version} + {pluginMeta.loaded ? "Yes" : "No"} - - {plugin.meta.version} - {plugin.meta.loaded ? "Yes" : "No"} + + {pluginMeta.version} + {pluginMeta.loaded ? "Yes" : "No"} {plugin.info.description} {plugin.package.homepage ? {plugin.package.homepage} @@ -66,14 +79,14 @@ export default function PluginViewPage() { : null} {plugin.package.license} - {plugin.meta.version !== plugin.package.version ? { + (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

Plugins

@@ -39,7 +69,7 @@ export default function PluginsPage() { responsive: ["sm"], }, ]} - dataSource={plugins} + dataSource={tableContents} rowKey={plugin => plugin.meta.name} pagination={false} onRow={plugin => ({ diff --git a/packages/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index f3de0e4d..13b78fef 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -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} + * @type {Map} */ - 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();