diff --git a/.eslintrc.js b/.eslintrc.js index 14045381..a6e7c847 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,7 +21,7 @@ module.exports = { }, }, { - "files": ["packages/master/web/**/*.jsx"], + "files": ["packages/web_ui/src/**/*.jsx", "plugins/*/web/**/*.jsx"], "env": { "browser": true, }, @@ -71,7 +71,7 @@ module.exports = { "default-case-last": "error", "default-param-last": "error", "dot-location": ["error", "property"], - "dot-notation": ["warn", { "allowKeywords": true }], + "dot-notation": ["warn", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }], "eol-last": "error", "eqeqeq": "error", "func-call-spacing": "error", diff --git a/package.json b/package.json index bf526b27..8d33925a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "node": ">=10.16 <11 || >= 12" }, "devDependencies": { + "@babel/core": "^7.12.3", + "@babel/preset-react": "^7.12.1", "@clusterio/ctl": "file:./packages/ctl", "@clusterio/lib": "file:./packages/lib", "@clusterio/master": "file:packages/master", diff --git a/packages/master/package.json b/packages/master/package.json index 98bf25e4..66396f6b 100644 --- a/packages/master/package.json +++ b/packages/master/package.json @@ -35,17 +35,12 @@ "yargs": "^14.2.0" }, "devDependencies": { - "@ant-design/icons": "^4.2.2", - "@babel/core": "^7.11.6", - "@babel/preset-react": "^7.10.4", - "antd": "^4.6.5", + "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", - "clean-webpack-plugin": "^3.0.0", "css-loader": "^4.3.0", - "events": "^3.2.0", "fast-deep-equal": "^3.1.3", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", - "path-browserify": "^1.0.1", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", diff --git a/packages/master/web/components/App.jsx b/packages/master/web/components/App.jsx deleted file mode 100644 index 10c155b0..00000000 --- a/packages/master/web/components/App.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import { BrowserRouter } from "react-router-dom"; - -import basename from "../basename"; -import Layout from "../layout"; - -export default function App() { - return ( - - - - ); -} diff --git a/packages/master/web/index.css b/packages/master/web/index.css deleted file mode 100644 index fa33de59..00000000 --- a/packages/master/web/index.css +++ /dev/null @@ -1,25 +0,0 @@ -@import '~antd/dist/antd.dark.css'; - -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - - -[data-theme="compact"] p, p { - margin: 0; -} - -.row-links{ - cursor: pointer; -} - diff --git a/packages/master/web/index.jsx b/packages/master/web/index.jsx index 50599f7a..30fdbf62 100644 --- a/packages/master/web/index.jsx +++ b/packages/master/web/index.jsx @@ -1 +1 @@ -import("./bootstrap"); +import("@clusterio/web_ui/src/bootstrap"); diff --git a/packages/master/web/layout/index.jsx b/packages/master/web/layout/index.jsx deleted file mode 100644 index 76f6ce24..00000000 --- a/packages/master/web/layout/index.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import { Layout, Menu } from "antd"; - -import { Route, Link, withRouter } from "react-router-dom"; - -import React, { Component } from "react"; -import { Dashboard } from "../pages/dashboard"; -import { InstancesTable } from "../pages/instances/instancesTable"; -import { SlavesTable } from "../pages/slaves/slavesTable"; -import { UsersTable } from "../pages/users/usersTable"; -import { RolesTable } from "../pages/roles/rolesTable"; -import { PermissionsTable } from "../pages/permissions/permissionsTable"; -import { InstanceView } from "../pages/instances/instance"; -import { UserView } from "../pages/users/user"; -import { RoleView } from "../pages/roles/role"; -import { Settings } from "../pages/settings/settings"; - -const { Content, Footer, Sider } = Layout; -const { SubMenu } = Menu; - -class SiteLayout extends Component { - - constructor(props) { - super(props); - const { location } = props; - - this.state = { - collapsed: false, - setcollapsed: false, - tags: [], - setTags: [], - location, - siteSettings: {}, - factoryModel: [], - }; - } - - onCollapse() { - this.setState({ collapsed: !this.state.collapsed }); - } - - titleCase(text) { - return text.substring(0, 1).toUpperCase() + text.substring(1); - } - - replaceParametersInUrl({ endpoint, params }) { - let path = endpoint; - console.log("endpoint", params); - Object.keys(params).forEach(key => { - path = path.replace(":" + key, params[key]); - }); - - return path; - } - - - render() { - return ( - - {document.location.search !== "?hideExtras" ? ( - -

-
- {" "} - {this.state.collapsed ? ( - - ) : ( - - )} -
-

-

- - { - let x = this.state.location.pathname.split("/"); - x.shift(); - return x; - })()} - mode="inline" - > - - Slaves - - - - Instances - - - - Users - - - - Roles - - - - Permissions - - - - Settings - - - -
- ) : ( - "" - )} - - - - - - - - - - - - - - - - - -
- ); - } -} - -export default withRouter(SiteLayout); diff --git a/packages/master/web/pages/dashboard.jsx b/packages/master/web/pages/dashboard.jsx deleted file mode 100644 index 4089dae5..00000000 --- a/packages/master/web/pages/dashboard.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, {Component} from "react"; - -export class Dashboard extends Component { - render(){ - return

Dashboard

; - } -} diff --git a/packages/master/web/pages/instances/instance.jsx b/packages/master/web/pages/instances/instance.jsx deleted file mode 100644 index c0ccdf13..00000000 --- a/packages/master/web/pages/instances/instance.jsx +++ /dev/null @@ -1,454 +0,0 @@ -import React, { Component } from "react"; -import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd"; -import { - listSlaves, - listInstances, - startInstance, - stopInstance, - setInstanceOutputSubscriptions, - deleteInstance, - assignInstance, - createSave, - getInstanceConfig, - setInstanceConfigField, - sendRcon, -} from "../../util/wslink"; -import DataTable from "../../components/data-table"; -import notify from "../../util/notify"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; -import EditOutlined from "@ant-design/icons/EditOutlined"; -import PlusOutlined from "@ant-design/icons/PlusOutlined"; - -const { Option } = Select; - -export class InstanceView extends Component { - constructor(props) { - super(props); - this.state = { - instance: {}, - slaves: [], - logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }], - }; - this.messagesEndRef = React.createRef(); - } - - navigate(url) { - this.props.history.push(url); - } - - async componentDidMount() { - let id = this.props.match.params.id; - - await this.getData(); - - await setInstanceOutputSubscriptions({ - instance_id: Number(id), - }); - window.instanceOutputEventHandler = ({ instance_id, output }) => { - if (instance_id === Number(id)) { - this.setState({ - logLines: [...this.state.logLines, output], - }); - } - // Automatically scroll to bottom on new line. - // this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - }; - } - - async getData() { - let id = this.props.match.params.id; - - let instances = await listInstances(); - let slaves = await listSlaves(); - - this.setState({ - instance: instances.find(x => x.id === Number(id)), - slaves, - }); - - let config = await getInstanceConfig({ instance_id: Number(id) }); - console.log(config); - this.setState({ - config, - }); - } - - async start() { - this.setState({ - starting: true, - }); - try { - await startInstance({ - instance_id: this.state.instance.id, - save: null, - }); - } catch (e) { - notify("Error starting instance", "error", e.message); - this.setState({ - starting: false, - }); - return; - } - notify("Instance started"); - this.setState({ - starting: false, - }); - this.getData(); - } - - async stop() { - this.setState({ - stopping: true, - }); - try { - await stopInstance({ - instance_id: this.state.instance.id, - }); - } catch (e) { - notify("Error stopping instance", "error", e.message); - this.setState({ - stopping: false, - }); - return; - } - notify("Instance stopped"); - this.setState({ - stopping: false, - }); - this.getData(); - } - - async createSave() { - this.setState({ - creatingSave: true, - }); - try { - await createSave({ - instance_id: this.state.instance.id, - }); - } catch (e) { - notify("Error creating save", "error", e.message); - this.setState({ - creatingSave: false, - }); - return; - } - notify("Save created", "success"); - this.setState({ - creatingSave: false, - }); - } - - async onConfigEditFinish(values) { - console.log("Completed config edit with", values); - for (let k in values) { - if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) { - let value = values[k]; - if (typeof value === "boolean") value = value.toString(); - console.log("Setting", k, value); - await setInstanceConfigField({ - instance_id: this.state.instance.id, - field: k, - value: value, - }); - } - } - notify("Set configuration fields", "success"); - this.setState({ - editingConfig: false, - }); - this.getData(); - } - - render() { - console.log(this.state); - let { instance, slaves } = this.state; - let slave = slaves && slaves.find(x => x.id === instance.assigned_slave); - return -

