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
+
+
+
+
+
+
+
+
+ >;
+}
+
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
-
-
-
-
-
-
-
-
- >;
-}
-
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];
+}