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 &&
+ {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 ? (
+

+ ) : (
+

+ )}
+
+
+
+
+
+
+ ) : (
+ ""
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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"
+ >
+
+
+
+
+ {/* rcon input field */}
+
+
+
+
+ {this.state.config && this.renderConfig(this.state.config)}
+
+ }
+ getInstanceConfigAsKvObject(config) {
+ let object = {}
+ for (let group of config.serialized_config.groups) {
+ for (let fieldName 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
+
+
+
+ :
+
+ }
+
+ {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
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
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]
+ }
+ }
+}