{instance.name}

-

ID: {instance.id}

-

Slave: {slave && slave.name}

- {instance.status !== "running" && } - {instance.status !== "stopped" && } - {instance.status !== "running" && } - { - let result = await assignInstance({ instance_id: instance.id, slave_id: newSlaveId }); - notify("Assigned to slave", "success"); - this.getData(); - }} - > - {this.state.slaves.map(newSlave => )} - - } - trigger="click" - type={instance.status === "unassigned" ? "primary" : "default"} - > - - - { - await deleteInstance({ instance_id: instance.id }); - notify("Deleted instance " + instance.id, "success"); - this.navigate("/instances"); - }} - > - Delete this instance permanently - - } - trigger="click" - > - - - -
- {this.state.logLines.map(item =>

{item.time} {item.level} {item.file}: {item.message}

)} -
-
- {/* rcon input field */} -
{ - await sendRcon({ instance_id: instance.id, command: values.command }); - }} - > - - - -
- {this.state.config && this.renderConfig(this.state.config)} -
; - } - - getInstanceConfigAsKvObject(config) { - let object = {}; - for (let group of config.serialized_config.groups) { - for (let fieldName of Object.keys(group.fields)) { - object[group.name + "." + fieldName] = group.fields[fieldName]; - } - } - return object; - } - - renderConfig(config) { - const layout = { - labelCol: { - span: 6, - }, - wrapperCol: { - span: 14, - }, - }; - const tailLayout = { - wrapperCol: { - offset: 6, - span: 14, - }, - }; - return -
- {this.state.editingConfig ? - - - - : - - } - - {config && config.serialized_config.groups.map(group => <> - - {group.name} - {/* {Object.keys(group.fields).map(field => <> - {field}: - {JSON.stringify(group.fields[field])} -
- )} */} - {console.log(group)} - {Object.keys(group.fields).map(field => )} - - )} -
-
-
; - } -} - -function renderDisplayField(value) { - if (typeof value === "object") { - return JSON.stringify(value); - } - if (typeof value === "boolean") { - return ; - } - return value.toString(); -} -function renderFormField(value) { - if (typeof value === "object") { - return ; - } - if (typeof value === "boolean") { - return ; - } - return ; -} - -class ObjectInput extends Component { - constructor(props) { - super(props); - console.log(this.props); - /* - value: { - auto_pause: false, - tags: ["clusterio"] - }, - id: "factorio.settings", - onChange: f() - */ - this.state = {}; - } - - onChange(e, key) { - if (e.target.type === "checkbox") { - // if (e.target.checked !== undefined) { - // Its a checkbox, set value to true/false - let value = { ...this.props.value }; - value[key] = e.target.checked || false; - this.props.onChange(value); - } else if (e.target.type === "text") { - // } else if (e.target.value !== undefined) { - // its probably a text input - let value = { ...this.props.value }; - value[key] = e.target.value || ""; - this.props.onChange(value); - } - } - - render() { - let { value } = this.props; - let ret = []; - for (let key in value) { - if (typeof value[key] === "boolean") { - ret.push( this.onChange(e, key)} - > - {key} - - ); - } else if (typeof value[key] === "string") { - ret.push(
{key} this.onChange(e, key)} - />
- ); - } - } - ret.push( - this.setState({ newInput: e.target.value })} /> - - } - trigger="click" - visible={this.state.addPopoverVisible} - handleVisibleChange={visible => this.setState({ addPopoverVisible: visible })} - > - - ); - return {ret}; - } -} - -class ConfigFormItem extends Component { - render() { - let { field, group, editingConfig } = this.props; - return - {editingConfig ? - renderFormField(group.fields[field]) - : - renderDisplayField(group.fields[field]) - } - ; - } -} diff --git a/packages/master/web/pages/instances/instancesTable.jsx b/packages/master/web/pages/instances/instancesTable.jsx deleted file mode 100644 index 34fb1be1..00000000 --- a/packages/master/web/pages/instances/instancesTable.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { Component } from "react"; -import { Card } from "antd"; -import { listSlaves, listInstances, createInstance } from "../../util/wslink"; -import DataTable from "../../components/data-table"; -const config = require("@clusterio/lib/config"); - -export class InstancesTable extends Component { - navigate(url) { - this.props.history.push(url); - } - - render() { - return -

Instances

- { - let instanceConfig = new config.InstanceConfig(); - await instanceConfig.init(); - console.log("Base config", instanceConfig); - instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); - let response = await createInstance(serialized_config); - console.log("Created instance", response); - }, - }} - DataFunction={listInstances} - TableProps={{ - onRow: (record, rowIndex) => ({ - onClick: event => { - this.navigate(`/instances/${record.id}/view`); - }, - }), - }} - /> -
; - } -} diff --git a/packages/master/web/pages/permissions/permissionsTable.jsx b/packages/master/web/pages/permissions/permissionsTable.jsx deleted file mode 100644 index a282b504..00000000 --- a/packages/master/web/pages/permissions/permissionsTable.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { Component } from "react"; -import { Card } from "antd"; -import { listSlaves, listInstances, listPermissions } from "../../util/wslink"; -import DataTable from "../../components/data-table"; - -export class PermissionsTable extends Component { - navigate(url) { - this.props.history.push(url); - } - - render() { - return -

Permissions

- ({ - onClick: event => { - this.navigate(`/permissions/${record.id}/view`); - }, - }), - }} - /> -
; - } -} diff --git a/packages/master/web/pages/roles/role.jsx b/packages/master/web/pages/roles/role.jsx deleted file mode 100644 index 66e3adc6..00000000 --- a/packages/master/web/pages/roles/role.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { Component } from "react"; - -import { Card, Button, Popover, Select, Transfer } from "antd"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; - -import { - listSlaves, - listInstances, - startInstance, - stopInstance, - setInstanceOutputSubscriptions, - deleteInstance, - assignInstance, - createSave, - getInstanceConfig, - setInstanceConfigField, - sendRcon, - deleteUser, - listUsers, - listRoles, - setRoles, - listPermissions, - updateRole, -} from "../../util/wslink"; -import DataTable from "../../components/data-table"; -import notify from "../../util/notify"; - -const { Option } = Select; - -export class RoleView extends Component { - constructor(props) { - super(props); - this.state = { - role: {}, - }; - this.messagesEndRef = React.createRef(); - } - - navigate(url) { - this.props.history.push(url); - } - - async componentDidMount() { - await this.getData(); - } - - async getData() { - let id = this.props.match.params.id; - - let roles = await listRoles(); - let permissions = await listPermissions(); - - this.setState({ - role: roles.find(x => x.id === Number(id)), - permissions, - }); - } - - async handleChange(targetKeys) { - this.setState({ - role: { - ...this.state.role, - permissions: targetKeys, - }, - }); - let response = await updateRole({ - ...this.state.role, - permissions: targetKeys, - }); - console.log("Updated user roles", response); - } - - render() { - console.log(this.state); - let { role, permissions } = this.state; - return - { - await deleteUser({ role: role.name }); - notify("Deleted role " + role.name, "success"); - this.navigate("/roles"); - }} - > - Delete this role permanently - - } - trigger="click" - > - - -

