diff --git a/packages/slave/slave.js b/packages/slave/slave.js index ecc492ed..a4db4b7e 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -886,6 +886,11 @@ async function discoverInstances(instancesDir) { continue; } + if (instanceInfos.has(instanceConfig.get("instance.id"))) { + logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`); + continue; + } + let instancePath = path.join(instancesDir, entry.name); logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`); instanceInfos.set(instanceConfig.get("instance.id"), { diff --git a/packages/web_ui/src/components/InstanceList.jsx b/packages/web_ui/src/components/InstanceList.jsx new file mode 100644 index 00000000..4a010ffa --- /dev/null +++ b/packages/web_ui/src/components/InstanceList.jsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useHistory } from "react-router-dom"; +import { Table } from "antd"; + +import { useSlaveList } from "../model/slave"; + +const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; + + +export default function InstanceList(props) { + let history = useHistory(); + let [slaveList] = useSlaveList(); + + function slaveName(slaveId) { + let slave = slaveList.find(s => s.id === slaveId); + if (slave) { + return slave.name; + } + return String(slaveId); + } + + let columns = [ + { + title: "Name", + dataIndex: "name", + defaultSortOrder: "ascend", + sorter: (a, b) => strcmp(a["name"], b["name"]), + }, + { + title: "Assigned Slave", + key: "assigned_slave", + render: instance => slaveName(instance["assigned_slave"]), + sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])), + }, + { + title: "Status", + dataIndex: "status", + sorter: (a, b) => strcmp(a["status"], b["status"]), + }, + ]; + + if (props.hideAssignedSlave) { + columns.splice(1, 1); + } + + return instance["id"]} + pagination={false} + onRow={(record, rowIndex) => ({ + onClick: event => { + history.push(`/instances/${record.id}/view`); + }, + })} + />; +} diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index e9bf72c9..c5a27cb5 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -9,7 +9,7 @@ import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import InstanceConfigTree from "./InstanceConfigTree"; -import InstanceConsole from "./InstanceConsole"; +import LogConsole from "./LogConsole"; import InstanceRcon from "./InstanceRcon"; import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; @@ -127,7 +127,7 @@ export default function InstanceViewPage(props) { account.hasAnyPermission("core.log.follow", "core.instance.send_rcon") && Console } - {account.hasPermission("core.log.follow") && } + {account.hasPermission("core.log.follow") && } { account.hasPermission("core.instance.send_rcon") && diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx index 85996fe2..fb6dbd71 100644 --- a/packages/web_ui/src/components/InstancesPage.jsx +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -1,6 +1,6 @@ import React, { useContext, useState } from "react"; import { useHistory } from "react-router-dom"; -import { Button, Form, Input, Modal, PageHeader, Table } from "antd"; +import { Button, Form, Input, Modal, PageHeader } from "antd"; import { libConfig, libLink } from "@clusterio/lib"; @@ -8,10 +8,8 @@ import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import { useInstanceList } from "../model/instance"; +import InstanceList from "./InstanceList"; import { notifyErrorHandler } from "../util/notify"; -import { useSlaveList } from "../model/slave"; - -const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; function CreateInstanceButton(props) { @@ -61,19 +59,9 @@ function CreateInstanceButton(props) { } export default function InstancesPage() { - let history = useHistory(); let account = useAccount(); - let [slaveList] = useSlaveList(); let [instanceList] = useInstanceList(); - function slaveName(slaveId) { - let slave = slaveList.find(s => s.id === slaveId); - if (slave) { - return slave.name; - } - return String(slaveId); - } - return } /> -
strcmp(a["name"], b["name"]), - }, - { - title: "Assigned Slave", - key: "assigned_slave", - render: instance => slaveName(instance["assigned_slave"]), - sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])), - }, - { - title: "Status", - dataIndex: "status", - sorter: (a, b) => strcmp(a["status"], b["status"]), - }, - ]} - dataSource={instanceList} - rowKey={instance => instance["id"]} - pagination={false} - onRow={(record, rowIndex) => ({ - onClick: event => { - history.push(`/instances/${record.id}/view`); - }, - })} - /> + ; } diff --git a/packages/web_ui/src/components/InstanceConsole.jsx b/packages/web_ui/src/components/LogConsole.jsx similarity index 88% rename from packages/web_ui/src/components/InstanceConsole.jsx rename to packages/web_ui/src/components/LogConsole.jsx index aa2d50ca..aa76c7a7 100644 --- a/packages/web_ui/src/components/InstanceConsole.jsx +++ b/packages/web_ui/src/components/LogConsole.jsx @@ -49,7 +49,7 @@ function formatLog(info, key) { return [{level}] {info.message}
; } -export default function InstanceConsole(props) { +export default function LogConsole(props) { let account = useAccount(); let control = useContext(ControlContext); let anchor = useRef(); @@ -57,16 +57,20 @@ export default function InstanceConsole(props) { let [lines, setLines] = useState([]); useEffect(() => { + let logFilter = { + all: props.all || false, + master: props.master || false, + slave_ids: props.slaves || [], + instance_ids: props.instances || [], + }; + // Scroll view to the anchor so it sticks to the bottom let parent = anchor.current.parentElement; parent.scrollTop = parent.scrollHeight - parent.clientHeight; if (account.hasPermission("core.log.query")) { libLink.messages.queryLog.send(control, { - all: false, - master: false, - slave_ids: [], - instance_ids: [props.id], + ...logFilter, max_level: null, limit: 400, order: "desc", @@ -85,11 +89,11 @@ export default function InstanceConsole(props) { )); } - control.onInstanceLog(props.id, logHandler); + control.onLog(logFilter, logHandler); return () => { - control.offInstanceLog(props.id, logHandler); + control.offLog(logFilter, logHandler); }; - }, [props.id]); + }, [props.all, props.master, props.slaves, props.instances]); return <> diff --git a/packages/web_ui/src/components/MasterPage.jsx b/packages/web_ui/src/components/MasterPage.jsx index 53c83877..088d347e 100644 --- a/packages/web_ui/src/components/MasterPage.jsx +++ b/packages/web_ui/src/components/MasterPage.jsx @@ -1,15 +1,23 @@ import React, { useContext } from "react"; +import { Typography } from "antd"; +import LogConsole from "./LogConsole"; import { useAccount } from "../model/account"; import MasterConfigTree from "./MasterConfigTree"; import PageLayout from "./PageLayout"; +const { Title } = Typography; + export default function SlavesPage() { let account = useAccount(); return

Master

+ {account.hasPermission("core.log.follow") && <> + Console + + } {account.hasPermission("core.master.get_config") && }
; }; diff --git a/packages/web_ui/src/components/SlaveViewPage.jsx b/packages/web_ui/src/components/SlaveViewPage.jsx new file mode 100644 index 00000000..c5a985bd --- /dev/null +++ b/packages/web_ui/src/components/SlaveViewPage.jsx @@ -0,0 +1,60 @@ +import React, { useContext, useState } from "react"; +import { Descriptions, PageHeader, Spin, Typography } from "antd"; +import { useParams } from "react-router-dom"; + +import InstanceList from "./InstanceList"; +import LogConsole from "./LogConsole"; +import { useAccount } from "../model/account"; +import { useInstanceList } from "../model/instance"; +import { useSlave } from "../model/slave"; +import PageLayout from "./PageLayout"; + +const { Title } = Typography; + + +export default function SlaveViewPage(props) { + let params = useParams(); + let slaveId = Number(params.id); + let account = useAccount(); + let [instanceList] = useInstanceList(); + let [slave] = useSlave(slaveId); + + instanceList = instanceList.filter(instance => instance["assigned_slave"] === slaveId); + + let nav = [{ name: "Slaves", path: "/slaves" }, { name: slave.name || slaveId }]; + if (slave.loading) { + return ; + } + + if (slave.missing) { + return + +

Slave with id {slaveId} was not found on the master server.

+
; + } + + return + + + + {slave["name"]} + {slave["connected"] ? "yes" : "no"} + {slave["agent"]} + {slave["version"]} + + {account.hasPermission("core.instance.list") && <> + Instances + + } + {account.hasPermission("core.log.follow") && <> + Console + + } + ; +} diff --git a/packages/web_ui/src/components/SlavesPage.jsx b/packages/web_ui/src/components/SlavesPage.jsx index f94001a8..9333752a 100644 --- a/packages/web_ui/src/components/SlavesPage.jsx +++ b/packages/web_ui/src/components/SlavesPage.jsx @@ -1,4 +1,5 @@ import React, { useContext, useRef, useState } from "react"; +import { useHistory } from "react-router-dom"; import { Button, Form, Input, Modal, PageHeader, Table } from "antd"; import { libLink } from "@clusterio/lib"; @@ -75,6 +76,7 @@ function GenerateSlaveTokenButton(props) { export default function SlavesPage() { let account = useAccount(); + let history = useHistory(); let [slaveList] = useSlaveList(); return @@ -112,6 +114,11 @@ export default function SlavesPage() { dataSource={slaveList} rowKey={slave => slave["id"]} pagination={false} + onRow={(record, rowIndex) => ({ + onClick: event => { + history.push(`/slaves/${record.id}/view`); + }, + })} /> ; }; diff --git a/packages/web_ui/src/index.jsx b/packages/web_ui/src/index.jsx index a692e723..ae2b6bbc 100644 --- a/packages/web_ui/src/index.jsx +++ b/packages/web_ui/src/index.jsx @@ -13,8 +13,10 @@ export { default as BaseConfigTree } from "./components/BaseConfigTree"; export { default as InstanceConfigTree } from "./components/InstanceConfigTree"; export { default as MasterConfigTree } from "./components/MasterConfigTree"; +export { default as LogConsole } from "./components/LogConsole"; + export { default as AssignInstanceModal } from "./components/AssignInstanceModal"; -export { default as InstanceConsole } from "./components/InstanceConsole"; +export { default as InstanceList } from "./components/InstanceList"; export { default as InstanceRcon } from "./components/InstanceRcon"; export { default as PageLayout } from "./components/PageLayout"; diff --git a/packages/web_ui/src/pages.jsx b/packages/web_ui/src/pages.jsx index b643b0e5..cec0de30 100644 --- a/packages/web_ui/src/pages.jsx +++ b/packages/web_ui/src/pages.jsx @@ -2,6 +2,7 @@ import React from "react"; import MasterPage from "./components/MasterPage"; import SlavesPage from "./components/SlavesPage"; +import SlaveViewPage from "./components/SlaveViewPage"; import InstancesPage from "./components/InstancesPage"; import InstanceViewPage from "./components/InstanceViewPage"; import UsersPage from "./components/UsersPage"; @@ -24,6 +25,11 @@ export const pages = [ permission: "core.slave.list", content: , }, + { + path: "/slaves/:id/view", + sidebarPath: "/slaves", + content: , + }, { path: "/instances", sidebarName: "Instances", diff --git a/packages/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index 18c54f1c..5473ad49 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -27,6 +27,22 @@ export class ControlConnector extends libLink.WebSocketClientConnector { } } +function logFilter({ all, master, slave_ids, instance_ids }, info) { + if (all) { + return true; + } + if (master && info.slave_id === undefined) { + return true; + } + if (info.slave_id && slave_ids.includes(info.slave_id)) { + return true; + } + if (info.instance_id && instance_ids.includes(info.instance_id)) { + return true; + } + return false; +} + /** * Handles running the control * @@ -70,7 +86,7 @@ export class Control extends libLink.Link { this.slaveUpdateHandlers = new Map(); this.instanceUpdateHandlers = new Map(); this.saveListUpdateHandlers = new Map(); - this.instanceLogHandlers = new Map(); + this.logHandlers = new Map(); this.connector.on("connect", data => { this.accountName = data.account.name; @@ -302,23 +318,20 @@ export class Control extends libLink.Link { async logMessageEventHandler(message) { let info = message.data.info; - if (info.instance_id !== undefined) { - let instanceHandlers = this.instanceLogHandlers.get(info.instance_id); - for (let handler of instanceHandlers || []) { - handler(info); + for (let [filter, handlers] of this.logHandlers) { + if (logFilter(filter, info)) { + for (let handler of handlers) { + handler(info); + } } } } - async onInstanceLog(id, handler) { - if (!Number.isInteger(id)) { - throw new Error("Invalid instance id"); - } - - let handlers = this.instanceLogHandlers.get(id); + async onLog(filter, handler) { + let handlers = this.logHandlers.get(filter); if (!handlers) { handlers = []; - this.instanceLogHandlers.set(id, handlers); + this.logHandlers.set(filter, handlers); } handlers.push(handler); @@ -328,15 +341,15 @@ export class Control extends libLink.Link { } } - async offInstanceLog(id, handler) { - let handlers = this.instanceLogHandlers.get(id); + async offLog(filter, handler) { + let handlers = this.logHandlers.get(filter); if (!handlers || !handlers.length) { - throw new Error(`No handlers for instance ${id} exist`); + throw new Error("No handlers for the given filter exists"); } let index = handlers.lastIndexOf(handler); if (index === -1) { - throw new Error(`Given handler is not registered for instance ${id}`); + throw new Error("Given handler is not registered for the filter"); } handlers.splice(index, 1); @@ -350,13 +363,29 @@ export class Control extends libLink.Link { return; } - await libLink.messages.setLogSubscriptions.send(this, { + let combinedFilter = { all: false, master: false, slave_ids: [], - instance_ids: [...this.instanceLogHandlers.keys()], - max_level: null, - }); + instance_ids: [], + }; + + for (let filter of this.logHandlers.keys()) { + if (filter.all) { combinedFilter.all = true; } + if (filter.master) { combinedFilter.master = true; } + for (let slaveId of filter.slave_ids || []) { + if (!combinedFilter.slave_ids.includes(slaveId)) { + combinedFilter.slave_ids.push(slaveId); + } + } + for (let instanceId of filter.instance_ids || []) { + if (!combinedFilter.instance_ids.includes(instanceId)) { + combinedFilter.instance_ids.push(instanceId); + } + } + } + + await libLink.messages.setLogSubscriptions.send(this, { ...combinedFilter, max_level: null }); } async debugWsMessageEventHandler(message) {