diff --git a/CHANGELOG.md b/CHANGELOG.md index af6f237e..c5827ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,8 @@ Version 2.0.0 them to be displayed by name in queries that join with the mapping. - Added option to configure timeout for exported Prometheus metrics. - Added option to set address to bind HTTP(S) port to. +- Added an overview of the installed plugins to the web interface. +- Added clusterioctl command to list plugins on the master server. ### Changes diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 7ca51d56..693d29df 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -13,6 +13,7 @@ const events = require("events"); const path = require("path"); const winston = require("winston"); const setBlocking = require("set-blocking"); +const phin = require("phin"); const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); @@ -104,6 +105,25 @@ masterConfigCommands.add(new libCommand.Command({ masterCommands.add(masterConfigCommands); +const masterPluginCommands = new libCommand.CommandTree({ + name: "plugin", alias: ["p"], description: "master plugin inspection", +}); +masterPluginCommands.add(new libCommand.Command({ + definition: ["list", "List plugins on master"], + handler: async function(args, control) { + let url = new URL(control.config.get("control.master_url")); + url.pathname += "api/plugins"; + let response = await phin({ + url, + parse: "json", + core: { ca: control.tlsCa }, + }); + print(asTable(response.body)); + }, +})); +masterCommands.add(masterPluginCommands); + + const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); slaveCommands.add(new libCommand.Command({ definition: [["list", "l"], "List slaves connected to the master"], @@ -698,10 +718,20 @@ class ControlConnector extends libLink.WebSocketClientConnector { */ class Control extends libLink.Link { - constructor(connector, controlPlugins) { + constructor(connector, controlConfig, tlsCa, controlPlugins) { super("control", "master", connector); libLink.attachAllMessages(this); + /** + * Control config used for connecting to the master. + * @type {module:lib/config.ControlConfig} + */ + this.config = controlConfig; + /** + * Certificate authority used to validate TLS connections to the master. + * @type {?string} + */ + this.tlsCa = tlsCa; /** * Mapping of plugin names to their instance for loaded plugins. * @type {Map} @@ -917,7 +947,7 @@ async function startControl() { tlsCa, controlConfig.get("control.master_token") ); - let control = new Control(controlConnector, controlPlugins); + let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins); try { await controlConnector.connect(); } catch (err) { diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 71f3f455..2664c621 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -22,6 +22,7 @@ "as-table": "^1.0.31", "chalk": "^3.0.0", "fs-extra": "^8.1.0", + "phin": "^3.5.1", "winston": "^3.3.3", "yargs": "^14.2.0" }, diff --git a/packages/master/master.js b/packages/master/master.js index 2474c0d6..bb5a7da9 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -279,7 +279,7 @@ app.get("/api/plugins", (req, res) => { for (let pluginInfo of pluginInfos) { let name = pluginInfo.name; let enabled = masterPlugins.has(name) && masterConfig.group(name).get("enabled"); - plugins.push({ name, enabled }); + plugins.push({ name, version: pluginInfo.version, enabled }); } res.send(plugins); }); diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index bcfde4ee..c72ab970 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -11,5 +11,6 @@ module.exports = [ "/users/:id/view", "/roles", "/roles/:id/view", - "/settings", + "/plugins", + "/plugins/:name/view", ]; diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 5461a2c3..5076d5db 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -736,7 +736,7 @@ class Instance extends libLink.Link { let response = await phin({ url, method: "PUT", data: content, - core: { rejectUnauthorized: false }, + core: { ca: this._slave.tlsCa }, headers: { "Content-Type": "application/zip", "x-access-token": this._slave.config.get("slave.master_token"), @@ -976,7 +976,7 @@ function checkFilename(name) { class Slave extends libLink.Link { // I don't like God classes, but the alternative of putting all this state // into global variables is not much better. - constructor(connector, slaveConfig, pluginInfos) { + constructor(connector, slaveConfig, tlsCa, pluginInfos) { super("slave", "master", connector); libLink.attachAllMessages(this); @@ -987,6 +987,12 @@ class Slave extends libLink.Link { this.config = slaveConfig; + /** + * Certificate authority used to validate TLS connections to the master. + * @type {?string} + */ + this.tlsCa = tlsCa; + this.instanceConnections = new Map(); this.discoveredInstanceInfos = new Map(); this.instanceInfos = new Map(); @@ -1618,7 +1624,7 @@ async function startSlave() { } let slaveConnector = new SlaveConnector(slaveConfig, tlsCa, pluginInfos); - let slave = new Slave(slaveConnector, slaveConfig, pluginInfos); + let slave = new Slave(slaveConnector, slaveConfig, tlsCa, pluginInfos); // Handle interrupts let secondSigint = false; diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index f9fafb14..627f446b 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -25,7 +25,7 @@ async function loadScript(url) { return result; } -async function loadPluginInfos() { +async function loadPlugins() { let response = await fetch(`${webRoot}api/plugins`); let pluginList; if (response.ok) { @@ -36,22 +36,30 @@ async function loadPluginInfos() { pluginList = []; } - let pluginInfos = []; + let plugins = []; await __webpack_init_sharing__("default"); - for (let plugin of pluginList) { + for (let meta of pluginList) { try { - await loadScript(`${webRoot}plugin/${plugin.name}/remoteEntry.js`); - let container = window[`plugin_${plugin.name}`]; + await loadScript(`${webRoot}plugin/${meta.name}/remoteEntry.js`); + let container = window[`plugin_${meta.name}`]; await container.init(__webpack_share_scopes__.default); - let info = (await container.get("./info"))(); - info.container = container; - pluginInfos.push(info); + let pluginInfo = (await container.get("./info"))(); + let pluginPackage = (await container.get("./package.json"))(); + plugins.push({ + meta, + info: pluginInfo, + package: pluginPackage, + container, + }); } catch (err) { - logger.error(`Failed to load plugin info for ${plugin.name}`); + logger.error(`Failed to load plugin info for ${meta.name}`); + plugins.push({ + meta, + }); } } - return pluginInfos; + return plugins; } async function load() { @@ -59,8 +67,8 @@ async function load() { level: "verbose", format: new WebConsoleFormat(), })); - let pluginInfos = await loadPluginInfos(); - libConfig.registerPluginConfigGroups(pluginInfos); + let plugins = await loadPlugins(); + libConfig.registerPluginConfigGroups(plugins.filter(p => p.info).map(p => p.info)); libConfig.finalizeConfigs(); let wsUrl = new URL(window.webRoot, document.location); @@ -69,7 +77,7 @@ async function load() { let controlConnector = new ControlConnector(wsUrl, 10); let control = new Control(controlConnector, []); - ReactDOM.render(, document.getElementById("root")); + ReactDOM.render(, document.getElementById("root")); } -load().catch((err) => logger.fatal(err)); +load().catch((err) => logger.fatal(err.stack)); diff --git a/packages/web_ui/src/components/App.jsx b/packages/web_ui/src/components/App.jsx index 54dc63f0..2c90130d 100644 --- a/packages/web_ui/src/components/App.jsx +++ b/packages/web_ui/src/components/App.jsx @@ -7,6 +7,7 @@ import { logger } from "@clusterio/lib/logging"; import basename from "../basename"; import SiteLayout from "./SiteLayout"; import ControlContext from "./ControlContext"; +import PluginsContext from "./PluginsContext"; import LoginForm from "./LoginForm"; import { Card, Spin } from "antd"; @@ -77,9 +78,11 @@ export default function App(props) { return ( - - {page} - + + + {page} + + ); } diff --git a/packages/web_ui/src/components/PluginViewPage.jsx b/packages/web_ui/src/components/PluginViewPage.jsx new file mode 100644 index 00000000..98313720 --- /dev/null +++ b/packages/web_ui/src/components/PluginViewPage.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { Alert, Descriptions } from "antd"; + +import PluginsContext from "./PluginsContext"; +import PageLayout from "./PageLayout"; +import { notifyErrorHandler } from "../util/notify"; + + +export default function PluginViewPage() { + let params = useParams(); + let plugins = useContext(PluginsContext); + let pluginName = params.name; + + let plugin = plugins.find(p => p.meta.name === pluginName); + let pluginTitle = plugin.info ? plugin.info.title : plugin.meta.name; + let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; + if (!plugin) { + return +