{role.name}

-

Assign roles

- - option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1} - targetKeys={role.permissions} - onChange={this.handleChange.bind(this)} - render={item => item.name} - /> -
; - } -} diff --git a/packages/master/web/pages/roles/rolesTable.jsx b/packages/master/web/pages/roles/rolesTable.jsx deleted file mode 100644 index b95071ad..00000000 --- a/packages/master/web/pages/roles/rolesTable.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { Component } from "react"; -import { Card } from "antd"; -import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink"; -import DataTable from "../../components/data-table"; - -export class RolesTable extends Component { - navigate(url) { - this.props.history.push(url); - } - - render() { - return -

Roles

- { - let response = await createRole({name: args.name, description: args.description}); - console.log("Created role",response); - }, - }} - DataFunction={listRoles} - TableProps={{ - onRow: (record, rowIndex) => ({ - onClick: event => { - this.navigate(`/roles/${record.id}/view`); - }, - }), - }} - /> -
; - } -} diff --git a/packages/master/web/pages/settings/settings.jsx b/packages/master/web/pages/settings/settings.jsx deleted file mode 100644 index 036884ba..00000000 --- a/packages/master/web/pages/settings/settings.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { Component } from "react"; -import { Card, Button, Input, Form } from "antd"; - -const layout = { - labelCol: { - span: 4, - }, - wrapperCol: { - span: 16, - }, -}; -const tailLayout = { - wrapperCol: { - offset: 4, - span: 16, - }, -}; - -export class Settings extends Component { - constructor(props) { - super(props); - this.state = { - role: {}, - }; - } - - navigate(url) { - this.props.history.push(url); - } - - render() { - console.log(this.state); - return -

Web interface settings

-
{ - console.log("Saving settings",values); - for (let key of Object.keys(values)){ - localStorage.setItem(key, values[key].replace("https://", "wss://").replace("http://", "ws://")); - } - // Force refresh to apply changes - document.location = document.location; - }} - > - - - - - - -
-
; - } -} diff --git a/packages/master/web/pages/slaves/slavesTable.jsx b/packages/master/web/pages/slaves/slavesTable.jsx deleted file mode 100644 index c46f3096..00000000 --- a/packages/master/web/pages/slaves/slavesTable.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { Component } from "react"; -import { Card } from "antd"; -import { listSlaves, listInstances } from "../../util/wslink"; -import DataTable from "../../components/data-table"; - -export class SlavesTable extends Component { - navigate(url) { - this.props.history.push(url); - } - - render() { - return -

Slaves

- ({ - onClick: event => { - this.navigate(`/slaves/${record.id}/view`); - }, - }), - }} - /> -
; - } -} diff --git a/packages/master/web/pages/users/user.jsx b/packages/master/web/pages/users/user.jsx deleted file mode 100644 index 40739bdc..00000000 --- a/packages/master/web/pages/users/user.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { Component } from "react"; -import { Card, Button, Popover, Select, Transfer } from "antd"; -import { - deleteUser, - listUsers, - listRoles, - setRoles, -} from "../../util/wslink"; -import DataTable from "../../components/data-table"; -import notify from "../../util/notify"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; - -const { Option } = Select; - -export class UserView extends Component { - constructor(props) { - super(props); - this.state = { - user: {}, - }; - this.messagesEndRef = React.createRef(); - } - - navigate(url) { - this.props.history.push(url); - } - - async componentDidMount() { - await this.getData(); - } - - async getData() { - let name = this.props.match.params.id; - - let users = await listUsers(); - let roles = await listRoles(); - - this.setState({ - user: users.find(x => x.name === name), - roles, - }); - } - - async handleChange(targetKeys) { - this.setState({ - user: { - ...this.state.user, - roles: targetKeys, - }, - }); - let response = await setRoles({ - name: this.state.user.name, - roles: targetKeys, - }); - console.log("Updated user roles",response); - }; - - render() { - console.log(this.state); - let { user, roles } = this.state; - return -

{user.name}

-

Assign roles

- option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1} - targetKeys={user.roles} - onChange={this.handleChange.bind(this)} - render={item => item.name} - /> - { - await deleteUser({ name: user.name }); - notify("Deleted user " + user.name, "success"); - this.navigate("/users"); - }} - > - Delete this user from the cluster - - } - trigger="click" - > - - -
; - } -} diff --git a/packages/master/web/pages/users/usersTable.jsx b/packages/master/web/pages/users/usersTable.jsx deleted file mode 100644 index 154d70a1..00000000 --- a/packages/master/web/pages/users/usersTable.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { Component } from "react"; -import { Card } from "antd"; -import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink"; -import DataTable from "../../components/data-table"; - -export class UsersTable extends Component { - navigate(url) { - this.props.history.push(url); - } - - render() { - return -

Slaves

