From 2382f4bbdc9432b969426dcc88d96e619d3ccaa7 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Fri, 25 Sep 2020 22:14:57 +0200 Subject: [PATCH] Import web interface from clusterio/clusterioWeb Import the source files in use for the web interface, excluding the copied and modified lib/link files. --- packages/master/web/components/data-table.jsx | 293 ++++++++++++ packages/master/web/index.css | 23 + packages/master/web/index.js | 29 ++ packages/master/web/layout/index.jsx | 239 ++++++++++ packages/master/web/pages/dashboard.jsx | 10 + .../master/web/pages/instances/instance.jsx | 436 ++++++++++++++++++ .../web/pages/instances/instancesTable.jsx | 46 ++ .../pages/permissions/permissionsTable.jsx | 30 ++ packages/master/web/pages/roles/role.jsx | 111 +++++ .../master/web/pages/roles/rolesTable.jsx | 43 ++ .../master/web/pages/settings/settings.jsx | 81 ++++ .../master/web/pages/slaves/slavesTable.jsx | 30 ++ packages/master/web/pages/users/user.jsx | 101 ++++ .../master/web/pages/users/usersTable.jsx | 40 ++ packages/master/web/util/notify.jsx | 15 + packages/master/web/util/websocket.jsx | 112 +++++ packages/master/web/util/wslink.jsx | 156 +++++++ 17 files changed, 1795 insertions(+) create mode 100644 packages/master/web/components/data-table.jsx create mode 100644 packages/master/web/index.css create mode 100644 packages/master/web/index.js create mode 100644 packages/master/web/layout/index.jsx create mode 100644 packages/master/web/pages/dashboard.jsx create mode 100644 packages/master/web/pages/instances/instance.jsx create mode 100644 packages/master/web/pages/instances/instancesTable.jsx create mode 100644 packages/master/web/pages/permissions/permissionsTable.jsx create mode 100644 packages/master/web/pages/roles/role.jsx create mode 100644 packages/master/web/pages/roles/rolesTable.jsx create mode 100644 packages/master/web/pages/settings/settings.jsx create mode 100644 packages/master/web/pages/slaves/slavesTable.jsx create mode 100644 packages/master/web/pages/users/user.jsx create mode 100644 packages/master/web/pages/users/usersTable.jsx create mode 100644 packages/master/web/util/notify.jsx create mode 100644 packages/master/web/util/websocket.jsx create mode 100644 packages/master/web/util/wslink.jsx diff --git a/packages/master/web/components/data-table.jsx b/packages/master/web/components/data-table.jsx new file mode 100644 index 00000000..19958ef4 --- /dev/null +++ b/packages/master/web/components/data-table.jsx @@ -0,0 +1,293 @@ +import React, { Component } from "react"; +import { + Collapse, Row, Col, Modal, Table, + Form, + Input, + Button, + Radio, + Select, + Cascader, + DatePicker, + InputNumber, + TreeSelect, + Switch, + Popover +} from "antd"; +import notify from "../util/notify"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; + +const { Panel } = Collapse; + +/** + * Display data in table. Allows for editing data by clicking rows in the table. + * Basic usage: + * + * DataFunction - String, corresponds to export from util/wslink. Same as ws message name. + * columns - Array of objects, optional, same as . Note the lowercase c + * Editable - Boolean - Allow editing + * AddRecord - Boolean - Allow adding new records + * CustomDataFilterFunction - function(tableData) - Filter tabledata and return results + * RecordIdField - String - By default we assume the column "Id" is a unique identifier for a record. If that isn't the case, supply the column name here + */ +class DataTable extends Component { + constructor(props) { + super(props); + if (!this.props.DataFunction) throw new Error("DataFunction not defined") + this.state = { + pagination: { + pageSize: 100, + current: 1 + }, + filters: this.props.Filters, + sorter: {}, + tableData: [], + showEditModal: false, + record: {}, + } + } + componentDidMount() { + this.getData() + } + getData = async (pagination, filters, sorter) => { + /* + pagination = { + pageSize: 10, // items per page + current: 1, // page number + total: 123, // Total amount of items + } + filters = { + ReportId: 123 + } + sorter = {} + */ + pagination = pagination || this.state.pagination + filters = filters || this.state.filters + sorter = sorter || this.state.sorter + // console.log(pagination, filters, sorter) + + let data = await this.props.DataFunction() + // await fetchApi(this.state.dataUrl, "post", { + // database: this.props.Database, + // table: this.props.Table, + // dataSource: this.props.DataSource, + // pagination: this.props.RecordCount? {...pagination, pageSize: this.props.RecordCount, current: 1}:pagination, + // filters, + // sorter, + // recordIdField: this.props.RecordIdField || "Id", // Sort by this + // }) + console.log(data) + data = data || {} + this.setState({ + tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data, + pagination: { ...pagination, ...data.pagination }, + sorter, + filters, + }) + } + showEditModal = (record, index) => { + if (!this.state.showEditModal) + this.setState({ + record: record, + showEditModal: true, + }) + } + hideEditModal = () => { + this.setState({ + showEditModal: false, + inserting: false, + record: {} + }) + } + onEditFinish = async values => { + console.log("Finished editing", values) + + if (this.state.inserting) { // Create new record + var status = {} + await this.props.AddRecord.insert(values) + } else { // Edit existing record, capable of doing sparse updates + var status = {} + await this.props.Editable.insert(values) + } + this.setState({ + showEditModal: false, + inserting: false, + record: {}, + }) + // Force refresh of data + this.getData() + } + deleteRecord = async record => { + delete record.key + // Delete "null" values as they seem to cause deletes to fail silently when the value is null and field type is float + for (let key in record) { + if (record[key] === null) delete record[key] + } + var status = {} + // await fetchApi(`/api/${this.props.Server}/tableData/record`, "delete", { + // database: this.props.Database, + // table: this.props.Table, + // dataSource: this.props.DataSource, + // record, + // }, undefined, true) + + console.log("Deleted record", status) + + if (status.ok) notify("success", status.msg, "success") + if (!status.ok) notify("error", status.msg, "error") + + // Force refresh of data + this.getData() + } + render() { + let tableProps = { + ...this.props.TableProps || {}, + onRow: (record, rowIndex) => { + /* + To allow for extending functionality, we take a TableProps argument from props and pass it through to the Table component. + Since we are also using onRow internaly here, we handle the externally provided events ourselves by passing along the event + object. This way the end user isn't affected by our event handlers. + */ + let externalOnRow = { + ...this.props.TableProps?.onRow?.(record, rowIndex), + } + return { + onClick: event => { + if (event.target.type !== "button") { + console.log("onRow", record, rowIndex) + if (this.props.Editable) this.showEditModal(record, rowIndex) + if (externalOnRow.onClick) externalOnRow.onClick(event) + } + }, + onDoubleClick: event => { + if (externalOnRow.onDoubleClick) externalOnRow.onDoubleClick(event) + }, + onContextMenu: event => { + if (externalOnRow.onContextMenu) externalOnRow.onContextMenu(event) + }, + onMouseEnter: event => { + if (externalOnRow.onMouseEnter) externalOnRow.onMouseEnter(event) + }, + onMouseLeave: event => { + if (externalOnRow.onMouseLeave) externalOnRow.onMouseLeave(event) + }, + } + } + } + let columns = this.props.columns || this.inferColumns(this.state.tableData) + if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) columns.push({ + // Add delete button on the right + dataIndex: "notInUse", + title: "", + render: (text, record) => { + this.deleteRecord(record) + }} + > + Slett permanent + + } + trigger="click" + > + + + }) + + return <> + + + +
{ + return { + ...x, // Add a 4th parameter to the stock render function with all of our table data + render: x.render ? (...originalParams) => x.render(...originalParams, this.state.tableData) : undefined + } + })} + dataSource={this.state.tableData} + pagination={this.state.pagination.total > 10 ? this.state.pagination : false} + onChange={this.getData} + /> + {this.props.AddRecord && } + + {this.state.inserting ? "Legg til" : "Rediger"} + } + > + {this.state.showEditModal &&
+ { + + ( + (this.state.inserting && this.props.AddRecord?.fields) // Use externally supplied form input fields + || this.props.columns?.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns + || this.inferColumns(this.state.tableData)).map(col => { // Generate input fields from autogenerated columns (only works with existing data) + console.log(this.state.record) + return + {col.renderEdit?.(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || } + + }) + } + + + + } +
+ + + } + inferColumns = data => { + // Look at the data and find columns and how to display them. Can be overridden by props.Columns if more customization is needed + let columns = [] + data.forEach(row => { + Object.keys(row).filter(x => x !== "key").forEach(key => { + let existing = columns.find(col => col.key === key) + if (!existing) { + columns.push({ + key: key, + title: key, + dataIndex: key, + }) + } + }) + }) + return columns + } +} + +export default DataTable; diff --git a/packages/master/web/index.css b/packages/master/web/index.css new file mode 100644 index 00000000..ef430c91 --- /dev/null +++ b/packages/master/web/index.css @@ -0,0 +1,23 @@ +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.js b/packages/master/web/index.js new file mode 100644 index 00000000..9a1c5b9a --- /dev/null +++ b/packages/master/web/index.js @@ -0,0 +1,29 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import Layout from './layout' +import * as serviceWorker from './serviceWorker'; +import { BrowserRouter } from 'react-router-dom'; + +import moment from "moment"; +import locale from "moment/locale/nb"; + +moment.updateLocale("nb", locale); +// ReactDOM.render( +// +// +// , +// document.getElementById('root') +// ); + +ReactDOM.render( + + + , + document.getElementById('root') +) + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.unregister(); diff --git a/packages/master/web/layout/index.jsx b/packages/master/web/layout/index.jsx new file mode 100644 index 00000000..c647b216 --- /dev/null +++ b/packages/master/web/layout/index.jsx @@ -0,0 +1,239 @@ +import { + Layout, + Menu, + Avatar, +} from "antd"; +import 'antd/dist/antd.dark.css'; +import { + UnorderedListOutlined, + UserOutlined, + SettingOutlined, + GoldOutlined, + FileTextOutlined, + ScheduleOutlined, +} from "@ant-design/icons"; + +import { withRouter } from "react-router-dom"; +import { BrowserRouter as Router, Route, Link } from "react-router-dom"; + +import React, { Component } from "react"; +import { Helmet } from "react-helmet"; +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.history = useHistory(); + + this.onCollapse = this.onCollapse.bind(this); + + 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 ( + + + + + Clusterio 2.0 + + + {document.location.search !== "?hideExtras" ? ( + +

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

+

+ + { + let x = this.state.location.pathname.split("/"); + x.shift(); + return x; + })()} + mode="inline" + > + + Slaves + + + + Instances + + + + Users + + + + Roles + + + + Permissions + + + + Settings + + + +
+ ) : ( + "" + )} + + + + + + + + + + + + + + + +
+ Clusterio 2.0 web interface https://github.com/clusterio +
+
+
+
+ ); + } +} + +export default withRouter(SiteLayout); diff --git a/packages/master/web/pages/dashboard.jsx b/packages/master/web/pages/dashboard.jsx new file mode 100644 index 00000000..059a6d69 --- /dev/null +++ b/packages/master/web/pages/dashboard.jsx @@ -0,0 +1,10 @@ +import React, {Component} from "react" + +export class Dashboard extends Component { + constructor(props){ + super(props) + } + render(){ + return

Dashboard

+ } +} \ No newline at end of file diff --git a/packages/master/web/pages/instances/instance.jsx b/packages/master/web/pages/instances/instance.jsx new file mode 100644 index 00000000..2235deec --- /dev/null +++ b/packages/master/web/pages/instances/instance.jsx @@ -0,0 +1,436 @@ +import React, { Component } from "react" +import { Card, Table, Button, List, 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, EditOutlined, PlusOutlined } from "@ant-design/icons" + +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" }], + } + } + 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' }) + } + } + getData = async () => { + 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 + }) + } + start = async () => { + this.setState({ + starting: true, + }) + try { + await startInstance({ + instance_id: this.state.instance.id, + save: null, + }) + } catch (e) { + notify("Error starting instance", "error", e.message) + return this.setState({ + starting: false, + }) + } + notify("Instance started") + this.setState({ + starting: false, + }) + this.getData() + } + stop = async () => { + this.setState({ + stopping: true, + }) + try { + await stopInstance({ + instance_id: this.state.instance.id, + }) + } catch (e) { + notify("Error stopping instance", "error", e.message) + return this.setState({ + stopping: false, + }) + } + notify("Instance stopped") + this.setState({ + stopping: false, + }) + this.getData() + } + createSave = async () => { + this.setState({ + creatingSave: true, + }) + try { + await createSave({ + instance_id: this.state.instance.id, + }) + } catch (e) { + notify("Error creating save", "error", e.message) + return this.setState({ + creatingSave: false, + }) + } + notify("Save created", "success") + this.setState({ + creatingSave: false, + }) + } + onConfigEditFinish = async 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 + return +

