mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
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:
1 parent
6c4e094d44
commit
5ce3fb6cc6
6 files changed
+140
-45
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user