- { - let response = await createUser({name: args.name}); - console.log("Created user",response); - }, - }} - DataFunction={listUsers} - TableProps={{ - onRow: (record, rowIndex) => ({ - onClick: event => { - this.navigate(`/users/${record.name}/view`); - }, - }), - }} - /> -
; - } -} diff --git a/packages/master/web/util/notify.jsx b/packages/master/web/util/notify.jsx deleted file mode 100644 index 11805738..00000000 --- a/packages/master/web/util/notify.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { notification } from "antd"; - -export default function notify(message, type = "info", description = undefined) { - notification[type]({ - message: typeof message === "string" ? message : "ERROR: See console", - description, - placement: "bottomRight", - }); - if (typeof message !== "string") console.error(message); -} diff --git a/packages/master/webpack.config.js b/packages/master/webpack.config.js index 6ed48d69..756d809d 100644 --- a/packages/master/webpack.config.js +++ b/packages/master/webpack.config.js @@ -3,9 +3,10 @@ const path = require("path"); const webpack = require("webpack"); const { merge } = require("webpack-merge"); -const common = require("./webpack.common"); +const common = require("@clusterio/web_ui/webpack.common"); module.exports = (env = {}) => merge(common(env), { + context: __dirname, entry: "./web/index.jsx", devServer: { contentBase: "./static", @@ -19,6 +20,7 @@ module.exports = (env = {}) => merge(common(env), { name: "master", shared: { "@clusterio/lib": { singleton: true }, + "@clusterio/web_ui": { singleton: true }, "ajv": {}, "antd": { singleton: true }, "react": { singleton: true }, diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json new file mode 100644 index 00000000..47b10086 --- /dev/null +++ b/packages/web_ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "@clusterio/web_ui", + "description": "Clusterio web interface implementation", + "author": "Hornwitser", + "license": "MIT", + "version": "2.0.0-alpha.1", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "clusterio" + ], + "engines": { + "node": ">=10.16 <11 || >= 12" + }, + "dependencies": { + "@ant-design/icons": "^4.2.2", + "@clusterio/lib": "^2.0.0-alpha.1", + "antd": "^4.6.5", + "clean-webpack-plugin": "^3.0.0", + "events": "^3.2.0", + "path-browserify": "^1.0.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/master/web/basename.jsx b/packages/web_ui/src/basename.jsx similarity index 100% rename from packages/master/web/basename.jsx rename to packages/web_ui/src/basename.jsx diff --git a/packages/master/web/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx similarity index 82% rename from packages/master/web/bootstrap.jsx rename to packages/web_ui/src/bootstrap.jsx index f534ccac..cda9cf8b 100644 --- a/packages/master/web/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -8,6 +8,7 @@ import libConfig from "@clusterio/lib/config"; import App from "./components/App"; import { Control, ControlConnector } from "./util/websocket"; + async function loadScript(url) { let script = document.createElement("script"); script.src = url; @@ -61,17 +62,10 @@ async function load() { let wsUrl = new URL(window.webRoot, document.location); wsUrl.protocol = wsUrl.protocol.replace("http", "ws"); - let controlConnector = new ControlConnector(wsUrl, 10, localStorage.getItem("master_token")); - // XXX come up with a better way of sharing this - window.control = new Control(controlConnector, []); - try { - await controlConnector.connect(); - } catch (err) { - // XXX: What now? - console.log(err); - } + let controlConnector = new ControlConnector(wsUrl, 10); + let control = new Control(controlConnector, []); - ReactDOM.render(, document.getElementById("root")); + ReactDOM.render(, document.getElementById("root")); } load().catch(console.log); diff --git a/packages/web_ui/src/components/App.jsx b/packages/web_ui/src/components/App.jsx new file mode 100644 index 00000000..454ce9be --- /dev/null +++ b/packages/web_ui/src/components/App.jsx @@ -0,0 +1,83 @@ +import events from "events"; +import React, { useEffect, useState } from "react"; +import { BrowserRouter } from "react-router-dom"; + +import basename from "../basename"; +import SiteLayout from "./SiteLayout"; +import ControlContext from "./ControlContext"; +import LoginForm from "./LoginForm"; + +import { Card, Spin } from "antd"; + + +export default function App(props) { + let [connected, setConnected] = useState(false); + let [token, setToken] = useState(localStorage.getItem("master_token") || null); + let connector = props.control.connector; + + function clearToken() { + setToken(null); + localStorage.removeItem("master_token"); + } + + useEffect(() => { + function onConnect() { + localStorage.setItem("master_token", token); + setConnected(true); + } + + function onClose() { + setConnected(false); + } + + function onError(err) { + console.error("Unexpected error in connector", err); + clearToken(); + setConnected(false); + } + + connector.on("connect", onConnect); + connector.on("close", onClose); + connector.on("error", onError); + + if (token && !connected) { + connector.token = token; + connector.connect(); + } + + return () => { + connector.off("error", onError); + connector.off("close", onClose); + connector.off("connect", onConnect); + }; + }); + + let page; + if (connected) { + page = ; + + } else if (token) { + page =
+ +

Connecting

+ +
+
; + + } else { + page =
+ +

Clusterio

+ +
+
; + } + + return ( + + + {page} + + + ); +} diff --git a/packages/web_ui/src/components/ControlContext.jsx b/packages/web_ui/src/components/ControlContext.jsx new file mode 100644 index 00000000..979faf93 --- /dev/null +++ b/packages/web_ui/src/components/ControlContext.jsx @@ -0,0 +1,3 @@ +import React from "react"; + +export default React.createContext(); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx new file mode 100644 index 00000000..91c51d33 --- /dev/null +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -0,0 +1,604 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { + notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal, + Popconfirm, Row, Select, Space, Spin, Tree, Typography, +} from "antd"; +import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; +import { notifyErrorHandler } from "../util/notify"; + +const { Title, Paragraph } = Typography; + + +function useInstance(id) { + let control = useContext(ControlContext); + let [instance, setInstance] = useState({ loading: true }); + + function updateInstance() { + if (!Number.isInteger(id)) { + setInstance({ missing: true }); + return; + } + + // XXX optimize by requesting only the instance in question + libLink.messages.listInstances.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setInstance({ missing: true }); + } else { + setInstance({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateInstance(); + }, [id]); + + return [instance, updateInstance]; +} + +function useSlave(id) { + let control = useContext(ControlContext); + let [slave, setSlave] = useState({ loading: true }); + + function updateSlave() { + if (!Number.isInteger(id)) { + setSlave({ missing: true }); + return; + } + + // XXX optimize by requesting only the slave in question + libLink.messages.listSlaves.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setSlave({ missing: true }); + } else { + setSlave({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateSlave(); + }, [id]); + + return [slave, updateSlave]; +} + +function formatOutputColored(output, key) { + let time = ""; + if (output.format === "seconds") { + time = {output.time.padStart(8)} ; + } else if (output.format === "date") { + time = {output.time} ; + } + + let info = ""; + if (output.type === "log") { + let level = output.level; + if (level === "Info") { + level = {level}; + } else if (output.level === "Warning") { + level = {level}; + } else if (output.level === "Error") { + level = {level}; + } + + info = <>{level} {output.file}: ; + + } else if (output.type === "action") { + info = <>[{output.action}] ; + } + + return {time}{info}{output.message}
; +} + +function InstanceConsole(props) { + let control = useContext(ControlContext); + let [lines, setLines] = useState([{ message: "" }]); + + useEffect(() => { + function outputHandler(output) { + setLines(currentLines => currentLines.concat([output])); + } + + control.onInstanceOutput(props.id, outputHandler); + return () => { + control.offInstanceOutput(props.id, outputHandler); + }; + }, [props.id]); + + return <> + + {lines.map(formatOutputColored)} +
+ + ; +} + +function InstanceRcon(props) { + let control = useContext(ControlContext); + let [output, setOutput] = useState(null); + let [running, setRunning] = useState(false); + + async function sendCommand(command) { + if (!command) { + setOutput(null); + return; + } + + setRunning(true); + try { + let result = await libLink.messages.sendRcon.send(control, { + instance_id: props.id, + command: command, + }); + setOutput(result.result); + } finally { + setRunning(false); + } + } + + return <> + {output && <> + Rcon result + {output} + } + sendCommand(command).catch(notifyErrorHandler("Error sending command"))} + loading={running} + /> + ; +} + +function AssignInstanceModal(props) { + let [visible, setVisible] = useState(false); + let [slaves, setSlaves] = useState([]); + let [loading, setLoading] = useState(true); + let [applying, setApplying] = useState(false); + let [form] = Form.useForm(); + let control = useContext(ControlContext); + + function open() { + setVisible(true); + libLink.messages.listSlaves.send(control).then((result) => { + setSlaves(result.list); + setLoading(false); + }); + } + + function handleAssign() { + let slaveId = form.getFieldValue("slave"); + if (slaveId === undefined) { + setVisible(false); + return; + } + + setApplying(true); + libLink.messages.assignInstanceCommand.send(control, { + instance_id: props.id, + slave_id: slaveId, + }).then(() => { + setVisible(false); + if (props.onFinish) { + props.onFinish(); + } + }).catch( + notifyErrorHandler("Error assigning instance") + ).finally( + () => setApplying(false) + ); + } + + function handleCancel() { + setVisible(false); + } + + return <> + + + + Select a Slave to assign this instance to. Assignment + creates the necessary files on the slave to start up + the instance. Note that reassigning an instance from + one slave to another will not move the server save over. + +
+ + + +
+
+ ; +} + +function StartStopInstanceButton(props) { + let control = useContext(ControlContext); + let [switching, setSwitching] = useState(false); + + function onClick() { + setSwitching(true); + let action; + if (props.instance["status"] === "stopped") { + action = libLink.messages.startInstance.send( + control, { instance_id: props.instance["id"], save: null } + ).catch( + notifyErrorHandler("Error starting instance") + ); + + } else if (["starting", "running"].includes(props.instance["status"])) { + action = libLink.messages.stopInstance.send( + control, { instance_id: props.instance["id"] } + ).catch( + notifyErrorHandler("Error stopping instance") + ); + + } else { + setSwitching(false); + return; + } + + action.finally(() => { + setSwitching(false); + if (props.onFinish) { + props.onFinish(); + } + }); + } + + return ; +} + + +function InstanceConfigTree(props) { + let control = useContext(ControlContext); + let [config, setConfig] = useState(null); + let [form] = Form.useForm(); + let [changedFields, setChangedFields] = useState(new Set()); + let [errorFields, setErrorFields] = useState(new Map()); + let [applying, setApplying] = useState(false); + + async function updateConfig() { + let result = await libLink.messages.getInstanceConfig.send(control, { + instance_id: props.id, + }); + + let newConfig = new libConfig.InstanceConfig(); + await newConfig.load(result.serialized_config); + setConfig(newConfig); + } + + useEffect(() => { updateConfig(); }, [props.id]); + + function renderInput(group, def) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; + } + + if (config === null) { + return ; + } + + let initialValues = {}; + let treeData = []; + let propsMap = new Map(); + for (let [name, GroupClass] of libConfig.InstanceConfig.groups) { + let group = config.group(name); + let treeNode = { + key: group.name, + title: group.name, + children: [], + }; + + for (let def of GroupClass.definitions.values()) { + let value = group.get(def.name); + let fieldName = `${group.name}.${def.name}`; + let childNode = { + key: fieldName, + children: [], + }; + + if (def.type === "object") { + childNode.title = ; + for (let prop of Object.keys(value)) { + let propPath = `${group.name}.${def.name}.${prop}`; + childNode.children.push({ + key: propPath, + title: { + try { + JSON.parse(fieldValue); + } catch (err) { + return Promise.reject(new Error("Must be valid json")); + } + return Promise.resolve(); + }}]} + > + + , + }); + + initialValues[propPath] = JSON.stringify(value[prop]); + propsMap.set(propPath, [fieldName, prop]); + } + + } else { + childNode.title = + {renderInput(group, def)} + ; + initialValues[fieldName] = value; + } + + treeNode.children.push(childNode); + } + + treeData.push(treeNode); + } + + function onValuesChange(changedValues) { + let newChangedFields = new Set(changedFields); + let changed = false; + for (let [key, value] of Object.entries(changedValues)) { + if (initialValues[key] === value) { + changed = newChangedFields.delete(key) || changed; + } else { + changed = !newChangedFields.has(key) || changed; + newChangedFields.add(key); + } + } + + if (changed) { + setChangedFields(newChangedFields); + } + } + + return <> + + Config + <Space style={{ float: "right" }}> + <Button + type="primary" + size="small" + loading={applying} + onClick={async () => { + setApplying(true); + for (let field of changedFields) { + let value = form.getFieldValue(field); + let request; + if (propsMap.has(field)) { + let [fieldName, prop] = propsMap.get(field); + try { + value = JSON.parse(value); + } catch (err) { + continue; + } + + request = libLink.messages.setInstanceConfigProp.send(control, { + instance_id: props.id, + field: fieldName, + prop, + value, + }); + + } else { + request = libLink.messages.setInstanceConfigField.send(control, { + instance_id: props.id, + field, + value: String(value), + }); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err) { + setErrorFields(errors => { + let newErrors = new Map(errors); + newErrors.set(field, err.message); + return newErrors; + }); + } + } + + await updateConfig(); + setApplying(false); + + if (props.onApply) { + props.onApply(); + } + }} + disabled={changedFields.size === 0} + > + Apply + </Button> + <Button + size="small" + onClick={() => { + form.resetFields(); + setChangedFields(new Set()); + setErrorFields(new Map()); + }} + > + Revert + </Button> + </Space> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} + +export default function InstanceViewPage(props) { + let params = useParams(); + let instanceId = Number(params.id); + + let history = useHistory(); + + let control = useContext(ControlContext); + let [instance, updateInstance] = useInstance(instanceId); + let [slave] = useSlave(Number(instance["assigned_slave"])); + + let [creatingSave, setCreatingSave] = useState(false); + + let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }]; + if (instance.loading) { + return ; + } + + if (instance.missing) { + return +

Instance not found

+

Instance with id {instanceId} was not found on the master server.

+
; + } + + let instanceButtons = + + {instance.status === "stopped" && } + { + libLink.messages.deleteInstance.send( + control, { instance_id: instanceId } + ).then(() => { + history.push("/instances"); + }).catch(notifyErrorHandler("Error deleting instance")); + }} + > + + + ; + + let assigned = instance["assigned_slave"] !== null; + return + + + {!assigned + ? Unassigned + : slave["name"] || instance["assigned_slave"] + } + + + {instance["status"]} + + + Console + + + + + ; +} diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx new file mode 100644 index 00000000..790525d1 --- /dev/null +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -0,0 +1,55 @@ +import React, { useContext } from "react"; +import { useHistory } from "react-router-dom"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import DataTable from "./data-table"; +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; + + +export default function InstancesPage() { + let control = useContext(ControlContext); + let history = useHistory(); + + async function listInstances() { + let result = await libLink.messages.listInstances.send(control); + return result["list"].map(item => ({ + key: item["id"], + "Name": item["name"], + "Assigned Slave": item["assigned_slave"], + "Status": item["status"], + })); + } + + return +

Instances

+ { + let instanceConfig = new libConfig.InstanceConfig(); + await instanceConfig.init(); + console.log("Base config", instanceConfig); + instanceConfig.set("instance.name", args.name); + let serialized_config = instanceConfig.serialize(); + let response = await libLink.messages.createInstance.send(control, { serialized_config }); + console.log("Created instance", response); + }, + }} + TableProps={{ + onRow: (record, rowIndex) => ({ + onClick: event => { + console.log(record, rowIndex); + history.push(`/instances/${record.key}/view`); + }, + }), + }} + /> +
; +} diff --git a/packages/web_ui/src/components/LoginForm.jsx b/packages/web_ui/src/components/LoginForm.jsx new file mode 100644 index 00000000..6a5a13cf --- /dev/null +++ b/packages/web_ui/src/components/LoginForm.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Form, Input, Button } from "antd"; +import LockOutlined from "@ant-design/icons/LockOutlined"; + +export default function LoginForm(props) { + return ( +
{ props.setToken(values.token); }} + > + + } + type="password" + placeholder="Authentication Token" + /> + + +
+ ); +} + diff --git a/packages/web_ui/src/components/PageLayout.jsx b/packages/web_ui/src/components/PageLayout.jsx new file mode 100644 index 00000000..dda21b32 --- /dev/null +++ b/packages/web_ui/src/components/PageLayout.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { Layout, Menu, Breadcrumb, Typography } from "antd"; + +const { Content } = Layout; + +export default function PageLayout(props) { + return <> + + {props.nav.map((part, index) => + {part.path ? {part.name} : part.name} + )} + + + {props.children} + + ; +} diff --git a/packages/web_ui/src/components/RoleViewPage.jsx b/packages/web_ui/src/components/RoleViewPage.jsx new file mode 100644 index 00000000..feb740ad --- /dev/null +++ b/packages/web_ui/src/components/RoleViewPage.jsx @@ -0,0 +1,151 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd"; +import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; + +import libLink from "@clusterio/lib/link"; +import libUsers from "@clusterio/lib/users"; + +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; +import { notifyErrorHandler } from "../util/notify"; + + +function useRole(id) { + let control = useContext(ControlContext); + let [role, setRole] = useState({ loading: true }); + + function updateRole() { + // XXX optimize by requesting only the role in question + libLink.messages.listRoles.send(control).then(result => { + let match = result.list.find(u => u.id === id); + if (!match) { + setRole({ missing: true }); + } else { + setRole({ + ...match, + present: true, + }); + } + }); + } + + useEffect(() => { + updateRole(); + }, [id]); + + return [role, updateRole]; +} + +export default function RoleViewPage() { + let params = useParams(); + let roleId = Number(params.id); + + let history = useHistory(); + + let control = useContext(ControlContext); + let [role, updateRole] = useRole(roleId); + let [edited, setEdited] = useState(false); + + + let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }]; + if (role.loading) { + return +

