mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
17 files changed
+231
-25
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -11,5 +11,6 @@ module.exports = [
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/settings",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user