diff --git a/packages/lib/plugin.js b/packages/lib/plugin.js index 4dce8257..960150a0 100644 --- a/packages/lib/plugin.js +++ b/packages/lib/plugin.js @@ -423,6 +423,68 @@ class BaseControlPlugin { async addCommands(rootCommand) { } } + +/** + * Plugin supplied login form + * @typedef {Object} module:lib/plugin~LoginForm + * @property {string} name - + * Internal name of the login form, this should start with the + * plugin name followed by a dot. + * @property {string} title - + * Name displayed above this form in the login window. + * @property {React.Component} Component - + * React component that's rendered for this login form. This is + * supplied the setToken function via its props which should be + * called when an authentication token is aquired via this + * form. + */ + +/** + * 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 }); + + /** + * List of login forms provided by this plugin + * + * @type {Array} + */ + this.loginForms = []; + } + + /** + * Called immediately after the class is instantiated. + */ + async init() { } +} + /** * Attach plugin messages * @@ -489,6 +551,7 @@ module.exports = { BaseInstancePlugin, BaseMasterPlugin, BaseControlPlugin, + BaseWebPlugin, attachPluginMessages, invokeHook, diff --git a/packages/master/master.js b/packages/master/master.js index b7af2e5d..fa65b2f4 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -41,8 +41,10 @@ let loadedPlugins = {}; let devMiddleware; let clusterLogger; let db = { - instances: new Map(), - slaves: new Map(), + instances: null, + slaves: null, + roles: null, + users: null, }; let slaveConnections = new Map(); let controlConnections = []; @@ -78,18 +80,6 @@ let httpServerCloser; let httpsServer; let httpsServerCloser; -app.use(cookieParser()); -app.use(bodyParser.json({ - limit: "10mb", -})); -app.use(bodyParser.urlencoded({ - parameterLimit: 100000, - limit: "10mb", - extended: true, -})); -app.use(fileUpload()); -app.use(compression()); - // Servers the web interface with the root path set apropriately. function serveWeb(route) { @@ -110,10 +100,6 @@ function serveWeb(route) { }; } -// Set folder to serve static content from (the website) -app.use(express.static(path.join(__dirname, "dist", "web"))); -app.use(express.static("static")); // Used for data export files - const slaveMappingGauge = new libPrometheus.Gauge( "clusterio_master_slave_mapping", "Mapping of Slave ID to name", @@ -149,6 +135,11 @@ const instanceMappingGauge = new libPrometheus.Gauge( } ); +const hitCounter = new libPrometheus.Counter( + "clusterio_master_http_hits_total", + "How many HTTP requests in total have been received" +); + const endpointHitCounter = new libPrometheus.Counter( "clusterio_master_http_endpoint_hits_total", "How many requests a particular HTTP endpoint has gotten", @@ -181,6 +172,28 @@ const wsActiveSlavesGauge = new libPrometheus.Gauge( "How many slaves are currently connected to the master" ); + +app.use((req, res, next) => { + hitCounter.inc(); + next(); +}); +app.use(cookieParser()); +app.use(bodyParser.json({ + limit: "10mb", +})); +app.use(bodyParser.urlencoded({ + parameterLimit: 100000, + limit: "10mb", + extended: true, +})); +app.use(fileUpload()); +app.use(compression()); + +// Set folder to serve static content from (the website) +app.use(express.static(path.join(__dirname, "dist", "web"))); +app.use(express.static("static")); // Used for data export files + + // Merges samples from sourceResult to destinationResult function mergeSamples(destinationResult, sourceResult) { // Merge metrics received by multiple slaves @@ -275,7 +288,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); }); @@ -488,6 +502,14 @@ async function shutdown() { logger.info("Saving configs"); await fs.outputFile(masterConfigPath, JSON.stringify(masterConfig.serialize(), null, 4)); + if (devMiddleware) { + await new Promise((resolve, reject) => { devMiddleware.close(resolve); }); + } + + if (!db.slaves) { + return; + } + await saveMap(masterConfig.get("master.database_directory"), "slaves.json", db.slaves); await saveInstances(masterConfig.get("master.database_directory"), "instances.json", db.instances); await saveUsers(masterConfig.get("master.database_directory"), "users.json"); @@ -496,10 +518,6 @@ async function shutdown() { stopAcceptingNewSessions = true; - if (devMiddleware) { - await new Promise((resolve, reject) => { devMiddleware.close(resolve); }); - } - let disconnectTasks = []; for (let controlConnection of controlConnections) { controlConnection.connector.setTimeout(masterConfig.get("master.connector_shutdown_timeout")); @@ -1861,6 +1879,7 @@ async function initialize() { }) .command("run", "Run master server", yargs => { yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 }); + yargs.option("dev-plugin", { hidden: true, type: "array" }); }) .demandCommand(1, "You need to specify a command to run") .strict() @@ -1953,15 +1972,29 @@ async function initialize() { async function startServer(args) { // Start webpack development server if enabled - if (args.dev) { + if (args.dev || args.devPlugin) { logger.warn("Webpack development mode enabled"); /* eslint-disable global-require */ const webpack = require("webpack"); const webpackDevMiddleware = require("webpack-dev-middleware"); - const webpackConfig = require("./webpack.config"); + const webpackConfigs = []; + if (args.dev) { + webpackConfigs.push(require("./webpack.config")); + } + if (args.devPlugin) { + for (let name of args.devPlugin) { + let info = pluginInfos.find(i => i.name === name); + if (!info) { + throw new libErrors.StartupError(`No plugin named ${name}`); + } + webpackConfigs.push( + require(path.posix.join(info.requirePath, "webpack.config")) + ); + } + } /* eslint-enable global-require */ - const compiler = webpack(webpackConfig({})); + const compiler = webpack(webpackConfigs.map(config => config({}))); devMiddleware = webpackDevMiddleware(compiler, {}); app.use(devMiddleware); } @@ -2033,7 +2066,7 @@ async function startServer(args) { let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json")); let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web"); - app.use(`/plugin/${pluginInfo.name}`, express.static(webPath)); + app.use(`/plugins/${pluginInfo.name}`, express.static(webPath)); } // Load plugins diff --git a/packages/master/package.json b/packages/master/package.json index 937d5f0a..0b22b6bb 100644 --- a/packages/master/package.json +++ b/packages/master/package.json @@ -41,7 +41,7 @@ "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", "webpack-cli": "^3.3.12", - "webpack-dev-middleware": "^3.7.2", + "webpack-dev-middleware": "^4.1.0", "webpack-merge": "^5.2.0" }, "publishConfig": { diff --git a/packages/master/web/index.html b/packages/master/web/index.html index 61d74ee8..372cf1fe 100644 --- a/packages/master/web/index.html +++ b/packages/master/web/index.html @@ -5,7 +5,7 @@ Clusterio diff --git a/packages/master/web/index.jsx b/packages/master/web/index.jsx index 30fdbf62..64be3a06 100644 --- a/packages/master/web/index.jsx +++ b/packages/master/web/index.jsx @@ -1 +1,3 @@ -import("@clusterio/web_ui/src/bootstrap"); +import("@clusterio/web_ui").then(webUi => { + return webUi.bootstrap(); +}).catch(console.error); diff --git a/packages/master/webpack.config.js b/packages/master/webpack.config.js index 14350778..d7570127 100644 --- a/packages/master/webpack.config.js +++ b/packages/master/webpack.config.js @@ -12,6 +12,7 @@ module.exports = (env = {}) => merge(common(env), { contentBase: "./dist/web", }, output: { + publicPath: "auto", filename: "bundle.js", path: path.resolve(__dirname, "dist", "web"), }, @@ -28,7 +29,6 @@ module.exports = (env = {}) => merge(common(env), { "@clusterio/lib/schema": { singleton: true }, "@clusterio/lib/users": { singleton: true }, "@clusterio/web_ui": { singleton: true }, - "ajv": {}, "antd": { singleton: true }, "react": { singleton: true }, "react-dom": { singleton: true }, diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 5a4fe02a..3adf1e6c 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -4,6 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.4", + "browser": "src/index.jsx", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/packages/web_ui/src/basename.jsx b/packages/web_ui/src/basename.jsx deleted file mode 100644 index 0e7e3de4..00000000 --- a/packages/web_ui/src/basename.jsx +++ /dev/null @@ -1,6 +0,0 @@ -// Calculate basedir of application based on current location and -// the provided web_root from master server. -const location = new URL(window.webRoot, document.location); -const basename = location.pathname !== "/" ? location.pathname : ""; - -export default basename; diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index c84405b7..6acef556 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -4,6 +4,7 @@ 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 App from "./components/App"; @@ -36,51 +37,57 @@ 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(`${webRoot}plugins/${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; } -async function load() { +export default async function bootstrap() { logger.add(new ConsoleTransport({ level: "verbose", 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")); } - -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 2c90130d..7532d7c8 100644 --- a/packages/web_ui/src/components/App.jsx +++ b/packages/web_ui/src/components/App.jsx @@ -4,14 +4,25 @@ import { BrowserRouter } from "react-router-dom"; import { logger } from "@clusterio/lib/logging"; -import basename from "../basename"; +import ErrorBoundary from "./ErrorBoundary"; import SiteLayout from "./SiteLayout"; import ControlContext from "./ControlContext"; import PluginsContext from "./PluginsContext"; import LoginForm from "./LoginForm"; -import { Card, Spin } from "antd"; +import { Card, Spin, Typography } from "antd"; +const { Paragraph } = Typography; + + +function ErrorCard(props) { + return
+ +