{roleId}

+ +
; + } + + if (role.missing) { + return +

Role not found

+

Role with id {roleId} was not found on the master server.

+
; + } + + let initialValues = { + name: role["name"], + description: role["description"], + permissions: { + ...Object.fromEntries([...libUsers.permissions.values()].map(perm => [ + perm.name, role["permissions"].includes(perm.name), + ])), + }, + }; + + return +
{ setEdited(true); }} + onFinish={values => { + console.log(values); + let newPermissions = []; + for (let [name, value] of Object.entries(values.permissions)) { + if (value) { newPermissions.push(name); } + } + + libLink.messages.updateRole.send(control, { + id: roleId, + name: values.name || "", + description: values.description || "", + permissions: newPermissions, + }).then(() => { + setEdited(false); + updateRole(); + }).catch(notifyErrorHandler("Error applying changes")); + }} + > + + +

{role["name"]}

+ + + + + { + libLink.messages.deleteRole.send( + control, { id: roleId } + ).then(() => { + history.push("/roles"); + }).catch(notifyErrorHandler("Error deleting role")); + }} + > + + + + +
+ + + + + + +

Permissions

+ {[...libUsers.permissions.values()].map(({name, title, description}) => ( + + + + ))} +
+
; +} diff --git a/packages/web_ui/src/components/RolesPage.jsx b/packages/web_ui/src/components/RolesPage.jsx new file mode 100644 index 00000000..bbd81bd0 --- /dev/null +++ b/packages/web_ui/src/components/RolesPage.jsx @@ -0,0 +1,51 @@ +import React, { useContext } from "react"; +import { useHistory } from "react-router-dom"; + +import libLink from "@clusterio/lib/link"; + +import DataTable from "./data-table"; +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; + + +export default function UsersPage() { + let control = useContext(ControlContext); + let history = useHistory(); + + async function listRoles() { + let result = await libLink.messages.listRoles.send(control); + return result["list"].map(item => ({ + key: item["id"], + "Name": item["name"], + "Description": item["description"], + })); + } + + return +

Roles

+ { + await libLink.messages.createRole.send(control, { + name: args.name, + description: args.description || "", + permissions: [], + }); + }, + }} + TableProps={{ + onRow: (record, rowIndex) => ({ + onClick: event => { + console.log(record, rowIndex); + history.push(`/roles/${record.key}/view`); + }, + }), + }} + /> +
; +} diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx new file mode 100644 index 00000000..88756b87 --- /dev/null +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from "react"; +import { Switch, Route, useHistory } from "react-router-dom"; +import { Layout, Menu } from "antd"; +import webUiPackage from "../../package.json"; + +const { Header, Sider } = Layout; + + +import SlavesPage from "./SlavesPage"; +import InstancesPage from "./InstancesPage"; +import InstanceViewPage from "./InstanceViewPage"; +import UsersPage from "./UsersPage"; +import UserViewPage from "./UserViewPage"; +import RolesPage from "./RolesPage"; +import RoleViewPage from "./RoleViewPage"; + +const pages = [ + { path: "/instances/:id/view", sidebarPath: "/instances", content: }, + { path: "/users/:name/view", sidebarPath: "/users", content: }, + { path: "/roles/:id/view", sidebarPath: "/roles", content: }, +]; + +const sidebar = [ + { name: "Slaves", path: "/slaves", content: }, + { name: "Instances", path: "/instances", content: }, + { name: "Users", path: "/users", content: }, + { name: "Roles", path: "/roles", content: }, +]; + + +export default function SiteLayout(props) { + let history = useHistory(); + let [sidebarPath, setSidebarPath] = useState(null); + + function SetSidebar(setSidebarProps) { + useEffect(() => { + setSidebarPath(setSidebarProps.path); + }); + return null; + } + + return +
+
+ Clusterio + { webUiPackage.version } + + Dashboard + +
+ + + history.push(key)} + > + {sidebar.map(({name, path}) => {name})} + + + + + {sidebar.map(({path, content}) => + + {content} + )} + {pages.map(page => + + {page.content} + )} + + + + + + +
; +} diff --git a/packages/web_ui/src/components/SlavesPage.jsx b/packages/web_ui/src/components/SlavesPage.jsx new file mode 100644 index 00000000..c3bd02ce --- /dev/null +++ b/packages/web_ui/src/components/SlavesPage.jsx @@ -0,0 +1,28 @@ +import React, { useContext } from "react"; + +import libLink from "@clusterio/lib/link"; + +import DataTable from "./data-table"; +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; + + +export default function SlavesPage() { + let control = useContext(ControlContext); + + async function listSlaves() { + let result = await libLink.messages.listSlaves.send(control); + return result["list"].map((item) => ({ + key: item["id"], + "Name": item["name"], + "Agent": item["agent"], + "Version": item["version"], + "Connected": item["connected"] && "Yes", + })); + } + + return +

Slaves

+ +
; +}; diff --git a/packages/web_ui/src/components/UserViewPage.jsx b/packages/web_ui/src/components/UserViewPage.jsx new file mode 100644 index 00000000..3f6cf656 --- /dev/null +++ b/packages/web_ui/src/components/UserViewPage.jsx @@ -0,0 +1,246 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd"; +import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; +import { notifyErrorHandler } from "../util/notify"; + + +function useUser(name) { + let control = useContext(ControlContext); + let [user, setUser] = useState({ loading: true }); + + function updateUser() { + // XXX optimize by requesting only the user in question + libLink.messages.listUsers.send(control).then(result => { + let match = result.list.find(u => u.name === name); + if (!match) { + setUser({ missing: true }); + } else { + setUser({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateUser(); + }, [name]); + + return [user, updateUser]; +} + +export default function UserViewPage() { + let params = useParams(); + let userName = params.name; + + let history = useHistory(); + + let control = useContext(ControlContext); + let [user, updateUser] = useUser(userName); + let [roles, setRoles] = useState(null); + let [form] = Form.useForm(); + let [rolesDirty, setRolesDirty] = useState(false); + let [applyingRoles, setApplyingRoles] = useState(false); + let [rolesError, setRolesError] = useState(); + let [banUserPopover, setBanUserPopover] = useState(false); + + useEffect(() => { + libLink.messages.listRoles.send(control).then(result => { + setRoles(new Map(result["list"].map(role => [role["id"], role]))); + }).catch(() => { + setRoles(new Map()); + }); + }, []); + + let nav = [{ name: "Users", path: "/users" }, { name: userName }]; + if (user.loading) { + return +

