Import web interface from clusterio/clusterioWeb

Import the source files in use for the web interface, excluding the
copied and modified lib/link files.
This commit is contained in:
Danielv123 authored and Hornwitser committed 2020-09-28 04:19:00 +02:00
1 parent f318f8826b
commit 2382f4bbdc
17 files changed
+1795

No files matched your search

@@ -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: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
*
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
* columns - Array of objects, optional, same as <Table />. 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) => <Popover
content={
<Button
type="primary"
danger
onClick={() => {
this.deleteRecord(record)
}}
>
Slett permanent
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
})
return <>
<Row>
<Col span={24}>
<style>
{`
.editableTable .ant-table-tbody > tr {
cursor:pointer
}
`}
</style>
<Table
className={this.props.Editable && "editableTable"}
{...tableProps}
columns={columns.map(x => {
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 && <Button type="primary" onClick={() => this.setState({
showEditModal: true,
inserting: true
})}>
<PlusOutlined />Add
</Button>}
</Col>
<Modal
visible={this.state.showEditModal}
onCancel={this.hideEditModal}
footer={null}
title={
<h2>{this.state.inserting ? "Legg til" : "Rediger"}</h2>
}
>
{this.state.showEditModal && <Form
labelCol={{
span: 6,
}}
wrapperCol={{
span: 12,
}}
layout="horizontal"
onFinish={this.onEditFinish}
initialValues={this.state.record}
>
{
(
(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 <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit?.(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
placeholder={this.state.record[col.dataIndex]}
/>}
</Form.Item>
})
}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Legg til" : "Lagre"}
</Button>
</Form.Item>
</Form>}
</Modal>
</Row>
</>
}
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;
+23
View File
@@ -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;
}
+29
View File
@@ -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(
// <React.StrictMode>
// <Layout />
// </React.StrictMode>,
// document.getElementById('root')
// );
ReactDOM.render(
<BrowserRouter>
<Layout />
</BrowserRouter>,
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();
+239
View File
@@ -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 (
<Router>
<Layout style={{ minHeight: "100vh" }}>
<Helmet>
<meta charSet="utf-8" />
<title>Clusterio 2.0</title>
<link
href="https://fonts.googleapis.com/css?family=Titillium+Web:200,400&display=swap"
rel="stylesheet"
></link>
</Helmet>
{document.location.search !== "?hideExtras" ? (
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse}
width={250}
breakpoint="lg"
>
<p></p>
<div style={{ width: "100%", textAlign: "center" }}>
{" "}
{this.state.collapsed ? (
<img
alt=""
src="/smal_logo.png"
style={{ height: "48px", margin: 8 }}
/>
) : (
<img
alt=""
src="/logo.png"
style={{ width: "200px", margin: 8 }}
/>
)}
</div>
<p></p>
<p></p>
<Menu
theme="dark"
defaultSelectedKeys={[
this.state.location.pathname.split("/")[
this.state.location.pathname.split("/").length - 1
],
]}
defaultOpenKeys={(() => {
let x = this.state.location.pathname.split("/");
x.shift();
return x;
})()}
mode="inline"
>
<Menu.Item key="slaves">
<span>Slaves</span>
<Link to="/slaves" />
</Menu.Item>
<Menu.Item key="instances">
<span>Instances</span>
<Link to="/instances" />
</Menu.Item>
<Menu.Item key="users">
<span>Users</span>
<Link to="/users" />
</Menu.Item>
<Menu.Item key="roles">
<span>Roles</span>
<Link to="/roles" />
</Menu.Item>
<Menu.Item key="permissions">
<span>Permissions</span>
<Link to="/permissions" />
</Menu.Item>
<Menu.Item key="settings">
<span>Settings</span>
<Link to="/settings" />
</Menu.Item>
</Menu>
</Sider>
) : (
""
)}
<Layout style={{
// background: "#F4F4F4"
}}>
<Content
style={{
margin: "24px 16px",
padding: 24,
// background: "#fff",
minHeight: 280,
}}
>
<Route exact path="/" component={Dashboard} />
<Route
exact
path="/instances"
component={InstancesTable}
/>
<Route
exact
path="/instances/:id/view"
component={InstanceView}
/>
<Route
exact
path="/slaves"
component={SlavesTable}
/>
<Route
exact
path="/slaves/:id/view"
component={SlavesTable}
/>
<Route
exact
path="/users"
component={UsersTable}
/>
<Route
exact
path="/users/:id/view"
component={UserView}
/>
<Route
exact
path="/roles"
component={RolesTable}
/>
<Route
exact
path="/roles/:id/view"
component={RoleView}
/>
<Route
exact
path="/permissions"
component={PermissionsTable}
/>
<Route
exact
path="/permissions/:id/view"
component={PermissionsTable}
/>
<Route
exact
path="/settings"
component={Settings}
/>
</Content>
<Footer style={{
// background: "#F4F4F4",
textAlign: "center"
}}>
Clusterio 2.0 web interface https://github.com/clusterio
</Footer>
</Layout>
</Layout>
</Router>
);
}
}
export default withRouter(SiteLayout);
+10
View File
@@ -0,0 +1,10 @@
import React, {Component} from "react"
export class Dashboard extends Component {
constructor(props){
super(props)
}
render(){
return <h2>Dashboard</h2>
}
}
@@ -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 <Card>
<h2>{instance.name}</h2>
<p>ID: {instance.id}</p>
<p>Slave: {slaves?.find(x => x.id === instance.assigned_slave)?.name}</p>
{instance.status !== "running" && <Button
loading={this.state.starting}
type="primary"
onClick={this.start}
>
Start server
</Button>}
{instance.status !== "stopped" && <Button
loading={this.state.stopping}
type="primary"
onClick={this.stop}
>
Stop server
</Button>}
{instance.status !== "running" && <Button
loading={this.state.creatingSave}
type="primary"
onClick={this.createSave}
>
Create save
</Button>}
<Popover
content={
<Select style={{
width: "200px"
}}
onChange={async newSlaveId => {
let result = await assignInstance({ instance_id: instance.id, slave_id: newSlaveId })
notify("Assigned to slave", "success")
this.getData()
}}
>
{this.state.slaves.map(slave => <Option defaultValue={instance.assigned_slave} value={slave.id} key={slave.id}>
{slave.name}
</Option>)}
</Select>
}
trigger="click"
type={instance.status === "unassigned" ? "primary" : "default"}
>
<Button>
Assign to slave
</Button>
</Popover>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteInstance({ instance_id: instance.id });
notify("Deleted instance " + instance.id, "success")
this.navigate("/instances")
}}
>
Delete this instance permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<style>
{/* Scroll sticks to bottom of terminal window after manually scrolling to the bottom */}
{`#scroller * {
/* don't allow the children of the scrollable element to be selected as an anchor node */
overflow-anchor: none;
}
#anchor {
/* allow the final child to be selected as an anchor node */
overflow-anchor: auto;
/* anchor nodes are required to have non-zero area */
height: 1px;
}`}
</style>
<div id="scroller" style={{
height: "300px",
overflowY: "scroll",
}}>
{this.state.logLines.map(item => <p>{item.time} {item.level} {item.file}: {item.message}</p>)}
<div id="anchor" ref={this.messagesEndRef}></div>
</div>
{/* rcon input field */}
<Form
onFinish={async values => {
await sendRcon({ instance_id: instance.id, command: values.command })
}}
>
<Form.Item
label="RCON: "
name="command"
>
<Input />
</Form.Item>
</Form>
{this.state.config && this.renderConfig(this.state.config)}
</Card>
}
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 <Card>
<Form
{...layout}
initialValues={this.getInstanceConfigAsKvObject(config)}
onFinish={this.onConfigEditFinish}
>
{this.state.editingConfig ?
<Form.Item>
<Button type="primary" htmlType="submit">
Save config changes
</Button>
</Form.Item>
:
<Button onClick={() => this.setState({ editingConfig: true })}>
<EditOutlined /> Edit config
</Button>
}
<Row>
{config && config.serialized_config.groups.map(group => <>
<Col span={24} lg={12}>
<Divider
// orientation="left"
>{group.name}</Divider>
{/* {Object.keys(group.fields).map(field => <>
<span>{field}: </span>
<span>{JSON.stringify(group.fields[field])}</span>
<br />
</>)} */}
{console.log(group)}
{Object.keys(group.fields).map(field => <ConfigFormItem field={field} group={group} editingConfig={this.state.editingConfig} />)}
</Col>
</>)}
</Row>
</Form>
</Card>
}
}
function renderDisplayField(value) {
if (typeof value === "object") {
return JSON.stringify(value)
}
if (typeof value === "boolean") {
return <Checkbox />
}
return value.toString()
}
function renderFormField(value) {
if (typeof value === "object") {
return <ObjectInput />
}
if (typeof value === "boolean") {
return <Checkbox />
}
return <Input />
}
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(<Checkbox
key={key}
checked={value[key]}
onChange={e => this.onChange(e, key)}
>
{key}
</Checkbox>
)
} else if (typeof value[key] === "string") {
ret.push(<div
key={key}
><span>{key}</span><Input
value={value[key]}
onChange={e => this.onChange(e, key)}
/></div>
)
}
}
ret.push(<Popover
key="addMore"
content={<>
<Input onChange={e => this.setState({ newInput: e.target.value })} />
<Button
onClick={e => {
this.onChange({ target: { value: "", type: "text" } }, this.state.newInput)
this.setState({
newInput: undefined,
addPopoverVisible: false,
})
}}
><PlusOutlined />Add property</Button>
</>}
trigger="click"
visible={this.state.addPopoverVisible}
handleVisibleChange={visible => this.setState({ addPopoverVisible: visible })}
>
<Button
onClick={e => this.setState({ addPopoverVisible: true })}
>
<PlusOutlined />Add property
</Button>
</Popover>)
return <Space direction="vertical">{ret}</Space>
}
}
class ConfigFormItem extends Component {
constructor(props) {
super(props)
}
render() {
let { field, group, editingConfig } = this.props
return <Form.Item
label={field}
name={group.name + "." + field}
valuePropName={typeof group.fields[field] === "boolean" ? "checked" : "value" /* Boolean fields should be rendered as checkboxes */}
>
{editingConfig ?
renderFormField(group.fields[field])
:
renderDisplayField(group.fields[field])
}
</Form.Item>
}
}
@@ -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 <Card>
<h2>Instances</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name"
}],
insert: async args => {
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`)
}
}
}
}}
/>
</Card>
}
}
@@ -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 <Card>
<h2>Permissions</h2>
<DataTable
DataFunction={listPermissions}
TableProps={{
onRow: (record, rowIndex) => {
return {
onClick: event => {
this.navigate(`/permissions/${record.id}/view`)
}
}
}
}}
/>
</Card>
}
}
+111
View File
@@ -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 <Card>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ role: role.name })
notify("Deleted role " + role.name, "success")
this.navigate("/roles")
}}
>
Delete this role permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<h2>{role.name}</h2>
<h3>Assign roles</h3>
<style>
{`
.ant-transfer-list{
width: calc( 50% - 34px );
height: 500px;
}
`}
</style>
<Transfer
titles={["Available", "Assigned"]}
dataSource={permissions}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={role.permissions}
onChange={this.handleChange}
render={item => item.name}
/>
</Card>
}
}
@@ -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 <Card>
<h2>Roles</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name"
},{
dataIndex: "description",
title: "Description"
}],
insert: async args => {
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`)
}
}
}
}}
/>
</Card>
}
}
@@ -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 <Card>
<h2>Web interface settings</h2>
<Form
{...layout}
name="basic"
initialValues={{
master_url: localStorage.getItem("master_url"),
master_token: localStorage.getItem("master_token"),
}}
onFinish={values => {
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
}}
>
<Form.Item
label="Master URL"
name="master_url"
rules={[
{
required: true,
message: 'Please input master url, ex ws://localhost:8080 or wss://localhost:8443/ . For SSL connections, ensure you have a valid certificate.',
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="User auth token"
name="master_token"
rules={[
{
required: true,
message: 'Please input your user auth token from config-control.json',
},
]}
>
<Input />
</Form.Item>
<Form.Item {...tailLayout}>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Form>
</Card>
}
}
@@ -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 <Card>
<h2>Slaves</h2>
<DataTable
DataFunction={listSlaves}
TableProps={{
onRow: (record, rowIndex) => {
return {
onClick: event => {
this.navigate(`/slaves/${record.id}/view`)
}
}
}
}}
/>
</Card>
}
}
+101
View File
@@ -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 <Card>
<h2>{user.name}</h2>
<h3>Assign roles</h3>
<Transfer
titles={["Available","Assigned"]}
dataSource={roles}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={user.roles}
onChange={this.handleChange}
render={item => item.name}
/>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ name: user.name })
notify("Deleted user " + user.name, "success")
this.navigate("/users")
}}
>
Delete this user from the cluster
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
</Card>
}
}
@@ -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 <Card>
<h2>Slaves</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name"
}],
insert: async args => {
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`)
}
}
}
}}
/>
</Card>
}
}
+15
View File
@@ -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)
}
+112
View File
@@ -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<string, module:lib/plugin.BaseControlPlugin>}
*/
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
}
+156
View File
@@ -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]
}
}
}