{instance.name}

+

ID: {instance.id}

+

Slave: {slaves?.find(x => x.id === instance.assigned_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(slave => )} + + } + 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 in 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 { + constructor(props) { + super(props) + } + 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 new file mode 100644 index 00000000..a839e1c2 --- /dev/null +++ b/packages/master/web/pages/instances/instancesTable.jsx @@ -0,0 +1,46 @@ +import React, { Component } from "react" +import { Card, Table } from "antd" +import { listSlaves, listInstances, createInstance } from "../../util/wslink" +import DataTable from "../../components/data-table" +const config = require("../../lib/config") + +export class InstancesTable extends Component { + constructor(props) { + super(props) + } + 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) => { + return { + 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 new file mode 100644 index 00000000..17274f58 --- /dev/null +++ b/packages/master/web/pages/permissions/permissionsTable.jsx @@ -0,0 +1,30 @@ +import React, { Component } from "react" +import { Card, Table } from "antd" +import { listSlaves, listInstances, listPermissions } from "../../util/wslink" +import DataTable from "../../components/data-table" + +export class PermissionsTable extends Component { + constructor(props) { + super(props) + } + navigate(url) { + this.props.history.push(url); + } + render() { + return +

Permissions

+ { + return { + 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 new file mode 100644 index 00000000..cec23e9b --- /dev/null +++ b/packages/master/web/pages/roles/role.jsx @@ -0,0 +1,111 @@ +import React, { Component } from "react" +import { Card, Table, Button, List, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space, Transfer } from "antd" +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" +import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons" + +const { Option } = Select + +export class RoleView extends Component { + constructor(props) { + super(props) + this.state = { + role: {} + } + } + messagesEndRef = React.createRef() + navigate(url) { + this.props.history.push(url); + } + async componentDidMount() { + await this.getData() + } + getData = async () => { + 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, + }) + } + handleChange = async 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} + render={item => item.name} + /> +
+ } +} diff --git a/packages/master/web/pages/roles/rolesTable.jsx b/packages/master/web/pages/roles/rolesTable.jsx new file mode 100644 index 00000000..0e796e97 --- /dev/null +++ b/packages/master/web/pages/roles/rolesTable.jsx @@ -0,0 +1,43 @@ +import React, { Component } from "react" +import { Card, Table } from "antd" +import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink" +import DataTable from "../../components/data-table" + +export class RolesTable extends Component { + constructor(props) { + super(props) + } + 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) => { + return { + 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 new file mode 100644 index 00000000..215c3f1a --- /dev/null +++ b/packages/master/web/pages/settings/settings.jsx @@ -0,0 +1,81 @@ +import React, { Component } from "react" +import { Card, Button, Select, Input, Form } from "antd" + +const layout = { + labelCol: { + span: 4, + }, + wrapperCol: { + span: 16, + }, +}; +const tailLayout = { + wrapperCol: { + offset: 4, + span: 16, + }, +}; + +export class Settings extends Component { + constructor(props) { + super(props) + this.state = { + role: {} + } + } + navigate(url) { + this.props.history.push(url); + } + render() { + console.log(this.state) + return +

Web interface settings

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

Slaves

+ { + return { + 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 new file mode 100644 index 00000000..3c33cba6 --- /dev/null +++ b/packages/master/web/pages/users/user.jsx @@ -0,0 +1,101 @@ +import React, { Component } from "react" +import { Card, Table, Button, List, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space, Transfer } from "antd" +import { + listSlaves, + listInstances, + startInstance, + stopInstance, + setInstanceOutputSubscriptions, + deleteInstance, + assignInstance, + createSave, + getInstanceConfig, + setInstanceConfigField, + sendRcon, + deleteUser, + listUsers, + listRoles, + setRoles, +} from "../../util/wslink" +import DataTable from "../../components/data-table" +import notify from "../../util/notify" +import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons" + +const { Option } = Select + +export class UserView extends Component { + constructor(props) { + super(props) + this.state = { + user: {} + } + } + messagesEndRef = React.createRef() + navigate(url) { + this.props.history.push(url); + } + async componentDidMount() { + await this.getData() + } + getData = async () => { + 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, + }) + } + handleChange = async 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} + 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 new file mode 100644 index 00000000..572e98cb --- /dev/null +++ b/packages/master/web/pages/users/usersTable.jsx @@ -0,0 +1,40 @@ +import React, { Component } from "react" +import { Card, Table } from "antd" +import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink" +import DataTable from "../../components/data-table" + +export class UsersTable extends Component { + constructor(props) { + super(props) + } + 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) => { + return { + onClick: event => { + this.navigate(`/users/${record.name}/view`) + } + } + } + }} + /> +
+ } +} diff --git a/packages/master/web/util/notify.jsx b/packages/master/web/util/notify.jsx new file mode 100644 index 00000000..8df6b7c5 --- /dev/null +++ b/packages/master/web/util/notify.jsx @@ -0,0 +1,15 @@ +import { notification } from "antd"; + +/** + * + * @param {string} message + * @param {("info"|"error"|"success"|"warning"|"warn")} type + */ +export default function notify(message, type = "info", description) { + 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/web/util/websocket.jsx b/packages/master/web/util/websocket.jsx new file mode 100644 index 00000000..7fc47fcc --- /dev/null +++ b/packages/master/web/util/websocket.jsx @@ -0,0 +1,112 @@ +const link = require("./../lib/link"); +const plugin = require("./../lib/plugin"); +const errors = require("./../lib/errors"); +const chalk = require("chalk"); +/** + * Format a parsed Factorio output message with colors + * + * Formats a parsed Factorio output from lib/factorio into a readable + * colorized output using terminal escape codes that can be printed. + * + * @param {Object} output - Factorio server output. + * @returns {string} terminal colorized message. + * @private + */ +function formatOutputColored(output) { + let time = ""; + if (output.format === "seconds") { + time = chalk.yellow(output.time.padStart(8)) + " "; + } else if (output.format === "date") { + time = chalk.yellow(output.time) + " "; + } + + let info = ""; + if (output.type === "log") { + let level = output.level; + if (level === "Info") { + level = chalk.bold.blueBright(level); + } else if (output.level === "Warning") { + level = chalk.bold.yellowBright(level); + } else if (output.level === "Error") { + level = chalk.bold.redBright(level); + } + + info = level + " " + chalk.gray(output.file) + ": "; + + } else if (output.type === "action") { + info = "[" + chalk.yellow(output.action) + "] "; + } + + return time + info + output.message; +} +/** + * Connector for control connection to master server + * @private + */ +class ControlConnector extends link.WebSocketClientConnector { + constructor(url, reconnectDelay, token) { + super(url, reconnectDelay); + this._token = token; + } + + register() { + console.log("SOCKET | registering control"); + this.sendHandshake("register_control", { + token: this._token, + agent: "clusterctl", + version: "2.0.0-alpha", + }); + } +} + +/** + * Handles running the control + * + * Connects to the master server over WebSocket and sends commands to it. + * @static + */ +class Control extends link.Link { + + constructor(connector, controlPlugins) { + super("control", "master", connector); + link.attachAllMessages(this); + + /** + * Mapping of plugin names to their instance for loaded plugins. + * @type {Map} + */ + this.plugins = controlPlugins; + for (let controlPlugin of controlPlugins.values()) { + plugin.attachPluginMessages(this, controlPlugin.info, controlPlugin); + } + } + + async instanceOutputEventHandler(message) { + let { instance_id, output } = message.data; + console.log(formatOutputColored(output)); + window.instanceOutputEventHandler && window.instanceOutputEventHandler({instance_id, output}) + } + + async debugWsMessageEventHandler(message) { + console.log("WS", message.data.direction, message.data.content); + } + + async shutdown() { + this.connector.setTimeout(30); + + try { + await link.messages.prepareDisconnect.send(this); + } catch (err) { + if (!(err instanceof errors.SessionLost)) { + throw err; + } + } + + await this.connector.close(1001, "Control Quit"); + } +} + +export { + ControlConnector, + Control +} diff --git a/packages/master/web/util/wslink.jsx b/packages/master/web/util/wslink.jsx new file mode 100644 index 00000000..4ed1e2e2 --- /dev/null +++ b/packages/master/web/util/wslink.jsx @@ -0,0 +1,156 @@ +import { Control, ControlConnector } from "./../util/websocket" +import * as errors from "./../lib/errors" +import * as link from "./../lib/link" + +let control, controlConnector + +// Create WS connection to master. Used by all components through the interface exported here. +(async () => { + controlConnector = new ControlConnector( + localStorage.getItem("master_url"), + 10, // Reconnect delay + localStorage.getItem("master_token"), + ); + control = new Control(controlConnector, []); + try { + await controlConnector.connect(); + } catch (err) { + console.log(err) + if (err instanceof errors.AuthenticationFailed) { + throw new errors.StartupError(err.message); + } + throw err; + } + let response = await link.messages.listSlaves.send(control); + + // console.log(response.list); + console.log(link.messages) + let data = {} + // for (let key in { + // ...link.messages, + // }) { + // if (link.messages[key].constructor.name === "Request") { + // let resp, error + // try{ + // resp = await link.messages[key].send(control) + // } catch(e){ + // error = e + // } + // data[key] = resp + // data[key].error = error + // } + // } + // console.log(data) +})() + +let listSlaves = createListFunction("listSlaves") +let listInstances = createListFunction("listInstances") +let listUsers = createListFunction("listUsers", "name") +let listRoles = createListFunction("listRoles") +let listPermissions = createListFunction("listPermissions", "name") + +let startInstance = async function ({ instance_id, save = null }) { + return await link.messages.startInstance.send(control, { instance_id, save }) +} +let stopInstance = async function ({ instance_id }) { + return await link.messages.stopInstance.send(control, { instance_id }) +} +let createInstance = async function (serialized_config) { + return await link.messages.createInstance.send(control, { serialized_config }); +} +let deleteInstance = async function ({ instance_id }) { + return await link.messages.deleteInstance.send(control, { instance_id }); +} +let assignInstance = async function ({ instance_id, slave_id }) { + return await link.messages.assignInstanceCommand.send(control, { + instance_id, + slave_id, + }); +} +let createSave = async function ({ instance_id }) { + return await link.messages.createSave.send(control, { instance_id }); +} +let getInstanceConfig = async function ({ instance_id }) { + return await link.messages.getInstanceConfig.send(control, { instance_id }); +} +let setInstanceConfigField = async function ({ instance_id, field, value }) { + return await link.messages.setInstanceConfigField.send(control, { + instance_id, + field, + value: typeof value === "object" ? JSON.stringify(value) : value, + }); +} +let setInstanceConfigProp = async function ({ instance_id, field, prop, value }) { + return await link.messages.setInstanceConfigProp.send(control, { + instance_id, + field, + prop, + value, // JSON Object + }); +} +let sendRcon = async function ({ instance_id, command }) { + return await link.messages.sendRcon.send(control, { instance_id, command }) +} +let setInstanceOutputSubscriptions = async function ({ instance_id }) { + return await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instance_id] }) +} + +let createUser = async function ({ name }) { + return await link.messages.createUser.send(control, { name }) +} +let deleteUser = async function ({ name }) { + return await link.messages.deleteUser.send(control, { name }) +} +let setRoles = async function ({ name, roles }) { + return await link.messages.updateUserRoles.send(control, { + name, + roles, + }) +} + +let createRole = async function ({ name, description, permissions = [] }) { + return await link.messages.createRole.send(control, { name, description, permissions }) +} +let updateRole = async function ({ id, name, description, permissions }) { + return await link.messages.updateRole.send(control, { + id, name, description, permissions + }) +} + +export { + listSlaves, + listInstances, + listUsers, + createUser, + deleteUser, + listRoles, + setRoles, + createRole, + updateRole, + listPermissions, + startInstance, + stopInstance, + createInstance, + deleteInstance, + assignInstance, + createSave, + getInstanceConfig, + setInstanceConfigField, + setInstanceConfigProp, + sendRcon, + setInstanceOutputSubscriptions, +} + +function createListFunction(name, key) { + return async () => { + return (await link.messages[name].send(control)).list.map(addKey(key || "id")); + } +} +function addKey(key) { + return function (el) { + return { + ...el, + key: el[key] + } + } +}