{userName}

+ +
; + } + + if (user.missing) { + return +

User not found

+

User with name {userName} was not found on the master server.

+
; + } + + return + + +

{userName}

+ + + + Delete user account and all data associated with it? + {(user["is_banned"] && <>
This will remove the user ban!)} + } + placement="bottomRight" + okText="Delete" + okButtonProps={{ danger: true }} + onConfirm={() => { + libLink.messages.deleteUser.send( + control, { name: userName } + ).then(() => { + history.push("/users"); + }).catch(notifyErrorHandler("Error deleting user")); + }} + > + +
+ +
+
+ + + + (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, + ]} + > + + + + + + + + + + { + libLink.messages.setUserAdmin.send(control, { + name: userName, + admin: !user["is_admin"], + create: false, + }).then(() => { + updateUser(); + }).catch(notifyErrorHandler("Error toggling user admin status")); + }} + /> + + + { + libLink.messages.setUserWhitelisted.send(control, { + name: userName, + whitelisted: !user["is_whitelisted"], + create: false, + }).then(() => { + updateUser(); + }).catch(notifyErrorHandler("Error toggling user whitelisted status")); + }} + /> + + + + {user["is_banned"] ? "Yes" : "No"} + { + if (!user["is_banned"]) { + setBanUserPopover(!banUserPopover); + } + }} + content= + + + + + + + + + + + + > + + + + +
+
; +} diff --git a/packages/web_ui/src/components/UsersPage.jsx b/packages/web_ui/src/components/UsersPage.jsx new file mode 100644 index 00000000..65855ed5 --- /dev/null +++ b/packages/web_ui/src/components/UsersPage.jsx @@ -0,0 +1,69 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { Spin, Tag } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import DataTable from "./data-table"; +import ControlContext from "./ControlContext"; +import PageLayout from "./PageLayout"; + + +export default function UsersPage() { + let control = useContext(ControlContext); + let history = useHistory(); + + let [roles, setRoles] = useState(null); + + useEffect(() => { + libLink.messages.listRoles.send(control).then(result => { + setRoles(new Map(result["list"].map(role => [role["id"], role]))); + }).catch(() => { + setRoles(new Map()); + }); + }, []); + + async function listUsers() { + let result = await libLink.messages.listUsers.send(control); + return result["list"].map(item => ({ + key: item["name"], + "Name": item["name"], + "Roles": item["roles"].map(id => {(roles.get(id) || { name: id })["name"]}), + "Admin": item["is_admin"] && "Yes", + "Whitelisted": item["is_whitelisted"] && "Yes", + "Banned": item["is_banned"] && "Yes", + })); + } + + // DataTable does not make it possible to update the rendering function + if (roles === null) { + return +

Users

+ +
; + } + + return +

