From f67f56e6de97f37b7ab3dd039b2bc29906d2edd2 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Nov 2020 22:42:27 +0100 Subject: [PATCH] Refactor web interface Reimplement the pages of the web interface in order to make them prettier and more easily amended by plugins. --- .eslintrc.js | 2 +- packages/master/package.json | 1 + packages/web_ui/src/bootstrap.jsx | 3 +- packages/web_ui/src/components/App.jsx | 4 +- .../src/components/InstanceViewPage.jsx | 604 ++++++++++++++++++ .../web_ui/src/components/InstancesPage.jsx | 55 ++ packages/web_ui/src/components/PageLayout.jsx | 18 + .../web_ui/src/components/RoleViewPage.jsx | 151 +++++ packages/web_ui/src/components/RolesPage.jsx | 51 ++ packages/web_ui/src/components/SiteLayout.jsx | 85 +++ packages/web_ui/src/components/SlavesPage.jsx | 28 + .../web_ui/src/components/UserViewPage.jsx | 246 +++++++ packages/web_ui/src/components/UsersPage.jsx | 69 ++ .../pictures => web_ui/src/images}/logo.png | Bin packages/web_ui/src/index.css | 127 +++- packages/web_ui/src/layout/index.jsx | 160 ----- packages/web_ui/src/pages/dashboard.jsx | 7 - .../web_ui/src/pages/instances/instance.jsx | 454 ------------- .../src/pages/instances/instancesTable.jsx | 42 -- .../pages/permissions/permissionsTable.jsx | 26 - packages/web_ui/src/pages/roles/role.jsx | 118 ---- .../web_ui/src/pages/roles/rolesTable.jsx | 39 -- .../web_ui/src/pages/slaves/slavesTable.jsx | 26 - packages/web_ui/src/pages/users/user.jsx | 95 --- .../web_ui/src/pages/users/usersTable.jsx | 36 -- packages/web_ui/src/util/notify.jsx | 23 + packages/web_ui/src/util/websocket.jsx | 79 ++- packages/web_ui/webpack.common.js | 8 + plugins/global_chat/package.json | 1 + plugins/research_sync/package.json | 1 + plugins/statistics_exporter/package.json | 1 + plugins/subspace_storage/package.json | 1 + 32 files changed, 1502 insertions(+), 1059 deletions(-) create mode 100644 packages/web_ui/src/components/InstanceViewPage.jsx create mode 100644 packages/web_ui/src/components/InstancesPage.jsx create mode 100644 packages/web_ui/src/components/PageLayout.jsx create mode 100644 packages/web_ui/src/components/RoleViewPage.jsx create mode 100644 packages/web_ui/src/components/RolesPage.jsx create mode 100644 packages/web_ui/src/components/SiteLayout.jsx create mode 100644 packages/web_ui/src/components/SlavesPage.jsx create mode 100644 packages/web_ui/src/components/UserViewPage.jsx create mode 100644 packages/web_ui/src/components/UsersPage.jsx rename packages/{master/web/pictures => web_ui/src/images}/logo.png (100%) delete mode 100644 packages/web_ui/src/layout/index.jsx delete mode 100644 packages/web_ui/src/pages/dashboard.jsx delete mode 100644 packages/web_ui/src/pages/instances/instance.jsx delete mode 100644 packages/web_ui/src/pages/instances/instancesTable.jsx delete mode 100644 packages/web_ui/src/pages/permissions/permissionsTable.jsx delete mode 100644 packages/web_ui/src/pages/roles/role.jsx delete mode 100644 packages/web_ui/src/pages/roles/rolesTable.jsx delete mode 100644 packages/web_ui/src/pages/slaves/slavesTable.jsx delete mode 100644 packages/web_ui/src/pages/users/user.jsx delete mode 100644 packages/web_ui/src/pages/users/usersTable.jsx diff --git a/.eslintrc.js b/.eslintrc.js index ba9eaec6..a6e7c847 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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/packages/master/package.json b/packages/master/package.json index 68cdb84e..66396f6b 100644 --- a/packages/master/package.json +++ b/packages/master/package.json @@ -39,6 +39,7 @@ "babel-loader": "^8.1.0", "css-loader": "^4.3.0", "fast-deep-equal": "^3.1.3", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index 4036075c..cda9cf8b 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -63,8 +63,7 @@ async function load() { wsUrl.protocol = wsUrl.protocol.replace("http", "ws"); let controlConnector = new ControlConnector(wsUrl, 10); - // XXX come up with a better way of sharing this - window.control = new Control(controlConnector, []); + let control = new Control(controlConnector, []); ReactDOM.render(, document.getElementById("root")); } diff --git a/packages/web_ui/src/components/App.jsx b/packages/web_ui/src/components/App.jsx index 113cd2cf..454ce9be 100644 --- a/packages/web_ui/src/components/App.jsx +++ b/packages/web_ui/src/components/App.jsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { BrowserRouter } from "react-router-dom"; import basename from "../basename"; -import SiteLayout from "../layout"; +import SiteLayout from "./SiteLayout"; import ControlContext from "./ControlContext"; import LoginForm from "./LoginForm"; @@ -21,8 +21,6 @@ export default function App(props) { } useEffect(() => { - console.log("Effect called"); - function onConnect() { localStorage.setItem("master_token", token); setConnected(true); 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/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/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 index f79dd7ad..692bef64 100644 --- a/packages/web_ui/src/index.css +++ b/packages/web_ui/src/index.css @@ -1,31 +1,122 @@ @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; + margin: 0; } -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - - [data-theme="compact"] p, p { - margin: 0; + margin: 0; } .row-links{ - cursor: pointer; + cursor: pointer; } .login-container { - min-height: 100vh; - display: flex; - justify-content: center; - align-items: center; + 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/layout/index.jsx b/packages/web_ui/src/layout/index.jsx deleted file mode 100644 index 1dd23a73..00000000 --- a/packages/web_ui/src/layout/index.jsx +++ /dev/null @@ -1,160 +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"; - -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 - - - -
- ) : ( - "" - )} - - - - - - - - - - - - - - -
- Clusterio 2.0 web interface https://github.com/clusterio -
-
-
- ); - } -} - -export default withRouter(SiteLayout); diff --git a/packages/web_ui/src/pages/dashboard.jsx b/packages/web_ui/src/pages/dashboard.jsx deleted file mode 100644 index 4089dae5..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/instances/instance.jsx b/packages/web_ui/src/pages/instances/instance.jsx deleted file mode 100644 index c0ccdf13..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/instances/instancesTable.jsx b/packages/web_ui/src/pages/instances/instancesTable.jsx deleted file mode 100644 index 34fb1be1..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/permissions/permissionsTable.jsx b/packages/web_ui/src/pages/permissions/permissionsTable.jsx deleted file mode 100644 index a282b504..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/roles/role.jsx b/packages/web_ui/src/pages/roles/role.jsx deleted file mode 100644 index 66e3adc6..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/roles/rolesTable.jsx b/packages/web_ui/src/pages/roles/rolesTable.jsx deleted file mode 100644 index b95071ad..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/slaves/slavesTable.jsx b/packages/web_ui/src/pages/slaves/slavesTable.jsx deleted file mode 100644 index c46f3096..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/users/user.jsx b/packages/web_ui/src/pages/users/user.jsx deleted file mode 100644 index 40739bdc..00000000 --- a/packages/web_ui/src/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/web_ui/src/pages/users/usersTable.jsx b/packages/web_ui/src/pages/users/usersTable.jsx deleted file mode 100644 index 154d70a1..00000000 --- a/packages/web_ui/src/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/web_ui/src/util/notify.jsx b/packages/web_ui/src/util/notify.jsx index 11805738..5028425d 100644 --- a/packages/web_ui/src/util/notify.jsx +++ b/packages/web_ui/src/util/notify.jsx @@ -1,5 +1,6 @@ import { notification } from "antd"; + export default function notify(message, type = "info", description = undefined) { notification[type]({ message: typeof message === "string" ? message : "ERROR: See console", @@ -8,3 +9,25 @@ export default function notify(message, type = "info", description = undefined) }); 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/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index c96e3c2b..61e7cdc6 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -3,34 +3,6 @@ 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 @@ -75,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); } @@ -100,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/web_ui/webpack.common.js b/packages/web_ui/webpack.common.js index 7b3bdb01..3fbf7291 100644 --- a/packages/web_ui/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 c3c11791..8a04762c 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index bca53ca3..bc3d1f82 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -22,6 +22,7 @@ "devDependencies": { "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index 3d271cd2..bbc376d5 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@clusterio/web_ui": "^2.0.0-alpha.1", "babel-loader": "^8.1.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", "webpack-cli": "^3.3.12", "webpack-merge": "^5.2.0" diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index afb3e970..56af382f 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -23,6 +23,7 @@ "@clusterio/web_ui": "^2.0.0-alpha.1", "antd": "^4.6.5", "babel-loader": "^8.1.0", + "file-loader": "^6.2.0", "null-loader": "^4.0.0", "react": "^16.13.1", "react-dom": "^16.13.1",