Plugin not found

+

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

+
; + } + + if (!plugin.info) { + return + + {plugin.meta.version} + {plugin.meta.enabled ? "Yes" : "No"} + + + ; + } + + return + + {plugin.meta.version} + {plugin.meta.enabled ? "Yes" : "No"} + {plugin.info.description} + {plugin.package.homepage ? + {plugin.package.homepage} + : null} + {plugin.package.author ? + {plugin.package.author} + : null} + {plugin.package.license} + + {plugin.meta.version !== plugin.package.version ? : null} + ; +} diff --git a/packages/web_ui/src/components/PluginsContext.jsx b/packages/web_ui/src/components/PluginsContext.jsx new file mode 100644 index 00000000..979faf93 --- /dev/null +++ b/packages/web_ui/src/components/PluginsContext.jsx @@ -0,0 +1,3 @@ +import React from "react"; + +export default React.createContext(); diff --git a/packages/web_ui/src/components/PluginsPage.jsx b/packages/web_ui/src/components/PluginsPage.jsx new file mode 100644 index 00000000..7b9b0065 --- /dev/null +++ b/packages/web_ui/src/components/PluginsPage.jsx @@ -0,0 +1,52 @@ +import React, { useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { Table } from "antd"; + +import PluginsContext from "./PluginsContext"; +import PageLayout from "./PageLayout"; + + +export default function PluginsPage() { + let plugins = useContext(PluginsContext); + let history = useHistory(); + + return +

Plugins

+ (plugin.info ? plugin.info.title : plugin.meta.name), + }, + { + title: "Version", + key: "version", + render: plugin => { + if (!plugin.package) { + return "Error loading module"; + } + if (plugin.package.version !== plugin.meta.version) { + return "Version missmatched"; + } + return plugin.package.version; + }, + }, + { + title: "Enabled", + dataIndex: ["meta", "enabled"], + render: enabled => (enabled ? "Yes" : null), + responsive: ["sm"], + }, + ]} + dataSource={plugins} + rowKey={plugin => plugin.meta.name} + pagination={false} + onRow={plugin => ({ + onClick: event => { + history.push(`/plugins/${plugin.meta.name}/view`); + }, + })} + /> + ; +} diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index f2ef9358..6553837b 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -17,11 +17,14 @@ import UsersPage from "./UsersPage"; import UserViewPage from "./UserViewPage"; import RolesPage from "./RolesPage"; import RoleViewPage from "./RoleViewPage"; +import PluginsPage from "./PluginsPage"; +import PluginViewPage from "./PluginViewPage"; const pages = [ { path: "/instances/:id/view", sidebarPath: "/instances", content: }, { path: "/users/:name/view", sidebarPath: "/users", content: }, { path: "/roles/:id/view", sidebarPath: "/roles", content: }, + { path: "/plugins/:name/view", sidebarPath: "/plugins", content: }, ]; const sidebar = [ @@ -30,6 +33,7 @@ const sidebar = [ { name: "Instances", path: "/instances", content: }, { name: "Users", path: "/users", content: }, { name: "Roles", path: "/roles", content: }, + { name: "Plugins", path: "/plugins", content: }, ]; @@ -75,7 +79,9 @@ export default function SiteLayout(props) { {sidebar.map(({path, content}) => - {content} + + {content} + )} {pages.map(page => diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.js index 60b98d7b..6f19d7f7 100644 --- a/plugins/global_chat/webpack.config.js +++ b/plugins/global_chat/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.js index 484dfafe..80a7863e 100644 --- a/plugins/research_sync/webpack.config.js +++ b/plugins/research_sync/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.js index 0da4c121..9a8259e7 100644 --- a/plugins/statistics_exporter/webpack.config.js +++ b/plugins/statistics_exporter/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index cfd034a8..c53e2ca3 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 8d3be325..47050662 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -71,6 +71,12 @@ describe("Integration of Clusterio", function() { }); }); + describe("master plugin list", function() { + it("runs", async function() { + await execCtl("master plugin list"); + }); + }); + describe("slave list", function() { it("runs", async function() { await execCtl("slave list");