mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Refactor web interface
Reimplement the pages of the web interface in order to make them prettier and more easily amended by plugins.
This commit is contained in:
1 parent
0c0626564a
commit
f67f56e6de
32 files changed
+1502
-1059
No files matched your search
+1
-1
@@ -71,7 +71,7 @@ module.exports = {
|
|||||||
"default-case-last": "error",
|
"default-case-last": "error",
|
||||||
"default-param-last": "error",
|
"default-param-last": "error",
|
||||||
"dot-location": ["error", "property"],
|
"dot-location": ["error", "property"],
|
||||||
"dot-notation": ["warn", { "allowKeywords": true }],
|
"dot-notation": ["warn", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||||
"eol-last": "error",
|
"eol-last": "error",
|
||||||
"eqeqeq": "error",
|
"eqeqeq": "error",
|
||||||
"func-call-spacing": "error",
|
"func-call-spacing": "error",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
"css-loader": "^4.3.0",
|
"css-loader": "^4.3.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ async function load() {
|
|||||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
||||||
|
|
||||||
let controlConnector = new ControlConnector(wsUrl, 10);
|
let controlConnector = new ControlConnector(wsUrl, 10);
|
||||||
// XXX come up with a better way of sharing this
|
let control = new Control(controlConnector, []);
|
||||||
window.control = new Control(controlConnector, []);
|
|
||||||
|
|
||||||
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
|
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import basename from "../basename";
|
import basename from "../basename";
|
||||||
import SiteLayout from "../layout";
|
import SiteLayout from "./SiteLayout";
|
||||||
import ControlContext from "./ControlContext";
|
import ControlContext from "./ControlContext";
|
||||||
import LoginForm from "./LoginForm";
|
import LoginForm from "./LoginForm";
|
||||||
|
|
||||||
@@ -21,8 +21,6 @@ export default function App(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Effect called");
|
|
||||||
|
|
||||||
function onConnect() {
|
function onConnect() {
|
||||||
localStorage.setItem("master_token", token);
|
localStorage.setItem("master_token", token);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
import React, { useEffect, useContext, useState } from "react";
|
||||||
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal,
|
||||||
|
Popconfirm, Row, Select, Space, Spin, Tree, Typography,
|
||||||
|
} from "antd";
|
||||||
|
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
import libConfig from "@clusterio/lib/config";
|
||||||
|
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
import { notifyErrorHandler } from "../util/notify";
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
||||||
|
function useInstance(id) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [instance, setInstance] = useState({ loading: true });
|
||||||
|
|
||||||
|
function updateInstance() {
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
setInstance({ missing: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX optimize by requesting only the instance in question
|
||||||
|
libLink.messages.listInstances.send(control).then(result => {
|
||||||
|
let match = result.list.find(i => i.id === id);
|
||||||
|
if (!match) {
|
||||||
|
setInstance({ missing: true });
|
||||||
|
} else {
|
||||||
|
setInstance({ ...match, present: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateInstance();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return [instance, updateInstance];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSlave(id) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [slave, setSlave] = useState({ loading: true });
|
||||||
|
|
||||||
|
function updateSlave() {
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
setSlave({ missing: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX optimize by requesting only the slave in question
|
||||||
|
libLink.messages.listSlaves.send(control).then(result => {
|
||||||
|
let match = result.list.find(i => i.id === id);
|
||||||
|
if (!match) {
|
||||||
|
setSlave({ missing: true });
|
||||||
|
} else {
|
||||||
|
setSlave({ ...match, present: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSlave();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return [slave, updateSlave];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutputColored(output, key) {
|
||||||
|
let time = "";
|
||||||
|
if (output.format === "seconds") {
|
||||||
|
time = <span className="factorio-time">{output.time.padStart(8)} </span>;
|
||||||
|
} else if (output.format === "date") {
|
||||||
|
time = <span className="factorio-time">{output.time} </span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = "";
|
||||||
|
if (output.type === "log") {
|
||||||
|
let level = output.level;
|
||||||
|
if (level === "Info") {
|
||||||
|
level = <span className="factorio-info">{level}</span>;
|
||||||
|
} else if (output.level === "Warning") {
|
||||||
|
level = <span className="factorio-warning">{level}</span>;
|
||||||
|
} else if (output.level === "Error") {
|
||||||
|
level = <span className="factorio-error">{level}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
info = <>{level} <span className="factorio-filename">{output.file}: </span></>;
|
||||||
|
|
||||||
|
} else if (output.type === "action") {
|
||||||
|
info = <>[<span className="factorio-action">{output.action}</span>] </>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={key}>{time}{info}{output.message}<br/></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceConsole(props) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [lines, setLines] = useState([{ message: "<previous messages are not shown>" }]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function outputHandler(output) {
|
||||||
|
setLines(currentLines => currentLines.concat([output]));
|
||||||
|
}
|
||||||
|
|
||||||
|
control.onInstanceOutput(props.id, outputHandler);
|
||||||
|
return () => {
|
||||||
|
control.offInstanceOutput(props.id, outputHandler);
|
||||||
|
};
|
||||||
|
}, [props.id]);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Paragraph code className="instance-console">
|
||||||
|
{lines.map(formatOutputColored)}
|
||||||
|
<div className="scroll-anchor" />
|
||||||
|
</Paragraph>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceRcon(props) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [output, setOutput] = useState(null);
|
||||||
|
let [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
async function sendCommand(command) {
|
||||||
|
if (!command) {
|
||||||
|
setOutput(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
let result = await libLink.messages.sendRcon.send(control, {
|
||||||
|
instance_id: props.id,
|
||||||
|
command: command,
|
||||||
|
});
|
||||||
|
setOutput(result.result);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{output && <>
|
||||||
|
<Title level={5}>Rcon result</Title>
|
||||||
|
<Paragraph code className="rcon-result">{output}</Paragraph>
|
||||||
|
</>}
|
||||||
|
<Input.Search
|
||||||
|
disabled={props.disabled}
|
||||||
|
placeholder="Send RCON Command"
|
||||||
|
enterButton="Send"
|
||||||
|
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
|
||||||
|
loading={running}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignInstanceModal(props) {
|
||||||
|
let [visible, setVisible] = useState(false);
|
||||||
|
let [slaves, setSlaves] = useState([]);
|
||||||
|
let [loading, setLoading] = useState(true);
|
||||||
|
let [applying, setApplying] = useState(false);
|
||||||
|
let [form] = Form.useForm();
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
setVisible(true);
|
||||||
|
libLink.messages.listSlaves.send(control).then((result) => {
|
||||||
|
setSlaves(result.list);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssign() {
|
||||||
|
let slaveId = form.getFieldValue("slave");
|
||||||
|
if (slaveId === undefined) {
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplying(true);
|
||||||
|
libLink.messages.assignInstanceCommand.send(control, {
|
||||||
|
instance_id: props.id,
|
||||||
|
slave_id: slaveId,
|
||||||
|
}).then(() => {
|
||||||
|
setVisible(false);
|
||||||
|
if (props.onFinish) {
|
||||||
|
props.onFinish();
|
||||||
|
}
|
||||||
|
}).catch(
|
||||||
|
notifyErrorHandler("Error assigning instance")
|
||||||
|
).finally(
|
||||||
|
() => setApplying(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button {...props.buttonProps} onClick={open}>
|
||||||
|
{props.buttonContent || "Assign"}
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
title="Assign Instance"
|
||||||
|
okText="Assign"
|
||||||
|
visible={visible}
|
||||||
|
confirmLoading={applying}
|
||||||
|
onOk={handleAssign}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Paragraph style={{ maxWidth: "30em" }}>
|
||||||
|
Select a Slave to assign this instance to. Assignment
|
||||||
|
creates the necessary files on the slave to start up
|
||||||
|
the instance. Note that reassigning an instance from
|
||||||
|
one slave to another will not move the server save over.
|
||||||
|
</Paragraph>
|
||||||
|
<Form form={form} initialValues={{ slave: props.slaveId }}>
|
||||||
|
<Form.Item name="slave" label="Slave">
|
||||||
|
<Select loading={loading}>
|
||||||
|
{slaves.map((slave) => <Select.Option
|
||||||
|
key={slave["id"]}
|
||||||
|
value={slave["id"]}
|
||||||
|
disabled={!slave["connected"]}
|
||||||
|
>
|
||||||
|
{slave["name"]}
|
||||||
|
{!slave["connected"] && " (offline)"}
|
||||||
|
</Select.Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartStopInstanceButton(props) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [switching, setSwitching] = useState(false);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
setSwitching(true);
|
||||||
|
let action;
|
||||||
|
if (props.instance["status"] === "stopped") {
|
||||||
|
action = libLink.messages.startInstance.send(
|
||||||
|
control, { instance_id: props.instance["id"], save: null }
|
||||||
|
).catch(
|
||||||
|
notifyErrorHandler("Error starting instance")
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (["starting", "running"].includes(props.instance["status"])) {
|
||||||
|
action = libLink.messages.stopInstance.send(
|
||||||
|
control, { instance_id: props.instance["id"] }
|
||||||
|
).catch(
|
||||||
|
notifyErrorHandler("Error stopping instance")
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
setSwitching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
action.finally(() => {
|
||||||
|
setSwitching(false);
|
||||||
|
if (props.onFinish) {
|
||||||
|
props.onFinish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button
|
||||||
|
{...(props.buttonProps || {})}
|
||||||
|
loading={switching}
|
||||||
|
type="primary"
|
||||||
|
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
|
||||||
|
</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function InstanceConfigTree(props) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [config, setConfig] = useState(null);
|
||||||
|
let [form] = Form.useForm();
|
||||||
|
let [changedFields, setChangedFields] = useState(new Set());
|
||||||
|
let [errorFields, setErrorFields] = useState(new Map());
|
||||||
|
let [applying, setApplying] = useState(false);
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
let result = await libLink.messages.getInstanceConfig.send(control, {
|
||||||
|
instance_id: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let newConfig = new libConfig.InstanceConfig();
|
||||||
|
await newConfig.load(result.serialized_config);
|
||||||
|
setConfig(newConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { updateConfig(); }, [props.id]);
|
||||||
|
|
||||||
|
function renderInput(group, def) {
|
||||||
|
if (def.type === "boolean") {
|
||||||
|
return <Checkbox/>;
|
||||||
|
}
|
||||||
|
if (def.type === "string") {
|
||||||
|
return <Input/>;
|
||||||
|
}
|
||||||
|
if (def.type === "number") {
|
||||||
|
return <InputNumber/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unknown type ${def.type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config === null) {
|
||||||
|
return <Spin/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialValues = {};
|
||||||
|
let treeData = [];
|
||||||
|
let propsMap = new Map();
|
||||||
|
for (let [name, GroupClass] of libConfig.InstanceConfig.groups) {
|
||||||
|
let group = config.group(name);
|
||||||
|
let treeNode = {
|
||||||
|
key: group.name,
|
||||||
|
title: group.name,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let def of GroupClass.definitions.values()) {
|
||||||
|
let value = group.get(def.name);
|
||||||
|
let fieldName = `${group.name}.${def.name}`;
|
||||||
|
let childNode = {
|
||||||
|
key: fieldName,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (def.type === "object") {
|
||||||
|
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
|
||||||
|
for (let prop of Object.keys(value)) {
|
||||||
|
let propPath = `${group.name}.${def.name}.${prop}`;
|
||||||
|
childNode.children.push({
|
||||||
|
key: propPath,
|
||||||
|
title: <Form.Item
|
||||||
|
name={propPath}
|
||||||
|
label={`"${prop}"`}
|
||||||
|
rules={[{ validator: (_, fieldValue) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(fieldValue);
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.reject(new Error("Must be valid json"));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}}]}
|
||||||
|
>
|
||||||
|
<Input className="json-input" />
|
||||||
|
</Form.Item>,
|
||||||
|
});
|
||||||
|
|
||||||
|
initialValues[propPath] = JSON.stringify(value[prop]);
|
||||||
|
propsMap.set(propPath, [fieldName, prop]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
childNode.title = <Form.Item
|
||||||
|
name={fieldName}
|
||||||
|
label={def.title || def.name}
|
||||||
|
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||||
|
help={errorFields.get(fieldName)}
|
||||||
|
tooltip={def.description}
|
||||||
|
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
||||||
|
>
|
||||||
|
{renderInput(group, def)}
|
||||||
|
</Form.Item>;
|
||||||
|
initialValues[fieldName] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
treeNode.children.push(childNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
treeData.push(treeNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onValuesChange(changedValues) {
|
||||||
|
let newChangedFields = new Set(changedFields);
|
||||||
|
let changed = false;
|
||||||
|
for (let [key, value] of Object.entries(changedValues)) {
|
||||||
|
if (initialValues[key] === value) {
|
||||||
|
changed = newChangedFields.delete(key) || changed;
|
||||||
|
} else {
|
||||||
|
changed = !newChangedFields.has(key) || changed;
|
||||||
|
newChangedFields.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setChangedFields(newChangedFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
Config
|
||||||
|
<Space style={{ float: "right" }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
loading={applying}
|
||||||
|
onClick={async () => {
|
||||||
|
setApplying(true);
|
||||||
|
for (let field of changedFields) {
|
||||||
|
let value = form.getFieldValue(field);
|
||||||
|
let request;
|
||||||
|
if (propsMap.has(field)) {
|
||||||
|
let [fieldName, prop] = propsMap.get(field);
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch (err) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = libLink.messages.setInstanceConfigProp.send(control, {
|
||||||
|
instance_id: props.id,
|
||||||
|
field: fieldName,
|
||||||
|
prop,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
request = libLink.messages.setInstanceConfigField.send(control, {
|
||||||
|
instance_id: props.id,
|
||||||
|
field,
|
||||||
|
value: String(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request;
|
||||||
|
setChangedFields(changed => {
|
||||||
|
let newChanged = new Set(changed);
|
||||||
|
newChanged.delete(field);
|
||||||
|
return newChanged;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setErrorFields(errors => {
|
||||||
|
let newErrors = new Map(errors);
|
||||||
|
newErrors.set(field, err.message);
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateConfig();
|
||||||
|
setApplying(false);
|
||||||
|
|
||||||
|
if (props.onApply) {
|
||||||
|
props.onApply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={changedFields.size === 0}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setChangedFields(new Set());
|
||||||
|
setErrorFields(new Map());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Title>
|
||||||
|
<Card size="small" className="config-container">
|
||||||
|
<Form
|
||||||
|
size="small"
|
||||||
|
form={form}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onValuesChange={onValuesChange}
|
||||||
|
>
|
||||||
|
<Tree
|
||||||
|
treeData={treeData}
|
||||||
|
selectable={false}
|
||||||
|
defaultExpandAll={true}
|
||||||
|
filterTreeNode={(node) => changedFields.has(node.key)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InstanceViewPage(props) {
|
||||||
|
let params = useParams();
|
||||||
|
let instanceId = Number(params.id);
|
||||||
|
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [instance, updateInstance] = useInstance(instanceId);
|
||||||
|
let [slave] = useSlave(Number(instance["assigned_slave"]));
|
||||||
|
|
||||||
|
let [creatingSave, setCreatingSave] = useState(false);
|
||||||
|
|
||||||
|
let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }];
|
||||||
|
if (instance.loading) {
|
||||||
|
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.missing) {
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<h2>Instance not found</h2>
|
||||||
|
<p>Instance with id {instanceId} was not found on the master server.</p>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instanceButtons = <Space>
|
||||||
|
<StartStopInstanceButton
|
||||||
|
instance={instance}
|
||||||
|
onFinish={updateInstance}
|
||||||
|
/>
|
||||||
|
{instance.status === "stopped" && <Button
|
||||||
|
loading={creatingSave}
|
||||||
|
onClick={() => {
|
||||||
|
setCreatingSave(true);
|
||||||
|
libLink.messages.createSave.send(
|
||||||
|
control, { instance_id: instanceId }
|
||||||
|
).catch(notifyErrorHandler("Error creating save")).finally(() => {
|
||||||
|
updateInstance();
|
||||||
|
setCreatingSave(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create save
|
||||||
|
</Button>}
|
||||||
|
<Popconfirm
|
||||||
|
title="Permanently delete instance and server saves?"
|
||||||
|
okText="Delete"
|
||||||
|
placement="bottomRight"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => {
|
||||||
|
libLink.messages.deleteInstance.send(
|
||||||
|
control, { instance_id: instanceId }
|
||||||
|
).then(() => {
|
||||||
|
history.push("/instances");
|
||||||
|
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>;
|
||||||
|
|
||||||
|
let assigned = instance["assigned_slave"] !== null;
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<Descriptions
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
title={instance["name"]}
|
||||||
|
extra={instanceButtons}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="Slave">
|
||||||
|
{!assigned
|
||||||
|
? <em>Unassigned</em>
|
||||||
|
: slave["name"] || instance["assigned_slave"]
|
||||||
|
}
|
||||||
|
<AssignInstanceModal
|
||||||
|
id={instanceId}
|
||||||
|
slaveId={instance["assigned_slave"]}
|
||||||
|
buttonProps={{
|
||||||
|
size: "small",
|
||||||
|
style: { float: "Right" },
|
||||||
|
type: assigned ? "default" : "primary",
|
||||||
|
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
|
||||||
|
}}
|
||||||
|
buttonContent={assigned ? "Reassign" : "Assign"}
|
||||||
|
onFinish={updateInstance}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||||
|
<InstanceConsole id={instanceId} />
|
||||||
|
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||||
|
|
||||||
|
<InstanceConfigTree id={instanceId} onApply={updateInstance} />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
import libConfig from "@clusterio/lib/config";
|
||||||
|
|
||||||
|
import DataTable from "./data-table";
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
|
||||||
|
|
||||||
|
export default function InstancesPage() {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
async function listInstances() {
|
||||||
|
let result = await libLink.messages.listInstances.send(control);
|
||||||
|
return result["list"].map(item => ({
|
||||||
|
key: item["id"],
|
||||||
|
"Name": item["name"],
|
||||||
|
"Assigned Slave": item["assigned_slave"],
|
||||||
|
"Status": item["status"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||||
|
<h2>Instances</h2>
|
||||||
|
<DataTable
|
||||||
|
DataFunction={listInstances}
|
||||||
|
AddRecord={{
|
||||||
|
fields: [{
|
||||||
|
dataIndex: "name",
|
||||||
|
title: "Name",
|
||||||
|
}],
|
||||||
|
insert: async args => {
|
||||||
|
let instanceConfig = new libConfig.InstanceConfig();
|
||||||
|
await instanceConfig.init();
|
||||||
|
console.log("Base config", instanceConfig);
|
||||||
|
instanceConfig.set("instance.name", args.name);
|
||||||
|
let serialized_config = instanceConfig.serialize();
|
||||||
|
let response = await libLink.messages.createInstance.send(control, { serialized_config });
|
||||||
|
console.log("Created instance", response);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
TableProps={{
|
||||||
|
onRow: (record, rowIndex) => ({
|
||||||
|
onClick: event => {
|
||||||
|
console.log(record, rowIndex);
|
||||||
|
history.push(`/instances/${record.key}/view`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Layout, Menu, Breadcrumb, Typography } from "antd";
|
||||||
|
|
||||||
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
export default function PageLayout(props) {
|
||||||
|
return <>
|
||||||
|
<Breadcrumb style={{ margin: "16px 0 16px 28px" }}>
|
||||||
|
{props.nav.map((part, index) => <Breadcrumb.Item key={index}>
|
||||||
|
{part.path ? <Link to={part.path}>{part.name}</Link> : part.name}
|
||||||
|
</Breadcrumb.Item>)}
|
||||||
|
</Breadcrumb>
|
||||||
|
<Content className="site-layout-content" >
|
||||||
|
{props.children}
|
||||||
|
</Content>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useContext, useState } from "react";
|
||||||
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
|
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
|
||||||
|
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
import libUsers from "@clusterio/lib/users";
|
||||||
|
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
import { notifyErrorHandler } from "../util/notify";
|
||||||
|
|
||||||
|
|
||||||
|
function useRole(id) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [role, setRole] = useState({ loading: true });
|
||||||
|
|
||||||
|
function updateRole() {
|
||||||
|
// XXX optimize by requesting only the role in question
|
||||||
|
libLink.messages.listRoles.send(control).then(result => {
|
||||||
|
let match = result.list.find(u => u.id === id);
|
||||||
|
if (!match) {
|
||||||
|
setRole({ missing: true });
|
||||||
|
} else {
|
||||||
|
setRole({
|
||||||
|
...match,
|
||||||
|
present: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateRole();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return [role, updateRole];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleViewPage() {
|
||||||
|
let params = useParams();
|
||||||
|
let roleId = Number(params.id);
|
||||||
|
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [role, updateRole] = useRole(roleId);
|
||||||
|
let [edited, setEdited] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
|
||||||
|
if (role.loading) {
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<h2>{roleId}</h2>
|
||||||
|
<Spin size="large" />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.missing) {
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<h2>Role not found</h2>
|
||||||
|
<p>Role with id {roleId} was not found on the master server.</p>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialValues = {
|
||||||
|
name: role["name"],
|
||||||
|
description: role["description"],
|
||||||
|
permissions: {
|
||||||
|
...Object.fromEntries([...libUsers.permissions.values()].map(perm => [
|
||||||
|
perm.name, role["permissions"].includes(perm.name),
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<Form
|
||||||
|
initialValues={initialValues}
|
||||||
|
onValuesChange={() => { setEdited(true); }}
|
||||||
|
onFinish={values => {
|
||||||
|
console.log(values);
|
||||||
|
let newPermissions = [];
|
||||||
|
for (let [name, value] of Object.entries(values.permissions)) {
|
||||||
|
if (value) { newPermissions.push(name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
libLink.messages.updateRole.send(control, {
|
||||||
|
id: roleId,
|
||||||
|
name: values.name || "",
|
||||||
|
description: values.description || "",
|
||||||
|
permissions: newPermissions,
|
||||||
|
}).then(() => {
|
||||||
|
setEdited(false);
|
||||||
|
updateRole();
|
||||||
|
}).catch(notifyErrorHandler("Error applying changes"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<Col flex="auto">
|
||||||
|
<h2>{role["name"]}</h2>
|
||||||
|
</Col>
|
||||||
|
<Col flex="0 0 auto">
|
||||||
|
<Space>
|
||||||
|
<Button type={edited ? "primary" : "default"} htmlType="submit">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete role?"
|
||||||
|
placement="bottomRight"
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => {
|
||||||
|
libLink.messages.deleteRole.send(
|
||||||
|
control, { id: roleId }
|
||||||
|
).then(() => {
|
||||||
|
history.push("/roles");
|
||||||
|
}).catch(notifyErrorHandler("Error deleting role"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button danger >
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="name" label="Name">
|
||||||
|
<Input/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="Description">
|
||||||
|
<Input/>
|
||||||
|
</Form.Item>
|
||||||
|
<h3>Permissions</h3>
|
||||||
|
{[...libUsers.permissions.values()].map(({name, title, description}) => (
|
||||||
|
<Form.Item
|
||||||
|
name={["permissions", name]}
|
||||||
|
key={name}
|
||||||
|
label={title}
|
||||||
|
tooltip={description}
|
||||||
|
valuePropName="checked"
|
||||||
|
labelCol={{ sm: { span: 22, push: 2 }}}
|
||||||
|
wrapperCol={{ sm: { span: 2, pull: 22 }}}
|
||||||
|
labelAlign="left"
|
||||||
|
colon={false}
|
||||||
|
>
|
||||||
|
<Checkbox/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
|
||||||
|
import DataTable from "./data-table";
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
async function listRoles() {
|
||||||
|
let result = await libLink.messages.listRoles.send(control);
|
||||||
|
return result["list"].map(item => ({
|
||||||
|
key: item["id"],
|
||||||
|
"Name": item["name"],
|
||||||
|
"Description": item["description"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||||
|
<h2>Roles</h2>
|
||||||
|
<DataTable
|
||||||
|
DataFunction={listRoles}
|
||||||
|
AddRecord={{
|
||||||
|
fields: [
|
||||||
|
{ dataIndex: "name", title: "Name" },
|
||||||
|
{ dataIndex: "description", title: "Description" },
|
||||||
|
],
|
||||||
|
insert: async args => {
|
||||||
|
await libLink.messages.createRole.send(control, {
|
||||||
|
name: args.name,
|
||||||
|
description: args.description || "",
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
TableProps={{
|
||||||
|
onRow: (record, rowIndex) => ({
|
||||||
|
onClick: event => {
|
||||||
|
console.log(record, rowIndex);
|
||||||
|
history.push(`/roles/${record.key}/view`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Switch, Route, useHistory } from "react-router-dom";
|
||||||
|
import { Layout, Menu } from "antd";
|
||||||
|
import webUiPackage from "../../package.json";
|
||||||
|
|
||||||
|
const { Header, Sider } = Layout;
|
||||||
|
|
||||||
|
|
||||||
|
import SlavesPage from "./SlavesPage";
|
||||||
|
import InstancesPage from "./InstancesPage";
|
||||||
|
import InstanceViewPage from "./InstanceViewPage";
|
||||||
|
import UsersPage from "./UsersPage";
|
||||||
|
import UserViewPage from "./UserViewPage";
|
||||||
|
import RolesPage from "./RolesPage";
|
||||||
|
import RoleViewPage from "./RoleViewPage";
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
|
||||||
|
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
|
||||||
|
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebar = [
|
||||||
|
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
|
||||||
|
{ name: "Instances", path: "/instances", content: <InstancesPage />},
|
||||||
|
{ name: "Users", path: "/users", content: <UsersPage /> },
|
||||||
|
{ name: "Roles", path: "/roles", content: <RolesPage /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
export default function SiteLayout(props) {
|
||||||
|
let history = useHistory();
|
||||||
|
let [sidebarPath, setSidebarPath] = useState(null);
|
||||||
|
|
||||||
|
function SetSidebar(setSidebarProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarPath(setSidebarProps.path);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Layout style={{ minHeight: "100vh" }}>
|
||||||
|
<Header className="header">
|
||||||
|
<div className="site-logo" />
|
||||||
|
<span className="site-name" style={{ top: -10 }}>Clusterio</span>
|
||||||
|
<span style={{ position: "absolute", top: 10, left: 120, color: "#f00" }}>{ webUiPackage.version }</span>
|
||||||
|
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
|
||||||
|
<Menu.Item key="1">Dashboard</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</Header>
|
||||||
|
<Layout>
|
||||||
|
<Sider
|
||||||
|
collapsible
|
||||||
|
collapsedWidth={0}
|
||||||
|
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
|
||||||
|
width={250}
|
||||||
|
className="site-layout-sider"
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[sidebarPath]}
|
||||||
|
style={{ height: "100%", borderRight: 0 }}
|
||||||
|
onClick={({ key }) => history.push(key)}
|
||||||
|
>
|
||||||
|
{sidebar.map(({name, path}) => <Menu.Item key={path}>{name}</Menu.Item>)}
|
||||||
|
</Menu>
|
||||||
|
</Sider>
|
||||||
|
<Layout style={{ padding: "0 24px 24px" }}>
|
||||||
|
<Switch>
|
||||||
|
{sidebar.map(({path, content}) => <Route exact path={path} key={path}>
|
||||||
|
<SetSidebar path={path} />
|
||||||
|
{content}
|
||||||
|
</Route>)}
|
||||||
|
{pages.map(page => <Route exact path={page.path} key={page.path}>
|
||||||
|
<SetSidebar path={page.sidebarPath} />
|
||||||
|
{page.content}
|
||||||
|
</Route>)}
|
||||||
|
<Route>
|
||||||
|
<SetSidebar path={null} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
</Layout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
|
||||||
|
import DataTable from "./data-table";
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
|
||||||
|
|
||||||
|
export default function SlavesPage() {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
|
||||||
|
async function listSlaves() {
|
||||||
|
let result = await libLink.messages.listSlaves.send(control);
|
||||||
|
return result["list"].map((item) => ({
|
||||||
|
key: item["id"],
|
||||||
|
"Name": item["name"],
|
||||||
|
"Agent": item["agent"],
|
||||||
|
"Version": item["version"],
|
||||||
|
"Connected": item["connected"] && "Yes",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "Slaves" }]}>
|
||||||
|
<h2>Slaves</h2>
|
||||||
|
<DataTable DataFunction={listSlaves} />
|
||||||
|
</PageLayout>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useEffect, useContext, useState } from "react";
|
||||||
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
|
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
|
||||||
|
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
import { notifyErrorHandler } from "../util/notify";
|
||||||
|
|
||||||
|
|
||||||
|
function useUser(name) {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [user, setUser] = useState({ loading: true });
|
||||||
|
|
||||||
|
function updateUser() {
|
||||||
|
// XXX optimize by requesting only the user in question
|
||||||
|
libLink.messages.listUsers.send(control).then(result => {
|
||||||
|
let match = result.list.find(u => u.name === name);
|
||||||
|
if (!match) {
|
||||||
|
setUser({ missing: true });
|
||||||
|
} else {
|
||||||
|
setUser({ ...match, present: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateUser();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
return [user, updateUser];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserViewPage() {
|
||||||
|
let params = useParams();
|
||||||
|
let userName = params.name;
|
||||||
|
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let [user, updateUser] = useUser(userName);
|
||||||
|
let [roles, setRoles] = useState(null);
|
||||||
|
let [form] = Form.useForm();
|
||||||
|
let [rolesDirty, setRolesDirty] = useState(false);
|
||||||
|
let [applyingRoles, setApplyingRoles] = useState(false);
|
||||||
|
let [rolesError, setRolesError] = useState();
|
||||||
|
let [banUserPopover, setBanUserPopover] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
libLink.messages.listRoles.send(control).then(result => {
|
||||||
|
setRoles(new Map(result["list"].map(role => [role["id"], role])));
|
||||||
|
}).catch(() => {
|
||||||
|
setRoles(new Map());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
|
||||||
|
if (user.loading) {
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<h2>{userName}</h2>
|
||||||
|
<Spin size="large" />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.missing) {
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<h2>User not found</h2>
|
||||||
|
<p>User with name {userName} was not found on the master server.</p>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={nav}>
|
||||||
|
<Row>
|
||||||
|
<Col flex="auto">
|
||||||
|
<h2>{userName}</h2>
|
||||||
|
</Col>
|
||||||
|
<Col flex="0 0 auto">
|
||||||
|
<Popconfirm
|
||||||
|
title={<>
|
||||||
|
Delete user account and all data associated with it?
|
||||||
|
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
|
||||||
|
</>}
|
||||||
|
placement="bottomRight"
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => {
|
||||||
|
libLink.messages.deleteUser.send(
|
||||||
|
control, { name: userName }
|
||||||
|
).then(() => {
|
||||||
|
history.push("/users");
|
||||||
|
}).catch(notifyErrorHandler("Error deleting user"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button danger >
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item
|
||||||
|
label="Roles"
|
||||||
|
>
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name="roles"
|
||||||
|
initialValue={user["roles"]}
|
||||||
|
rules={[
|
||||||
|
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
onChange={() => setRolesDirty(true)}
|
||||||
|
loading={!roles}
|
||||||
|
mode="multiple"
|
||||||
|
showArrow={true}
|
||||||
|
filterOption={(inputValue, option) => {
|
||||||
|
let role = roles.get(option.value);
|
||||||
|
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roles && [...roles.values()].map(r => <Select.Option
|
||||||
|
key={r["id"]}
|
||||||
|
value={r["id"]}
|
||||||
|
>{r["name"]}</Select.Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="0 0 auto">
|
||||||
|
<Button
|
||||||
|
type={"primary"}
|
||||||
|
disabled={!rolesDirty}
|
||||||
|
loading={applyingRoles}
|
||||||
|
onClick={() => {
|
||||||
|
let newRoles = form.getFieldValue("roles");
|
||||||
|
setApplyingRoles(true);
|
||||||
|
libLink.messages.updateUserRoles.send(control, {
|
||||||
|
name: userName,
|
||||||
|
roles: newRoles,
|
||||||
|
}).then(() => {
|
||||||
|
setRolesDirty(false);
|
||||||
|
setRolesError();
|
||||||
|
}).catch(err => {
|
||||||
|
setRolesError(err.message);
|
||||||
|
return form.validateFields();
|
||||||
|
}).finally(() => {
|
||||||
|
setApplyingRoles(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>Apply</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="In-game Admin">
|
||||||
|
<Switch
|
||||||
|
checked={user["is_admin"]}
|
||||||
|
onClick={() => {
|
||||||
|
libLink.messages.setUserAdmin.send(control, {
|
||||||
|
name: userName,
|
||||||
|
admin: !user["is_admin"],
|
||||||
|
create: false,
|
||||||
|
}).then(() => {
|
||||||
|
updateUser();
|
||||||
|
}).catch(notifyErrorHandler("Error toggling user admin status"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Whitelisted">
|
||||||
|
<Switch
|
||||||
|
checked={user["is_whitelisted"]}
|
||||||
|
onClick={() => {
|
||||||
|
libLink.messages.setUserWhitelisted.send(control, {
|
||||||
|
name: userName,
|
||||||
|
whitelisted: !user["is_whitelisted"],
|
||||||
|
create: false,
|
||||||
|
}).then(() => {
|
||||||
|
updateUser();
|
||||||
|
}).catch(notifyErrorHandler("Error toggling user whitelisted status"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Banned">
|
||||||
|
<Space>
|
||||||
|
{user["is_banned"] ? "Yes" : "No"}
|
||||||
|
<Popover
|
||||||
|
title="Ban user"
|
||||||
|
visible={banUserPopover}
|
||||||
|
trigger="click"
|
||||||
|
onVisibleChange={() => {
|
||||||
|
if (!user["is_banned"]) {
|
||||||
|
setBanUserPopover(!banUserPopover);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
content=<Form.Item label="Reason">
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Form.Item noStyle name="reason">
|
||||||
|
<Input/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="0 0 auto">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
let reason = form.getFieldValue("reason");
|
||||||
|
libLink.messages.setUserBanned.send(control, {
|
||||||
|
name: userName,
|
||||||
|
create: false,
|
||||||
|
banned: true,
|
||||||
|
reason,
|
||||||
|
}).then(() => {
|
||||||
|
setBanUserPopover(false);
|
||||||
|
updateUser();
|
||||||
|
}).catch(notifyErrorHandler("Error banning user"));
|
||||||
|
}}
|
||||||
|
>Ban</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item>
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
if (user["is_banned"]) {
|
||||||
|
libLink.messages.setUserBanned.send(control, {
|
||||||
|
name: userName,
|
||||||
|
create: false,
|
||||||
|
banned: false,
|
||||||
|
reason: "",
|
||||||
|
}).then(() => {
|
||||||
|
updateUser();
|
||||||
|
}).catch(notifyErrorHandler("Error pardoning user"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useContext, useState } from "react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { Spin, Tag } from "antd";
|
||||||
|
|
||||||
|
import libLink from "@clusterio/lib/link";
|
||||||
|
|
||||||
|
import DataTable from "./data-table";
|
||||||
|
import ControlContext from "./ControlContext";
|
||||||
|
import PageLayout from "./PageLayout";
|
||||||
|
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
let control = useContext(ControlContext);
|
||||||
|
let history = useHistory();
|
||||||
|
|
||||||
|
let [roles, setRoles] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
libLink.messages.listRoles.send(control).then(result => {
|
||||||
|
setRoles(new Map(result["list"].map(role => [role["id"], role])));
|
||||||
|
}).catch(() => {
|
||||||
|
setRoles(new Map());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function listUsers() {
|
||||||
|
let result = await libLink.messages.listUsers.send(control);
|
||||||
|
return result["list"].map(item => ({
|
||||||
|
key: item["name"],
|
||||||
|
"Name": item["name"],
|
||||||
|
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
|
||||||
|
"Admin": item["is_admin"] && "Yes",
|
||||||
|
"Whitelisted": item["is_whitelisted"] && "Yes",
|
||||||
|
"Banned": item["is_banned"] && "Yes",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataTable does not make it possible to update the rendering function
|
||||||
|
if (roles === null) {
|
||||||
|
return <PageLayout nav={[{ name: "Users" }]}>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<Spin/>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "Users" }]}>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<DataTable
|
||||||
|
DataFunction={listUsers}
|
||||||
|
AddRecord={{
|
||||||
|
fields: [{
|
||||||
|
dataIndex: "name",
|
||||||
|
title: "Name",
|
||||||
|
}],
|
||||||
|
insert: async args => {
|
||||||
|
await libLink.messages.createUser.send(control, { name: args.name });
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
TableProps={{
|
||||||
|
onRow: (record, rowIndex) => ({
|
||||||
|
onClick: event => {
|
||||||
|
console.log(record, rowIndex);
|
||||||
|
history.push(`/users/${record.key}/view`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
File renamed without changes.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
+109
-18
@@ -1,31 +1,122 @@
|
|||||||
@import '~antd/dist/antd.dark.css';
|
@import '~antd/dist/antd.dark.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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 {
|
[data-theme="compact"] p, p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-links{
|
.row-links{
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-layout-content {
|
||||||
|
background: #141414;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-layout-sider {
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-left: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
position: absolute;
|
||||||
|
left: 112px;
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 36px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background-image: url("./images/logo.png");
|
||||||
|
background-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcon-result code {
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
border-color: #252525;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* see https://blog.eqrion.net/pin-to-bottom/ for how scroll anchors works */
|
||||||
|
.instance-console code * {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console code .scroll-anchor {
|
||||||
|
overflow-anchor: auto;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console code {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
border-color: #252525;
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-time {
|
||||||
|
color: #afaf1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-filename {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-info {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4646ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-warning {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #afaf1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-error {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ad3939;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-console .factorio-action {
|
||||||
|
color: #afaf1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-container {
|
||||||
|
background-color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-container .ant-form-item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-container .filter-node .ant-tree-node-content-wrapper {
|
||||||
|
background: #2a1912;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-input {
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { Layout, Menu } from "antd";
|
|
||||||
|
|
||||||
import { Route, Link, withRouter } from "react-router-dom";
|
|
||||||
|
|
||||||
import React, { Component } from "react";
|
|
||||||
import { Dashboard } from "../pages/dashboard";
|
|
||||||
import { InstancesTable } from "../pages/instances/instancesTable";
|
|
||||||
import { SlavesTable } from "../pages/slaves/slavesTable";
|
|
||||||
import { UsersTable } from "../pages/users/usersTable";
|
|
||||||
import { RolesTable } from "../pages/roles/rolesTable";
|
|
||||||
import { PermissionsTable } from "../pages/permissions/permissionsTable";
|
|
||||||
import { InstanceView } from "../pages/instances/instance";
|
|
||||||
import { UserView } from "../pages/users/user";
|
|
||||||
import { RoleView } from "../pages/roles/role";
|
|
||||||
|
|
||||||
const { Content, Footer, Sider } = Layout;
|
|
||||||
const { SubMenu } = Menu;
|
|
||||||
|
|
||||||
class SiteLayout extends Component {
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
const { location } = props;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
collapsed: false,
|
|
||||||
setcollapsed: false,
|
|
||||||
tags: [],
|
|
||||||
setTags: [],
|
|
||||||
location,
|
|
||||||
siteSettings: {},
|
|
||||||
factoryModel: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onCollapse() {
|
|
||||||
this.setState({ collapsed: !this.state.collapsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
titleCase(text) {
|
|
||||||
return text.substring(0, 1).toUpperCase() + text.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceParametersInUrl({ endpoint, params }) {
|
|
||||||
let path = endpoint;
|
|
||||||
console.log("endpoint", params);
|
|
||||||
Object.keys(params).forEach(key => {
|
|
||||||
path = path.replace(":" + key, params[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Layout style={{ minHeight: "100vh" }}>
|
|
||||||
{document.location.search !== "?hideExtras" ? (
|
|
||||||
<Sider
|
|
||||||
collapsible
|
|
||||||
collapsed={this.state.collapsed}
|
|
||||||
onCollapse={this.onCollapse.bind(this)}
|
|
||||||
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>
|
|
||||||
</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} />
|
|
||||||
</Content>
|
|
||||||
<Footer style={{
|
|
||||||
// background: "#F4F4F4",
|
|
||||||
textAlign: "center",
|
|
||||||
}}>
|
|
||||||
Clusterio 2.0 web interface https://github.com/clusterio
|
|
||||||
</Footer>
|
|
||||||
</Layout>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withRouter(SiteLayout);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React, {Component} from "react";
|
|
||||||
|
|
||||||
export class Dashboard extends Component {
|
|
||||||
render(){
|
|
||||||
return <h2>Dashboard</h2>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd";
|
|
||||||
import {
|
|
||||||
listSlaves,
|
|
||||||
listInstances,
|
|
||||||
startInstance,
|
|
||||||
stopInstance,
|
|
||||||
setInstanceOutputSubscriptions,
|
|
||||||
deleteInstance,
|
|
||||||
assignInstance,
|
|
||||||
createSave,
|
|
||||||
getInstanceConfig,
|
|
||||||
setInstanceConfigField,
|
|
||||||
sendRcon,
|
|
||||||
} from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
import notify from "../../util/notify";
|
|
||||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
|
||||||
import EditOutlined from "@ant-design/icons/EditOutlined";
|
|
||||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
export class InstanceView extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
instance: {},
|
|
||||||
slaves: [],
|
|
||||||
logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }],
|
|
||||||
};
|
|
||||||
this.messagesEndRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
let id = this.props.match.params.id;
|
|
||||||
|
|
||||||
await this.getData();
|
|
||||||
|
|
||||||
await setInstanceOutputSubscriptions({
|
|
||||||
instance_id: Number(id),
|
|
||||||
});
|
|
||||||
window.instanceOutputEventHandler = ({ instance_id, output }) => {
|
|
||||||
if (instance_id === Number(id)) {
|
|
||||||
this.setState({
|
|
||||||
logLines: [...this.state.logLines, output],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Automatically scroll to bottom on new line.
|
|
||||||
// this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getData() {
|
|
||||||
let id = this.props.match.params.id;
|
|
||||||
|
|
||||||
let instances = await listInstances();
|
|
||||||
let slaves = await listSlaves();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
instance: instances.find(x => x.id === Number(id)),
|
|
||||||
slaves,
|
|
||||||
});
|
|
||||||
|
|
||||||
let config = await getInstanceConfig({ instance_id: Number(id) });
|
|
||||||
console.log(config);
|
|
||||||
this.setState({
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
this.setState({
|
|
||||||
starting: true,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await startInstance({
|
|
||||||
instance_id: this.state.instance.id,
|
|
||||||
save: null,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notify("Error starting instance", "error", e.message);
|
|
||||||
this.setState({
|
|
||||||
starting: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notify("Instance started");
|
|
||||||
this.setState({
|
|
||||||
starting: false,
|
|
||||||
});
|
|
||||||
this.getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
this.setState({
|
|
||||||
stopping: true,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await stopInstance({
|
|
||||||
instance_id: this.state.instance.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notify("Error stopping instance", "error", e.message);
|
|
||||||
this.setState({
|
|
||||||
stopping: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notify("Instance stopped");
|
|
||||||
this.setState({
|
|
||||||
stopping: false,
|
|
||||||
});
|
|
||||||
this.getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSave() {
|
|
||||||
this.setState({
|
|
||||||
creatingSave: true,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await createSave({
|
|
||||||
instance_id: this.state.instance.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notify("Error creating save", "error", e.message);
|
|
||||||
this.setState({
|
|
||||||
creatingSave: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notify("Save created", "success");
|
|
||||||
this.setState({
|
|
||||||
creatingSave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async onConfigEditFinish(values) {
|
|
||||||
console.log("Completed config edit with", values);
|
|
||||||
for (let k in values) {
|
|
||||||
if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) {
|
|
||||||
let value = values[k];
|
|
||||||
if (typeof value === "boolean") value = value.toString();
|
|
||||||
console.log("Setting", k, value);
|
|
||||||
await setInstanceConfigField({
|
|
||||||
instance_id: this.state.instance.id,
|
|
||||||
field: k,
|
|
||||||
value: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notify("Set configuration fields", "success");
|
|
||||||
this.setState({
|
|
||||||
editingConfig: false,
|
|
||||||
});
|
|
||||||
this.getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
console.log(this.state);
|
|
||||||
let { instance, slaves } = this.state;
|
|
||||||
let slave = slaves && slaves.find(x => x.id === instance.assigned_slave);
|
|
||||||
return <Card>
|
|
||||||
<h2>{instance.name}</h2>
|
|
||||||
<p>ID: {instance.id}</p>
|
|
||||||
<p>Slave: {slave && slave.name}</p>
|
|
||||||
{instance.status !== "running" && <Button
|
|
||||||
loading={this.state.starting}
|
|
||||||
type="primary"
|
|
||||||
onClick={this.start.bind(this)}
|
|
||||||
>
|
|
||||||
Start server
|
|
||||||
</Button>}
|
|
||||||
{instance.status !== "stopped" && <Button
|
|
||||||
loading={this.state.stopping}
|
|
||||||
type="primary"
|
|
||||||
onClick={this.stop.bind(this)}
|
|
||||||
>
|
|
||||||
Stop server
|
|
||||||
</Button>}
|
|
||||||
{instance.status !== "running" && <Button
|
|
||||||
loading={this.state.creatingSave}
|
|
||||||
type="primary"
|
|
||||||
onClick={this.createSave.bind(this)}
|
|
||||||
>
|
|
||||||
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(newSlave => <Option
|
|
||||||
defaultValue={instance.assigned_slave} value={newSlave.id} key={newSlave.id}
|
|
||||||
>
|
|
||||||
{newSlave.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 of Object.keys(group.fields)) {
|
|
||||||
object[group.name + "." + fieldName] = group.fields[fieldName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderConfig(config) {
|
|
||||||
const layout = {
|
|
||||||
labelCol: {
|
|
||||||
span: 6,
|
|
||||||
},
|
|
||||||
wrapperCol: {
|
|
||||||
span: 14,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tailLayout = {
|
|
||||||
wrapperCol: {
|
|
||||||
offset: 6,
|
|
||||||
span: 14,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return <Card>
|
|
||||||
<Form
|
|
||||||
{...layout}
|
|
||||||
initialValues={this.getInstanceConfigAsKvObject(config)}
|
|
||||||
onFinish={this.onConfigEditFinish.bind(this)}
|
|
||||||
>
|
|
||||||
{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 {
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card } from "antd";
|
|
||||||
import { listSlaves, listInstances, createInstance } from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
const config = require("@clusterio/lib/config");
|
|
||||||
|
|
||||||
export class InstancesTable extends Component {
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <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) => ({
|
|
||||||
onClick: event => {
|
|
||||||
this.navigate(`/instances/${record.id}/view`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card } from "antd";
|
|
||||||
import { listSlaves, listInstances, listPermissions } from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
|
|
||||||
export class PermissionsTable extends Component {
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <Card>
|
|
||||||
<h2>Permissions</h2>
|
|
||||||
<DataTable
|
|
||||||
DataFunction={listPermissions}
|
|
||||||
TableProps={{
|
|
||||||
onRow: (record, rowIndex) => ({
|
|
||||||
onClick: event => {
|
|
||||||
this.navigate(`/permissions/${record.id}/view`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
|
|
||||||
import { Card, Button, Popover, Select, Transfer } from "antd";
|
|
||||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
|
||||||
|
|
||||||
import {
|
|
||||||
listSlaves,
|
|
||||||
listInstances,
|
|
||||||
startInstance,
|
|
||||||
stopInstance,
|
|
||||||
setInstanceOutputSubscriptions,
|
|
||||||
deleteInstance,
|
|
||||||
assignInstance,
|
|
||||||
createSave,
|
|
||||||
getInstanceConfig,
|
|
||||||
setInstanceConfigField,
|
|
||||||
sendRcon,
|
|
||||||
deleteUser,
|
|
||||||
listUsers,
|
|
||||||
listRoles,
|
|
||||||
setRoles,
|
|
||||||
listPermissions,
|
|
||||||
updateRole,
|
|
||||||
} from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
import notify from "../../util/notify";
|
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
export class RoleView extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
role: {},
|
|
||||||
};
|
|
||||||
this.messagesEndRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
await this.getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getData() {
|
|
||||||
let id = this.props.match.params.id;
|
|
||||||
|
|
||||||
let roles = await listRoles();
|
|
||||||
let permissions = await listPermissions();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
role: roles.find(x => x.id === Number(id)),
|
|
||||||
permissions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleChange(targetKeys) {
|
|
||||||
this.setState({
|
|
||||||
role: {
|
|
||||||
...this.state.role,
|
|
||||||
permissions: targetKeys,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let response = await updateRole({
|
|
||||||
...this.state.role,
|
|
||||||
permissions: targetKeys,
|
|
||||||
});
|
|
||||||
console.log("Updated user roles", response);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
console.log(this.state);
|
|
||||||
let { role, permissions } = this.state;
|
|
||||||
return <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.bind(this)}
|
|
||||||
render={item => item.name}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card } from "antd";
|
|
||||||
import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
|
|
||||||
export class RolesTable extends Component {
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <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) => ({
|
|
||||||
onClick: event => {
|
|
||||||
this.navigate(`/roles/${record.id}/view`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card } from "antd";
|
|
||||||
import { listSlaves, listInstances } from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
|
|
||||||
export class SlavesTable extends Component {
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <Card>
|
|
||||||
<h2>Slaves</h2>
|
|
||||||
<DataTable
|
|
||||||
DataFunction={listSlaves}
|
|
||||||
TableProps={{
|
|
||||||
onRow: (record, rowIndex) => ({
|
|
||||||
onClick: event => {
|
|
||||||
this.navigate(`/slaves/${record.id}/view`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card, Button, Popover, Select, Transfer } from "antd";
|
|
||||||
import {
|
|
||||||
deleteUser,
|
|
||||||
listUsers,
|
|
||||||
listRoles,
|
|
||||||
setRoles,
|
|
||||||
} from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
import notify from "../../util/notify";
|
|
||||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
export class UserView extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
user: {},
|
|
||||||
};
|
|
||||||
this.messagesEndRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
await this.getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getData() {
|
|
||||||
let name = this.props.match.params.id;
|
|
||||||
|
|
||||||
let users = await listUsers();
|
|
||||||
let roles = await listRoles();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
user: users.find(x => x.name === name),
|
|
||||||
roles,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleChange(targetKeys) {
|
|
||||||
this.setState({
|
|
||||||
user: {
|
|
||||||
...this.state.user,
|
|
||||||
roles: targetKeys,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let response = await setRoles({
|
|
||||||
name: this.state.user.name,
|
|
||||||
roles: targetKeys,
|
|
||||||
});
|
|
||||||
console.log("Updated user roles",response);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
console.log(this.state);
|
|
||||||
let { user, roles } = this.state;
|
|
||||||
return <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.bind(this)}
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
import { Card } from "antd";
|
|
||||||
import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink";
|
|
||||||
import DataTable from "../../components/data-table";
|
|
||||||
|
|
||||||
export class UsersTable extends Component {
|
|
||||||
navigate(url) {
|
|
||||||
this.props.history.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <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) => ({
|
|
||||||
onClick: event => {
|
|
||||||
this.navigate(`/users/${record.name}/view`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notification } from "antd";
|
import { notification } from "antd";
|
||||||
|
|
||||||
|
|
||||||
export default function notify(message, type = "info", description = undefined) {
|
export default function notify(message, type = "info", description = undefined) {
|
||||||
notification[type]({
|
notification[type]({
|
||||||
message: typeof message === "string" ? message : "ERROR: See console",
|
message: typeof message === "string" ? message : "ERROR: See console",
|
||||||
@@ -8,3 +9,25 @@ export default function notify(message, type = "info", description = undefined)
|
|||||||
});
|
});
|
||||||
if (typeof message !== "string") console.error(message);
|
if (typeof message !== "string") console.error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise rejection handler notifying the user
|
||||||
|
*
|
||||||
|
* Shows the error occuring in a notification card.
|
||||||
|
*
|
||||||
|
* @param {string} message -
|
||||||
|
* User facing message to give context to the error. Should say which
|
||||||
|
* operation failed.
|
||||||
|
* @returns {function(err)} function showing the error passed to it.
|
||||||
|
*/
|
||||||
|
export function notifyErrorHandler(message) {
|
||||||
|
return function handler(err) {
|
||||||
|
console.error(err);
|
||||||
|
notification.error({
|
||||||
|
message,
|
||||||
|
description: err.message,
|
||||||
|
duration: 0,
|
||||||
|
placement: "bottomRight",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,34 +3,6 @@ const libPlugin = require("@clusterio/lib/plugin");
|
|||||||
const libErrors = require("@clusterio/lib/errors");
|
const libErrors = require("@clusterio/lib/errors");
|
||||||
const version = require("../../package.json").version;
|
const version = require("../../package.json").version;
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a parsed Factorio output message
|
|
||||||
*
|
|
||||||
* Formats a parsed Factorio output from lib/factorio back into the
|
|
||||||
* text string it was parsed from.
|
|
||||||
*
|
|
||||||
* @param {Object} output - Factorio server output.
|
|
||||||
* @returns {string} origial output text.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function formatOutput(output) {
|
|
||||||
let time = "";
|
|
||||||
if (output.format === "seconds") {
|
|
||||||
time = `${output.time.padStart(8)} `;
|
|
||||||
} else if (output.format === "date") {
|
|
||||||
time = `${output.time} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
let info = "";
|
|
||||||
if (output.type === "log") {
|
|
||||||
info = `${output.level} ${output.file}: `;
|
|
||||||
|
|
||||||
} else if (output.type === "action") {
|
|
||||||
info = `[${output.action}] `;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${time}${info}${output.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connector for control connection to master server
|
* Connector for control connection to master server
|
||||||
@@ -75,16 +47,59 @@ export class Control extends libLink.Link {
|
|||||||
for (let controlPlugin of controlPlugins.values()) {
|
for (let controlPlugin of controlPlugins.values()) {
|
||||||
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.instanceOutputHandlers = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
async instanceOutputEventHandler(message) {
|
async instanceOutputEventHandler(message) {
|
||||||
let { instance_id, output } = message.data;
|
let { instance_id, output } = message.data;
|
||||||
console.log(formatOutput(output));
|
let handlers = this.instanceOutputHandlers.get(instance_id);
|
||||||
if (window.instanceOutputEventHandler) {
|
for (let handler of handlers || []) {
|
||||||
window.instanceOutputEventHandler({instance_id, output});
|
handler(output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onInstanceOutput(id, handler) {
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
throw new Error("Invalid instance id");
|
||||||
|
}
|
||||||
|
|
||||||
|
let handlers = this.instanceOutputHandlers.get(id);
|
||||||
|
if (!handlers) {
|
||||||
|
handlers = [];
|
||||||
|
this.instanceOutputHandlers.set(id, handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.push(handler);
|
||||||
|
|
||||||
|
if (handlers.length === 1) {
|
||||||
|
await this.updateInstanceOutputSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async offInstanceOutput(id, handler) {
|
||||||
|
let handlers = this.instanceOutputHandlers.get(id);
|
||||||
|
if (!handlers || !handlers.length) {
|
||||||
|
throw new Error(`No handlers for instance ${id} exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = handlers.lastIndexOf(handler);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Given handler is not registered for instance ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.splice(index, 1);
|
||||||
|
if (!handlers.length) {
|
||||||
|
await this.updateInstanceOutputSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInstanceOutputSubscriptions() {
|
||||||
|
await libLink.messages.setInstanceOutputSubscriptions.send(this, {
|
||||||
|
instance_ids: [...this.instanceOutputHandlers.keys()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async debugWsMessageEventHandler(message) {
|
async debugWsMessageEventHandler(message) {
|
||||||
console.log("WS", message.data.direction, message.data.content);
|
console.log("WS", message.data.direction, message.data.content);
|
||||||
}
|
}
|
||||||
@@ -100,6 +115,6 @@ export class Control extends libLink.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.connector.close(1001, "Control Quit");
|
await this.connector.close(1000, "Control Quit");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,10 @@ module.exports = (env = {}) => ({
|
|||||||
test: /node_modules.fs-extra/,
|
test: /node_modules.fs-extra/,
|
||||||
use: "null-loader",
|
use: "null-loader",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /node_modules.jsonwebtoken/,
|
||||||
|
use: "null-loader",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
use: [
|
use: [
|
||||||
@@ -59,6 +63,10 @@ module.exports = (env = {}) => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.png$/,
|
||||||
|
use: "file-loader",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"webpack-cli": "^3.3.12",
|
"webpack-cli": "^3.3.12",
|
||||||
"webpack-merge": "^5.2.0"
|
"webpack-merge": "^5.2.0"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"webpack-cli": "^3.3.12",
|
"webpack-cli": "^3.3.12",
|
||||||
"webpack-merge": "^5.2.0"
|
"webpack-merge": "^5.2.0"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"webpack-cli": "^3.3.12",
|
"webpack-cli": "^3.3.12",
|
||||||
"webpack-merge": "^5.2.0"
|
"webpack-merge": "^5.2.0"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
"@clusterio/web_ui": "^2.0.0-alpha.1",
|
||||||
"antd": "^4.6.5",
|
"antd": "^4.6.5",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
|||||||
Reference in new issue
Block a user