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 ? (
-

- ) : (
-

- )}
-
-
-
-
-
-
- ) : (
- ""
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-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"
- >
-
-
-
-
- {/* rcon input field */}
-
-
-
-
- {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
-
-
-
- :
-
- }
-
- {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
-
-
-
-
-
-
-
- ;
- }
-}
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
+
+
+
+
+
+
+
+
+ >;
+}
+
+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 (
+
+ }
+ 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
+
+
+
+
+
+
+ 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 }
+
+
+
+
+
+
+
+
+ {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 },