Users

+ { + await libLink.messages.createUser.send(control, { name: args.name }); + }, + }} + TableProps={{ + onRow: (record, rowIndex) => ({ + onClick: event => { + console.log(record, rowIndex); + history.push(`/users/${record.key}/view`); + }, + }), + }} + /> +
; +} diff --git a/packages/master/web/components/data-table.jsx b/packages/web_ui/src/components/data-table.jsx similarity index 100% rename from packages/master/web/components/data-table.jsx rename to packages/web_ui/src/components/data-table.jsx diff --git a/packages/master/web/pictures/logo.png b/packages/web_ui/src/images/logo.png similarity index 100% rename from packages/master/web/pictures/logo.png rename to packages/web_ui/src/images/logo.png diff --git a/packages/web_ui/src/index.css b/packages/web_ui/src/index.css new file mode 100644 index 00000000..692bef64 --- /dev/null +++ b/packages/web_ui/src/index.css @@ -0,0 +1,122 @@ +@import '~antd/dist/antd.dark.css'; + +body { + margin: 0; +} + +[data-theme="compact"] p, p { + margin: 0; +} + +.row-links{ + cursor: pointer; +} + +.login-container { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +.site-layout-content { + background: #141414; + padding: 24px; + margin: 0; + flex-grow: 0; +} + +.site-layout-sider { + z-index: 0; +} + +.header { + padding-left: 250px; +} + +.site-name { + position: absolute; + left: 112px; + font-size: 18pt; + font-weight: 400; +} + +.site-logo { + position: absolute; + left: 36px; + width: 64px; + height: 64px; + background-image: url("./images/logo.png"); + background-size: 64px; +} + +.rcon-result code { + display: block; + background: #000; + border-color: #252525; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +} + +/* see https://blog.eqrion.net/pin-to-bottom/ for how scroll anchors works */ +.instance-console code * { + overflow-anchor: none; +} + +.instance-console code .scroll-anchor { + overflow-anchor: auto; + height: 1px; +} + +.instance-console code { + display: block; + margin: 0; + background: #000; + border-color: #252525; + height: 300px; + overflow-y: scroll; +} + +.instance-console .factorio-time { + color: #afaf1f; +} + +.instance-console .factorio-filename { + color: #808080; +} + +.instance-console .factorio-info { + font-weight: bold; + color: #4646ea; +} + +.instance-console .factorio-warning { + font-weight: bold; + color: #afaf1f; +} + +.instance-console .factorio-error { + font-weight: bold; + color: #ad3939; +} + +.instance-console .factorio-action { + color: #afaf1f; +} + +.config-container { + background-color: #111111; +} + +.config-container .ant-form-item { + margin: 0; +} + +.config-container .filter-node .ant-tree-node-content-wrapper { + background: #2a1912; +} + +.json-input { + font-family: monospace; +} diff --git a/packages/web_ui/src/util/notify.jsx b/packages/web_ui/src/util/notify.jsx new file mode 100644 index 00000000..5028425d --- /dev/null +++ b/packages/web_ui/src/util/notify.jsx @@ -0,0 +1,33 @@ +import { notification } from "antd"; + + +export default function notify(message, type = "info", description = undefined) { + notification[type]({ + message: typeof message === "string" ? message : "ERROR: See console", + description, + placement: "bottomRight", + }); + if (typeof message !== "string") console.error(message); +} + +/** + * Promise rejection handler notifying the user + * + * Shows the error occuring in a notification card. + * + * @param {string} message - + * User facing message to give context to the error. Should say which + * operation failed. + * @returns {function(err)} function showing the error passed to it. + */ +export function notifyErrorHandler(message) { + return function handler(err) { + console.error(err); + notification.error({ + message, + description: err.message, + duration: 0, + placement: "bottomRight", + }); + }; +} diff --git a/packages/master/web/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx similarity index 50% rename from packages/master/web/util/websocket.jsx rename to packages/web_ui/src/util/websocket.jsx index 990c78ce..61e7cdc6 100644 --- a/packages/master/web/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -1,52 +1,29 @@ const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); const libErrors = require("@clusterio/lib/errors"); +const version = require("../../package.json").version; -/** - * Format a parsed Factorio output message - * - * Formats a parsed Factorio output from lib/factorio back into the - * text string it was parsed from. - * - * @param {Object} output - Factorio server output. - * @returns {string} origial output text. - * @private - */ -function formatOutput(output) { - let time = ""; - if (output.format === "seconds") { - time = `${output.time.padStart(8)} `; - } else if (output.format === "date") { - time = `${output.time} `; - } - - let info = ""; - if (output.type === "log") { - info = `${output.level} ${output.file}: `; - - } else if (output.type === "action") { - info = `[${output.action}] `; - } - - return `${time}${info}${output.message}`; -} /** * Connector for control connection to master server * @private */ export class ControlConnector extends libLink.WebSocketClientConnector { - constructor(url, reconnectDelay, token) { + constructor(url, reconnectDelay) { super(url, reconnectDelay); - this._token = token; + this.token = null; } register() { + if (!this.token) { + throw new Error("Token not set"); + } + console.log("SOCKET | registering control"); this.sendHandshake("register_control", { - token: this._token, - agent: "web", - version: "2.0.0-alpha", + token: this.token, + agent: "Clusterio Web UI", + version, }); } } @@ -70,16 +47,59 @@ export class Control extends libLink.Link { for (let controlPlugin of controlPlugins.values()) { libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin); } + + this.instanceOutputHandlers = new Map(); } async instanceOutputEventHandler(message) { let { instance_id, output } = message.data; - console.log(formatOutput(output)); - if (window.instanceOutputEventHandler) { - window.instanceOutputEventHandler({instance_id, output}); + let handlers = this.instanceOutputHandlers.get(instance_id); + for (let handler of handlers || []) { + handler(output); } } + async onInstanceOutput(id, handler) { + if (!Number.isInteger(id)) { + throw new Error("Invalid instance id"); + } + + let handlers = this.instanceOutputHandlers.get(id); + if (!handlers) { + handlers = []; + this.instanceOutputHandlers.set(id, handlers); + } + + handlers.push(handler); + + if (handlers.length === 1) { + await this.updateInstanceOutputSubscriptions(); + } + } + + async offInstanceOutput(id, handler) { + let handlers = this.instanceOutputHandlers.get(id); + if (!handlers || !handlers.length) { + throw new Error(`No handlers for instance ${id} exist`); + } + + let index = handlers.lastIndexOf(handler); + if (index === -1) { + throw new Error(`Given handler is not registered for instance ${id}`); + } + + handlers.splice(index, 1); + if (!handlers.length) { + await this.updateInstanceOutputSubscriptions(); + } + } + + async updateInstanceOutputSubscriptions() { + await libLink.messages.setInstanceOutputSubscriptions.send(this, { + instance_ids: [...this.instanceOutputHandlers.keys()], + }); + } + async debugWsMessageEventHandler(message) { console.log("WS", message.data.direction, message.data.content); } @@ -95,6 +115,6 @@ export class Control extends libLink.Link { } } - await this.connector.close(1001, "Control Quit"); + await this.connector.close(1000, "Control Quit"); } } diff --git a/packages/master/web/util/wslink.jsx b/packages/web_ui/src/util/wslink.jsx similarity index 100% rename from packages/master/web/util/wslink.jsx rename to packages/web_ui/src/util/wslink.jsx diff --git a/packages/master/webpack.common.js b/packages/web_ui/webpack.common.js similarity index 92% rename from packages/master/webpack.common.js rename to packages/web_ui/webpack.common.js index 7b3bdb01..3fbf7291 100644 --- a/packages/master/webpack.common.js +++ b/packages/web_ui/webpack.common.js @@ -29,6 +29,10 @@ module.exports = (env = {}) => ({ test: /node_modules.fs-extra/, use: "null-loader", }, + { + test: /node_modules.jsonwebtoken/, + use: "null-loader", + }, { test: /\.css$/, use: [ @@ -59,6 +63,10 @@ module.exports = (env = {}) => ({ }, }, }, + { + test: /\.png$/, + use: "file-loader", + }, ], }, resolve: { diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index 49fec954..8a04762c 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -17,14 +17,10 @@ "@clusterio/lib": "^2.0.0-alpha.0" }, "devDependencies": { - "@babel/core": "^7.11.6", - "@babel/preset-react": "^7.10.4", - "@clusterio/master": "^2.0.0-alpha.1", + "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", - "clean-webpack-plugin": "^3.0.0", - "events": "^3.2.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", - "path-browserify": "^1.0.1", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" }, diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.js index f338be0e..042295a4 100644 --- a/plugins/global_chat/webpack.config.js +++ b/plugins/global_chat/webpack.config.js @@ -3,9 +3,10 @@ const path = require("path"); const webpack = require("webpack"); const { merge } = require("webpack-merge"); -const common = require("@clusterio/master/webpack.common"); +const common = require("@clusterio/web_ui/webpack.common"); module.exports = (env = {}) => merge(common(env), { + context: __dirname, entry: "./web/index.jsx", output: { filename: "bundle.js", @@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), { }, shared: { "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, "ajv": { import: false }, }, }), diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index 8c7a4e59..bc3d1f82 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -20,14 +20,10 @@ "fs-extra": "^8.1.0" }, "devDependencies": { - "@babel/core": "^7.11.6", - "@babel/preset-react": "^7.10.4", - "@clusterio/master": "^2.0.0-alpha.1", + "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", - "clean-webpack-plugin": "^3.0.0", - "events": "^3.2.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", - "path-browserify": "^1.0.1", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" }, diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.js index a3151f33..4a519c63 100644 --- a/plugins/research_sync/webpack.config.js +++ b/plugins/research_sync/webpack.config.js @@ -3,9 +3,10 @@ const path = require("path"); const webpack = require("webpack"); const { merge } = require("webpack-merge"); -const common = require("@clusterio/master/webpack.common"); +const common = require("@clusterio/web_ui/webpack.common"); module.exports = (env = {}) => merge(common(env), { + context: __dirname, entry: "./web/index.jsx", output: { filename: "bundle.js", @@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), { }, shared: { "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, "ajv": { import: false }, }, }), diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index f1945a65..bbc376d5 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -17,14 +17,10 @@ "@clusterio/lib": "^2.0.0-alpha.0" }, "devDependencies": { - "@babel/core": "^7.11.6", - "@babel/preset-react": "^7.10.4", - "@clusterio/master": "^2.0.0-alpha.1", + "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", - "clean-webpack-plugin": "^3.0.0", - "events": "^3.2.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", - "path-browserify": "^1.0.1", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" }, diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.js index 129dcf0f..3198c71e 100644 --- a/plugins/statistics_exporter/webpack.config.js +++ b/plugins/statistics_exporter/webpack.config.js @@ -3,9 +3,10 @@ const path = require("path"); const webpack = require("webpack"); const { merge } = require("webpack-merge"); -const common = require("@clusterio/master/webpack.common"); +const common = require("@clusterio/web_ui/webpack.common"); module.exports = (env = {}) => merge(common(env), { + context: __dirname, entry: "./web/index.jsx", output: { filename: "bundle.js", @@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), { }, shared: { "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, "ajv": { import: false }, }, }), diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index bbacb929..56af382f 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -20,15 +20,11 @@ "fs-extra": "^8.1.0" }, "devDependencies": { - "@babel/core": "^7.11.6", - "@babel/preset-react": "^7.10.4", - "@clusterio/master": "^2.0.0-alpha.1", + "@clusterio/web_ui": "^2.0.0-alpha.1", "antd": "^4.6.5", "babel-loader": "^8.1.0", - "clean-webpack-plugin": "^3.0.0", - "events": "^3.2.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", - "path-browserify": "^1.0.1", "react": "^16.13.1", "react-dom": "^16.13.1", "webpack-cli": "^3.3.12", diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index 05814382..4a7b4901 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -3,9 +3,10 @@ const path = require("path"); const webpack = require("webpack"); const { merge } = require("webpack-merge"); -const common = require("@clusterio/master/webpack.common"); +const common = require("@clusterio/web_ui/webpack.common"); module.exports = (env = {}) => merge(common(env), { + context: __dirname, entry: "./web/index.jsx", output: { filename: "bundle.js", @@ -21,6 +22,7 @@ module.exports = (env = {}) => merge(common(env), { }, shared: { "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, "ajv": { import: false }, "antd": { import: false }, "react": { import: false },