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