diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.jsx new file mode 100644 index 00000000..8a0b2399 --- /dev/null +++ b/packages/web_ui/src/components/AssignInstanceModal.jsx @@ -0,0 +1,91 @@ +import React, { useContext, useState } from "react"; +import { Button, Form, Modal, Select, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Paragraph } = Typography; + + +export default function AssignInstanceModal(props) { + let [visible, setVisible] = useState(false); + let [slaves, setSlaves] = useState([]); + let [loading, setLoading] = useState(true); + let [applying, setApplying] = useState(false); + let [form] = Form.useForm(); + let control = useContext(ControlContext); + + function open() { + setVisible(true); + libLink.messages.listSlaves.send(control).then((result) => { + setSlaves(result.list); + setLoading(false); + }); + } + + function handleAssign() { + let slaveId = form.getFieldValue("slave"); + if (slaveId === undefined) { + setVisible(false); + return; + } + + setApplying(true); + libLink.messages.assignInstanceCommand.send(control, { + instance_id: props.id, + slave_id: slaveId, + }).then(() => { + setVisible(false); + if (props.onFinish) { + props.onFinish(); + } + }).catch( + notifyErrorHandler("Error assigning instance") + ).finally( + () => setApplying(false) + ); + } + + function handleCancel() { + setVisible(false); + } + + return <> + + + + Select a Slave to assign this instance to. Assignment + creates the necessary files on the slave to start up + the instance. Note that reassigning an instance from + one slave to another will not move the server save over. + +
+ + + +
+
+ ; +} + diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx new file mode 100644 index 00000000..e5a29865 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -0,0 +1,277 @@ +import React, { useEffect, useContext, useState } from "react"; +import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import ControlContext from "./ControlContext"; + +const { Title } = Typography; + + +export default 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("control"); + await newConfig.load(result.serialized_config); + setConfig(newConfig); + } + + useEffect(() => { updateConfig(); }, [props.id]); + + function renderInput(def) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; + } + + if (config === null) { + return ; + } + + let initialValues = {}; + 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); + } + } + + 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()) { + if (!group.canAccess(def.name)) { + continue; + } + + let value = group.get(def.name); + let fieldName = `${group.name}.${def.name}`; + let childNode = { + key: fieldName, + children: [], + }; + + if (def.type === "object") { + childNode.title = ; + for (let prop of Object.keys(value)) { + let propPath = `${group.name}.${def.name}.${prop}`; + childNode.children.push({ + key: propPath, + title: { + if (!fieldValue.length) { + return Promise.reject(new Error("Will be removed")); + } + try { + JSON.parse(fieldValue); + } catch (err) { + return Promise.reject(new Error("Must be valid json")); + } + return Promise.resolve(); + }}]} + > + + , + }); + + initialValues[propPath] = JSON.stringify(value[prop]); + propsMap.set(propPath, [fieldName, prop]); + } + + let newPropPath = `${group.name}.${def.name}:add`; + childNode.children.push({ + key: newPropPath, + title: + + + : + + + + + , + }); + + } else { + childNode.title = + {renderInput(def)} + ; + initialValues[fieldName] = value; + } + + treeNode.children.push(childNode); + } + + treeData.push(treeNode); + } + + return <> + + Config + <Space style={{ float: "right" }}> + <Button + type="primary" + size="small" + loading={applying} + onClick={async () => { + setApplying(true); + for (let field of changedFields) { + let value = form.getFieldValue(field); + let request; + if (propsMap.has(field)) { + let [fieldName, prop] = propsMap.get(field); + let data = { + instance_id: props.id, + field: fieldName, + prop, + }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + continue; + } + } + + request = libLink.messages.setInstanceConfigProp.send(control, data); + + } else { + request = libLink.messages.setInstanceConfigField.send(control, { + instance_id: props.id, + field, + value: String(value), + }); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err) { + setErrorFields(errors => { + let newErrors = new Map(errors); + newErrors.set(field, err.message); + return newErrors; + }); + } + } + + await updateConfig(); + setApplying(false); + + if (props.onApply) { + props.onApply(); + } + }} + disabled={changedFields.size === 0} + > + Apply + </Button> + <Button + size="small" + onClick={() => { + form.resetFields(); + setChangedFields(new Set()); + setErrorFields(new Map()); + }} + > + Revert + </Button> + </Space> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} + diff --git a/packages/web_ui/src/components/InstanceConsole.jsx b/packages/web_ui/src/components/InstanceConsole.jsx new file mode 100644 index 00000000..07542676 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConsole.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useContext, useRef, useState } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; + +const { Paragraph } = Typography; + + +function formatParsedOutput(parsed, key) { + let time = ""; + if (parsed.format === "seconds") { + time = {parsed.time.padStart(8)} ; + } else if (parsed.format === "date") { + time = {parsed.time} ; + } + + let info = ""; + if (parsed.type === "log") { + let level = parsed.level; + if (level === "Script") { + level = {level}; + } else if (level === "Verbose") { + level = {level}; + } else if (level === "Info") { + level = {level}; + } else if (parsed.level === "Warning") { + level = {level}; + } else if (parsed.level === "Error") { + level = {level}; + } + + info = <>{level} {parsed.file}: ; + + } else if (parsed.type === "action") { + info = <>[{parsed.action}] ; + } + + return {time}{info}{parsed.message}
; +} + +function formatLog(info, key) { + if (info.level === "server" && info.parsed) { + return formatParsedOutput(info.parsed, key); + } + let level = {info.level}; + return [{level}] {info.message}
; +} + +export default function InstanceConsole(props) { + let control = useContext(ControlContext); + let anchor = useRef(); + let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); + let [lines, setLines] = useState([]); + + useEffect(() => { + // Scroll view to the anchor so it sticks to the bottom + let parent = anchor.current.parentElement; + parent.scrollTop = parent.scrollHeight - parent.clientHeight; + + libLink.messages.queryLog.send(control, { + all: false, + master: false, + slave_ids: [], + instance_ids: [props.id], + max_level: null, + }).then(result => { + setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); + }).catch(err => { + setPastLines([{`Error loading log: ${err.message}`}
]); + }); + + function logHandler(info) { + setLines(currentLines => currentLines.concat( + [formatLog(info, currentLines.length)] + )); + } + + control.onInstanceLog(props.id, logHandler); + return () => { + control.offInstanceLog(props.id, logHandler); + }; + }, [props.id]); + + return <> + + {pastLines} + {lines} +
+ + ; +} + diff --git a/packages/web_ui/src/components/InstanceRcon.jsx b/packages/web_ui/src/components/InstanceRcon.jsx new file mode 100644 index 00000000..ce1e07ee --- /dev/null +++ b/packages/web_ui/src/components/InstanceRcon.jsx @@ -0,0 +1,49 @@ +import React, { useContext, useState } from "react"; +import { Input, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Title, Paragraph } = Typography; + + +export default function InstanceRcon(props) { + let control = useContext(ControlContext); + let [output, setOutput] = useState(null); + let [running, setRunning] = useState(false); + + async function sendCommand(command) { + if (!command) { + setOutput(null); + return; + } + + setRunning(true); + try { + let result = await libLink.messages.sendRcon.send(control, { + instance_id: props.id, + command: command, + }); + setOutput(result.result); + } finally { + setRunning(false); + } + } + + return <> + {output && <> + Rcon result + {output} + } + sendCommand(command).catch(notifyErrorHandler("Error sending command"))} + loading={running} + /> + ; +} + diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 6b15a635..f44b5c34 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -1,591 +1,24 @@ -import React, { useEffect, useContext, useRef, useState } from "react"; +import React, { 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 { Button, Descriptions, Popconfirm, Space, Spin, 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 InstanceConfigTree from "./InstanceConfigTree"; +import InstanceConsole from "./InstanceConsole"; +import InstanceRcon from "./InstanceRcon"; +import AssignInstanceModal from "./AssignInstanceModal"; +import StartStopInstanceButton from "./StartStopInstanceButton"; import { notifyErrorHandler } from "../util/notify"; +import { useInstance } from "../model/instance"; +import { useSlave } from "../model/slave"; 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 formatParsedOutput(parsed, key) { - let time = ""; - if (parsed.format === "seconds") { - time = {parsed.time.padStart(8)} ; - } else if (parsed.format === "date") { - time = {parsed.time} ; - } - - let info = ""; - if (parsed.type === "log") { - let level = parsed.level; - if (level === "Script") { - level = {level}; - } else if (level === "Verbose") { - level = {level}; - } else if (level === "Info") { - level = {level}; - } else if (parsed.level === "Warning") { - level = {level}; - } else if (parsed.level === "Error") { - level = {level}; - } - - info = <>{level} {parsed.file}: ; - - } else if (parsed.type === "action") { - info = <>[{parsed.action}] ; - } - - return {time}{info}{parsed.message}
; -} - -function formatLog(info, key) { - if (info.level === "server" && info.parsed) { - return formatParsedOutput(info.parsed, key); - } - let level = {info.level}; - return [{level}] {info.message}
; -} - -function InstanceConsole(props) { - let control = useContext(ControlContext); - let anchor = useRef(); - let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); - let [lines, setLines] = useState([]); - - useEffect(() => { - // Scroll view to the anchor so it sticks to the bottom - let parent = anchor.current.parentElement; - parent.scrollTop = parent.scrollHeight - parent.clientHeight; - - libLink.messages.queryLog.send(control, { - all: false, - master: false, - slave_ids: [], - instance_ids: [props.id], - max_level: null, - }).then(result => { - setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); - }).catch(err => { - setPastLines([{`Error loading log: ${err.message}`}
]); - }); - - function logHandler(info) { - setLines(currentLines => currentLines.concat( - [formatLog(info, currentLines.length)] - )); - } - - control.onInstanceLog(props.id, logHandler); - return () => { - control.offInstanceLog(props.id, logHandler); - }; - }, [props.id]); - - return <> - - {pastLines} - {lines} -
- - ; -} - -function InstanceRcon(props) { - let control = useContext(ControlContext); - let [output, setOutput] = useState(null); - let [running, setRunning] = useState(false); - - async function sendCommand(command) { - if (!command) { - setOutput(null); - return; - } - - setRunning(true); - try { - let result = await libLink.messages.sendRcon.send(control, { - instance_id: props.id, - command: command, - }); - setOutput(result.result); - } finally { - setRunning(false); - } - } - - return <> - {output && <> - Rcon result - {output} - } - sendCommand(command).catch(notifyErrorHandler("Error sending command"))} - loading={running} - /> - ; -} - -function AssignInstanceModal(props) { - let [visible, setVisible] = useState(false); - let [slaves, setSlaves] = useState([]); - let [loading, setLoading] = useState(true); - let [applying, setApplying] = useState(false); - let [form] = Form.useForm(); - let control = useContext(ControlContext); - - function open() { - setVisible(true); - libLink.messages.listSlaves.send(control).then((result) => { - setSlaves(result.list); - setLoading(false); - }); - } - - function handleAssign() { - let slaveId = form.getFieldValue("slave"); - if (slaveId === undefined) { - setVisible(false); - return; - } - - setApplying(true); - libLink.messages.assignInstanceCommand.send(control, { - instance_id: props.id, - slave_id: slaveId, - }).then(() => { - setVisible(false); - if (props.onFinish) { - props.onFinish(); - } - }).catch( - notifyErrorHandler("Error assigning instance") - ).finally( - () => setApplying(false) - ); - } - - function handleCancel() { - setVisible(false); - } - - return <> - - - - Select a Slave to assign this instance to. Assignment - creates the necessary files on the slave to start up - the instance. Note that reassigning an instance from - one slave to another will not move the server save over. - -
- - - -
-
- ; -} - -function StartStopInstanceButton(props) { - let control = useContext(ControlContext); - let [switching, setSwitching] = useState(false); - - function onClick() { - setSwitching(true); - let action; - if (props.instance["status"] === "stopped") { - action = libLink.messages.startInstance.send( - control, { instance_id: props.instance["id"], save: null } - ).catch( - notifyErrorHandler("Error starting instance") - ); - - } else if (["starting", "running"].includes(props.instance["status"])) { - action = libLink.messages.stopInstance.send( - control, { instance_id: props.instance["id"] } - ).catch( - notifyErrorHandler("Error stopping instance") - ); - - } else { - setSwitching(false); - return; - } - - action.finally(() => { - setSwitching(false); - if (props.onFinish) { - props.onFinish(); - } - }); - } - - return ; -} - - -function InstanceConfigTree(props) { - let control = useContext(ControlContext); - let [config, setConfig] = useState(null); - let [form] = Form.useForm(); - let [changedFields, setChangedFields] = useState(new Set()); - let [errorFields, setErrorFields] = useState(new Map()); - let [applying, setApplying] = useState(false); - - async function updateConfig() { - let result = await libLink.messages.getInstanceConfig.send(control, { - instance_id: props.id, - }); - - let newConfig = new libConfig.InstanceConfig("control"); - await newConfig.load(result.serialized_config); - setConfig(newConfig); - } - - useEffect(() => { updateConfig(); }, [props.id]); - - function renderInput(def) { - if (def.type === "boolean") { - return ; - } - if (def.type === "string") { - return ; - } - if (def.type === "number") { - return ; - } - - return `Unknown type ${def.type}`; - } - - if (config === null) { - return ; - } - - let initialValues = {}; - 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); - } - } - - 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()) { - if (!group.canAccess(def.name)) { - continue; - } - - let value = group.get(def.name); - let fieldName = `${group.name}.${def.name}`; - let childNode = { - key: fieldName, - children: [], - }; - - if (def.type === "object") { - childNode.title = ; - for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; - childNode.children.push({ - key: propPath, - title: { - if (!fieldValue.length) { - return Promise.reject(new Error("Will be removed")); - } - try { - JSON.parse(fieldValue); - } catch (err) { - return Promise.reject(new Error("Must be valid json")); - } - return Promise.resolve(); - }}]} - > - - , - }); - - initialValues[propPath] = JSON.stringify(value[prop]); - propsMap.set(propPath, [fieldName, prop]); - } - - let newPropPath = `${group.name}.${def.name}:add`; - childNode.children.push({ - key: newPropPath, - title: - - - : - - - - - , - }); - - } else { - childNode.title = - {renderInput(def)} - ; - initialValues[fieldName] = value; - } - - treeNode.children.push(childNode); - } - - treeData.push(treeNode); - } - - return <> - - Config - <Space style={{ float: "right" }}> - <Button - type="primary" - size="small" - loading={applying} - onClick={async () => { - setApplying(true); - for (let field of changedFields) { - let value = form.getFieldValue(field); - let request; - if (propsMap.has(field)) { - let [fieldName, prop] = propsMap.get(field); - let data = { - instance_id: props.id, - field: fieldName, - prop, - }; - if (value) { - try { - data.value = JSON.parse(value); - } catch (err) { - continue; - } - } - - request = libLink.messages.setInstanceConfigProp.send(control, data); - - } else { - request = libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value: String(value), - }); - } - - try { - await request; - setChangedFields(changed => { - let newChanged = new Set(changed); - newChanged.delete(field); - return newChanged; - }); - - } catch (err) { - setErrorFields(errors => { - let newErrors = new Map(errors); - newErrors.set(field, err.message); - return newErrors; - }); - } - } - - await updateConfig(); - setApplying(false); - - if (props.onApply) { - props.onApply(); - } - }} - disabled={changedFields.size === 0} - > - Apply - </Button> - <Button - size="small" - onClick={() => { - form.resetFields(); - setChangedFields(new Set()); - setErrorFields(new Map()); - }} - > - Revert - </Button> - </Space> - - -
- changedFields.has(node.key)} - /> - -
- ; -} - export default function InstanceViewPage(props) { let params = useParams(); let instanceId = Number(params.id); diff --git a/packages/web_ui/src/components/StartStopInstanceButton.jsx b/packages/web_ui/src/components/StartStopInstanceButton.jsx new file mode 100644 index 00000000..1e7f7162 --- /dev/null +++ b/packages/web_ui/src/components/StartStopInstanceButton.jsx @@ -0,0 +1,55 @@ +import React, { useContext, useState } from "react"; +import { Button } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + + +export default 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 ; +} + + diff --git a/packages/web_ui/src/model/instance.jsx b/packages/web_ui/src/model/instance.jsx new file mode 100644 index 00000000..c30227ab --- /dev/null +++ b/packages/web_ui/src/model/instance.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export 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]; +} diff --git a/packages/web_ui/src/model/slave.jsx b/packages/web_ui/src/model/slave.jsx new file mode 100644 index 00000000..ca6ee136 --- /dev/null +++ b/packages/web_ui/src/model/slave.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export 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]; +}