An unexpected error occured

+ {props.error.stack} +
+
; +} export default function App(props) { let [connected, setConnected] = useState(false); @@ -69,20 +80,19 @@ export default function App(props) { } else { page =
- -

Clusterio

- -
+
; } return ( - - - - {page} - - - + + + + + {page} + + + + ); } diff --git a/packages/web_ui/src/components/LoginForm.jsx b/packages/web_ui/src/components/LoginForm.jsx index 6a5a13cf..7e78b915 100644 --- a/packages/web_ui/src/components/LoginForm.jsx +++ b/packages/web_ui/src/components/LoginForm.jsx @@ -1,27 +1,61 @@ -import React from "react"; -import { Form, Input, Button } from "antd"; +import React, { Fragment, useContext } from "react"; +import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typography } from "antd"; import LockOutlined from "@ant-design/icons/LockOutlined"; -export default function LoginForm(props) { - return ( +import logo from "../images/logo.png"; +import PluginsContext from "./PluginsContext"; + +const { Paragraph, Text } = Typography; + + +function TokenAuth(props) { + return <> + If you have generated an authentication token from the master server, use that here.
{ props.setToken(values.token); }} + layout="inline" > } + style={{ width: "14em" }} + prefix= type="password" placeholder="Authentication Token" />
- ); + ; +} + +export default function LoginForm(props) { + let plugins = useContext(PluginsContext); + + let loginForms = []; + for (let plugin of plugins.values()) { + loginForms.push(...plugin.loginForms); + } + + loginForms.push({ + name: "core.token", + title: "Token", + Component: TokenAuth, + }); + + return + +

Clusterio

+ + Log in to the cluster using one of the methods below. +
+ {loginForms.map(form => + {form.title} + + )} +
; } diff --git a/packages/web_ui/src/components/PluginViewPage.jsx b/packages/web_ui/src/components/PluginViewPage.jsx index ae847103..4ab2955f 100644 --- a/packages/web_ui/src/components/PluginViewPage.jsx +++ b/packages/web_ui/src/components/PluginViewPage.jsx @@ -1,6 +1,6 @@ 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 PluginsContext from "./PluginsContext"; import PageLayout from "./PageLayout"; @@ -10,27 +10,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(`${webRoot}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 +78,14 @@ export default function PluginViewPage() { : null} {plugin.package.license} - {plugin.meta.version !== plugin.package.version ? { + (async () => { + let response = await fetch(`${webRoot}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 +68,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/index.jsx b/packages/web_ui/src/index.jsx new file mode 100644 index 00000000..9761a022 --- /dev/null +++ b/packages/web_ui/src/index.jsx @@ -0,0 +1,19 @@ +export { default as bootstrap } from "./bootstrap.jsx"; +export { default as notify, notifyErrorHandler } from "./util/notify.jsx"; + +export * from "./model/instance.jsx"; +export * from "./model/slave.jsx"; + + +export { default as ControlContext } from "./components/ControlContext"; +export { default as PluginsContext } from "./components/PluginsContext"; + +export { default as BaseConfigTree } from "./components/BaseConfigTree"; +export { default as InstanceConfigTree } from "./components/InstanceConfigTree"; +export { default as MasterConfigTree } from "./components/MasterConfigTree"; + +export { default as AssignInstanceModal } from "./components/AssignInstanceModal"; +export { default as InstanceConsole } from "./components/InstanceConsole"; +export { default as InstanceRcon } from "./components/InstanceRcon"; + +export { default as PageLayout } from "./components/PageLayout"; 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(); diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.js index 6efae364..b1b77ec3 100644 --- a/plugins/global_chat/webpack.config.js +++ b/plugins/global_chat/webpack.config.js @@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), { context: __dirname, entry: "./web/index.jsx", output: { + publicPath: "auto", filename: "bundle.js", path: path.resolve(__dirname, "dist", "web"), }, diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.js index 78645a96..e41d52e5 100644 --- a/plugins/research_sync/webpack.config.js +++ b/plugins/research_sync/webpack.config.js @@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), { context: __dirname, entry: "./web/index.jsx", output: { + publicPath: "auto", filename: "bundle.js", path: path.resolve(__dirname, "dist", "web"), }, diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.js index 6ca9a1a4..81f1a2ad 100644 --- a/plugins/statistics_exporter/webpack.config.js +++ b/plugins/statistics_exporter/webpack.config.js @@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), { context: __dirname, entry: "./web/index.jsx", output: { + publicPath: "auto", filename: "bundle.js", path: path.resolve(__dirname, "dist", "web"), }, diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index 65853b7f..8305920b 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), { context: __dirname, entry: "./web/index.jsx", output: { + publicPath: "auto", filename: "bundle.js", path: path.resolve(__dirname, "dist", "web"), },