Merge pull request #362 from Hornwitser/plugin-list

Add plugin list
This commit is contained in:
Hornwitser authored and GitHub committed 2021-03-05 16:58:15 +01:00
commit 641d8e9fc5
17 files changed
+231 -25

No files matched your search

+2
View File
@@ -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
+32 -2
View File
@@ -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<string, module:lib/plugin.BaseControlPlugin>}
@@ -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) {
+1
View File
@@ -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"
},
+1 -1
View File
@@ -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);
});
+2 -1
View File
@@ -11,5 +11,6 @@ module.exports = [
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/settings",
"/plugins",
"/plugins/:name/view",
];
+9 -3
View File
@@ -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;
+22 -14
View File
@@ -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(<App control={control}/>, document.getElementById("root"));
ReactDOM.render(<App control={control} plugins={plugins}/>, document.getElementById("root"));
}
load().catch((err) => logger.fatal(err));
load().catch((err) => logger.fatal(err.stack));
+6 -3
View File
@@ -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 (
<ControlContext.Provider value={props.control}>
<BrowserRouter basename={basename}>
{page}
</BrowserRouter>
<PluginsContext.Provider value={props.plugins}>
<BrowserRouter basename={basename}>
{page}
</BrowserRouter>
</PluginsContext.Provider>
</ControlContext.Provider>
);
}
@@ -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 <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) {
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Enabled">{plugin.meta.enabled ? "Yes" : "No"}</Descriptions.Item>
</Descriptions>
<Alert
style={{
marginTop: "1em",
}}
message="Error loading web module"
description={
"The web interface was unable to load the webpack module for this plugin. This is "+
"usually due to an incorrect or missing webpack build for the plugin. Due to the "+
"module not being loaded, the configs and web controls defined by this plugin will "+
"not be available in the web interface."
}
type="error"
showIcon
/>
</PageLayout>;
}
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Enabled" span={2}>{plugin.meta.enabled ? "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>
</Descriptions.Item> : null}
{plugin.package.author ? <Descriptions.Item label="Author" span={3}>
{plugin.package.author}
</Descriptions.Item> : null}
<Descriptions.Item label="License" span={3}>{plugin.package.license}</Descriptions.Item>
</Descriptions>
{plugin.meta.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}), `+
"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."
}
type="warning"
showIcon
/> : null}
</PageLayout>;
}
@@ -0,0 +1,3 @@
import React from "react";
export default React.createContext();
@@ -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 <PageLayout nav={[{ name: "Plugins" }]}>
<h2>Plugins</h2>
<Table
columns={[
{
title: "Name",
key: "name",
render: plugin => (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`);
},
})}
/>
</PageLayout>;
}
@@ -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: <InstanceViewPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
{ path: "/plugins/:name/view", sidebarPath: "/plugins", content: <PluginViewPage /> },
];
const sidebar = [
@@ -30,6 +33,7 @@ const sidebar = [
{ name: "Instances", path: "/instances", content: <InstancesPage />},
{ name: "Users", path: "/users", content: <UsersPage /> },
{ name: "Roles", path: "/roles", content: <RolesPage /> },
{ name: "Plugins", path: "/plugins", content: <PluginsPage /> },
];
@@ -75,7 +79,9 @@ export default function SiteLayout(props) {
<Switch>
{sidebar.map(({path, content}) => <Route exact path={path} key={path}>
<SetSidebar path={path} />
{content}
<ErrorBoundary Component={ErrorPage}>
{content}
</ErrorBoundary>
</Route>)}
{pages.map(page => <Route exact path={page.path} key={page.path}>
<SetSidebar path={page.sidebarPath} />
+1
View File
@@ -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 },
+1
View File
@@ -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 },
@@ -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 },
@@ -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 },
+6
View File
@@ -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");