From 9f4f19f11b3a6cc038d7760130f1ff5b2acf7733 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 10 Apr 2026 10:02:00 -0600 Subject: [PATCH 1/7] fix: update button variants in DebugConsole and clean up tsconfig.tsbuildinfo formatting --- src/features/DebugConsole/DebugConsole.tsx | 4 +- tsconfig.tsbuildinfo | 52 +--------------------- 2 files changed, 3 insertions(+), 53 deletions(-) diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index c1fd09c..4d0ead3 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -54,12 +54,12 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => {
{!isConnected ? ( - ) : ( - )} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index db16e85..71535fa 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1,51 +1 @@ -{ - "root": [ - "./src/app.test.tsx", - "./src/app.tsx", - "./src/index.tsx", - "./src/react-app-env.d.ts", - "./src/reportwebvitals.ts", - "./src/setuptests.ts", - "./src/features/configfile.tsx", - "./src/features/devicedetail.tsx", - "./src/features/devicelist.tsx", - "./src/features/errorbox.tsx", - "./src/features/mainlayout.tsx", - "./src/features/mobilecontrol.tsx", - "./src/features/topnav.tsx", - "./src/features/types.tsx", - "./src/features/versions.tsx", - "./src/features/debugconsole/consolewindow.tsx", - "./src/features/debugconsole/debugconsole.tsx", - "./src/features/debugconsole/debugfilters.tsx", - "./src/features/debugconsole/logmessagedetaildrawer.tsx", - "./src/features/debugconsole/minimumlogleveldropdown.tsx", - "./src/features/debugconsole/restartconfirmmodal.tsx", - "./src/features/debugconsole/debugconsts.ts", - "./src/features/debugconsole/hooks/usefilteredmessages.ts", - "./src/services/httpservice.ts", - "./src/shared/filterclearbutton.tsx", - "./src/shared/filterdropdownsearchparams.tsx", - "./src/shared/filtersearchtext.tsx", - "./src/shared/headerscrollerfooter.tsx", - "./src/shared/listfiltersheader.tsx", - "./src/shared/tablecellspacer.tsx", - "./src/shared/hooks/useappparams.ts", - "./src/shared/icons/objecticons.ts", - "./src/shared/icons/othericons.ts", - "./src/shared/icons/index.tsx", - "./src/shared/types/idlabel.ts", - "./src/shared/types/logmessage.ts", - "./src/store/apislice.ts", - "./src/store/hooks.ts", - "./src/store/store.ts", - "./src/store/websocketmiddleware.ts", - "./src/store/websocketslice.ts", - "./src/store/commonui/commonuihooks.ts", - "./src/store/commonui/commonuiselectors.ts", - "./src/store/commonui/commonuislice.ts", - "./src/store/commonui/commonuistate.ts", - "./vite.config.ts" - ], - "version": "6.0.2" -} +{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorbox.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file From e13afa684e806357438022383b0815ccb1259305 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 10 Apr 2026 12:22:34 -0600 Subject: [PATCH 2/7] feat: add routing feature with React Flow integration - Introduced a new Routing component to visualize device connections using React Flow and Dagre for layout. - Added RoutingDeviceNode component to represent individual devices with input and output ports. - Implemented signal type filtering and device selection/deselection functionality. - Created corresponding SCSS modules for styling the Routing component and device nodes. - Updated package.json to include necessary dependencies: @xyflow/react and dagre. - Enhanced apiSlice to fetch routing devices and tie lines from the backend. - Updated TopNav to include a link to the new Routing feature. --- package-lock.json | 249 ++++++++++++ package.json | 3 + src/App.tsx | 2 + src/features/Routing.module.scss | 53 +++ src/features/Routing.tsx | 418 +++++++++++++++++++++ src/features/RoutingDeviceNode.module.scss | 28 ++ src/features/RoutingDeviceNode.tsx | 98 +++++ src/features/TopNav.tsx | 10 + src/store/apiSlice.ts | 113 +++++- tsconfig.tsbuildinfo | 2 +- 10 files changed, 955 insertions(+), 21 deletions(-) create mode 100644 src/features/Routing.module.scss create mode 100644 src/features/Routing.tsx create mode 100644 src/features/RoutingDeviceNode.module.scss create mode 100644 src/features/RoutingDeviceNode.tsx diff --git a/package-lock.json b/package-lock.json index b842ef7..e1267c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", + "@xyflow/react": "^12.10.2", "axios": "^1.14.0", "bootstrap": "^5.3.8", + "dagre": "^0.8.5", "lodash": "^4.17.21", "react": "^19.2.4", "react-bootstrap": "^2.10.10", @@ -25,6 +27,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dagre": "^0.7.54", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -2021,6 +2024,62 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2289,6 +2348,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2535,6 +2626,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -2605,6 +2702,121 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2943,6 +3155,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4915,6 +5136,34 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ab1619f..dd62db1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", + "@xyflow/react": "^12.10.2", "axios": "^1.14.0", "bootstrap": "^5.3.8", + "dagre": "^0.8.5", "lodash": "^4.17.21", "react": "^19.2.4", "react-bootstrap": "^2.10.10", @@ -27,6 +29,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dagre": "^0.7.54", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/src/App.tsx b/src/App.tsx index d471750..d064219 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import DebugConsole from "./features/DebugConsole/DebugConsole"; import DeviceList from "./features/DeviceList"; import MainLayout from "./features/MainLayout"; import MobileControl from './features/MobileControl'; +import Routing from './features/Routing'; import Types from "./features/Types"; import Versions from "./features/Versions"; import { @@ -50,6 +51,7 @@ function App() { } /> } /> } /> + } /> } /> = { + AudioVideo: "#6f42c1", + Video: "#0d6efd", + Audio: "#dc3545", + "Audio, SecondaryAudio": "#dc3545", + "UsbOutput, UsbInput": "#fd7e14", +}; + +const FALLBACK_COLOR = "#adb5bd"; + +function signalColor(signalType: string): string { + return SIGNAL_COLORS[signalType] ?? FALLBACK_COLOR; +} + +// ─── Dagre layout ──────────────────────────────────────────────────────────── + +function nodeHeight(device: RoutingDevice): number { + const rows = Math.max( + (device.inputPorts ?? []).length, + (device.outputPorts ?? []).length, + 1, + ); + return HEADER_PX + rows * PORT_ROW_PX; +} + +function makeGraph() { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "LR", nodesep: NODE_SEP, ranksep: RANK_SEP }); + return g; +} + +function buildGraph( + data: RoutingDevicesAndTieLines, + hiddenTypes: Set, + hideUnconnected: boolean, + hiddenDevices: Set, +): { nodes: Node[]; edges: Edge[] } { + // Devices that appear in at least one *visible* tie line endpoint + const connectedKeys = new Set( + data.tieLines + .filter((tl) => !hiddenTypes.has(tl.signalType)) + .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]), + ); + + const devices = data.devices.filter((d) => { + if (hiddenDevices.has(d.key)) return false; + if (hideUnconnected && !connectedKeys.has(d.key)) return false; + return true; + }); + + // Collect one unique device-pair edge per source→destination (dagre only + // needs connectivity, not multiplicity, for rank assignment). + const uniquePairs = [ + ...new Set( + data.tieLines.map( + (tl) => `${tl.sourceDeviceKey}|${tl.destinationDeviceKey}`, + ), + ), + ]; + + // ── Pass 1: layout with all edges to detect cross-level (backwards) edges ── + const g1 = makeGraph(); + for (const device of devices) { + g1.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); + } + for (const pair of uniquePairs) { + const [src, dst] = pair.split("|"); + g1.setEdge(src, dst); + } + dagre.layout(g1); + + // Any edge where the source's computed x is greater than the destination's x + // (with a small tolerance) is a "backwards" cross-level edge that would pull + // a midpoint node into a later column. Exclude these from the second pass. + const backwardPairs = new Set( + uniquePairs.filter((pair) => { + const [src, dst] = pair.split("|"); + const sx = g1.node(src)?.x ?? 0; + const dx = g1.node(dst)?.x ?? 0; + return sx > dx + 10; + }), + ); + + // ── Pass 2: layout without backwards edges → correct column alignment ────── + const g = makeGraph(); + for (const device of devices) { + g.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); + } + for (const pair of uniquePairs) { + if (!backwardPairs.has(pair)) { + const [src, dst] = pair.split("|"); + g.setEdge(src, dst); + } + } + dagre.layout(g); + + // Map dagre positions → React Flow nodes + const nodes: Node[] = devices.map((device) => { + const pos = g.node(device.key); + return { + id: device.key, + type: "routingDevice", + position: { + x: pos.x - NODE_WIDTH / 2, + y: pos.y - nodeHeight(device) / 2, + }, + data: { device }, + }; + }); + + // Map tieLines → React Flow edges, filtering by hidden signal types and hidden devices. + // All tie lines are rendered regardless of whether they were excluded from + // the layout pass — backwards edges still appear as connections on the canvas. + const edges: Edge[] = data.tieLines + .filter( + (tl) => + !hiddenTypes.has(tl.signalType) && + !hiddenDevices.has(tl.sourceDeviceKey) && + !hiddenDevices.has(tl.destinationDeviceKey), + ) + .map((tl, idx) => ({ + id: `tl-${idx}-${tl.sourceDeviceKey}-${tl.sourcePortKey}-${tl.destinationDeviceKey}-${tl.destinationPortKey}`, + source: tl.sourceDeviceKey, + sourceHandle: tl.sourcePortKey, + target: tl.destinationDeviceKey, + targetHandle: tl.destinationPortKey, + style: { stroke: signalColor(tl.signalType), strokeWidth: 1.5 }, + animated: false, + })); + + return { nodes, edges }; +} + +// ─── Signal type toggle button list ───────────────────────────────────────── + +function uniqueSignalTypes(tieLines: TieLine[]): string[] { + return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); +} + +// ─── Node types registry (stable reference outside component) ──────────────── + +const nodeTypes: NodeTypes = { + routingDevice: RoutingDeviceNode, +}; + +// ─── Component ─────────────────────────────────────────────────────────────── + +const Routing = () => { + const { appId } = useAppParams(); + const { data, isLoading, isError } = useGetRoutingDevicesAndTieLinesQuery( + appId ? { appId } : skipToken, + ); + + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [hideUnconnected, setHideUnconnected] = useState(false); + const [hiddenDevices, setHiddenDevices] = useState>(new Set()); + const [deviceSearch, setDeviceSearch] = useState(""); + + const sortedDevices = useMemo( + () => + [...(data?.devices ?? [])].sort((a, b) => + (a.name || a.key).localeCompare(b.name || b.key), + ), + [data], + ); + + const filteredDropdownDevices = useMemo(() => { + const q = deviceSearch.toLowerCase(); + if (!q) return sortedDevices; + return sortedDevices.filter( + (d) => + d.key.toLowerCase().includes(q) || d.name.toLowerCase().includes(q), + ); + }, [sortedDevices, deviceSearch]); + + const signalTypes = useMemo( + () => (data ? uniqueSignalTypes(data.tieLines) : []), + [data], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges] = useEdgesState([]); + + // Re-run dagre layout only when the source data or filters change. + // Using useEffect (not useMemo) means React Flow owns the node array + // between renders, so drag positions are preserved. + useEffect(() => { + if (!data) return; + const { nodes: layoutNodes, edges: layoutEdges } = buildGraph( + data, + hiddenTypes, + hideUnconnected, + hiddenDevices, + ); + setNodes(layoutNodes); + setEdges(layoutEdges); + }, [data, hiddenTypes, hideUnconnected, hiddenDevices, setNodes, setEdges]); + + function toggleSignalType(type: string) { + setHiddenTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + } + + function toggleDevice(key: string) { + setHiddenDevices((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + + function selectAllDevices() { + setHiddenDevices(new Set()); + } + + function deselectAllDevices() { + setHiddenDevices(new Set((data?.devices ?? []).map((d) => d.key))); + } + + const visibleDeviceCount = (data?.devices.length ?? 0) - hiddenDevices.size; + + if (isLoading) return
Loading routing data…
; + if (isError || !data) + return
Failed to load routing data.
; + + return ( +
+ {/* Signal type filter bar */} +
+
+ + Signal types: + + {/* Device filter dropdown */} + + {signalTypes.map((type) => { + const hidden = hiddenTypes.has(type); + const color = signalColor(type); + return ( + + ); + })} +
+
+ + Filters: + + + 0 ? "warning" : "outline-secondary"} + className={styles.dropdownToggle} + > + Devices ({visibleDeviceCount}/{data.devices.length}) + + +
+ setDeviceSearch(e.target.value)} + /> +
+ + +
+
+
+ {filteredDropdownDevices.map((device, idx) => ( +
+ toggleDevice(device.key)} + /> + +
+ ))} + {filteredDropdownDevices.length === 0 && ( +
+ No devices match. +
+ )} +
+
+
+
+
+ setHideUnconnected(e.target.checked)} + /> + +
+
+ + {/* React Flow canvas */} +
+ + + + + +
+
+ ); +}; + +export default Routing; diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss new file mode 100644 index 0000000..d1d09c9 --- /dev/null +++ b/src/features/RoutingDeviceNode.module.scss @@ -0,0 +1,28 @@ +.nodeCard { + width: 280px; + font-size: 0.72rem; +} + +.nodeHeader { + line-height: 1.4; +} + +.nodeKeyLabel { + font-size: 0.65rem; +} + +.handle { + background: #555; +} + +// Border added between port rows via adjacent-sibling selector, +// removing the need for a conditional inline style. +.portRow { + & + & { + border-top: 1px solid #f0f0f0; + } +} + +.portLabel { + max-width: 46%; +} diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx new file mode 100644 index 0000000..7e9a6c4 --- /dev/null +++ b/src/features/RoutingDeviceNode.tsx @@ -0,0 +1,98 @@ +import { Handle, NodeProps, Position } from "@xyflow/react"; +import { RoutingDevice } from "../store/apiSlice"; +import styles from "./RoutingDeviceNode.module.scss"; + +export type RoutingDeviceNodeData = { + device: RoutingDevice; +}; + +const PORT_ROW_PX = 28; +const HEADER_PX = 38; + +const RoutingDeviceNode = ({ data }: NodeProps) => { + const device = (data as RoutingDeviceNodeData).device; + const inputPorts = device.inputPorts ?? []; + const outputPorts = device.outputPorts ?? []; + const portRows = Math.max(inputPorts.length, outputPorts.length, 1); + const bodyHeight = portRows * PORT_ROW_PX; + + return ( +
+
+
{device.name || device.key}
+ {device.name && ( +
+ {device.key} +
+ )} +
+ +
+ {/* Input port handles (left side) */} + {inputPorts.map((port, i) => { + const topPct = ((i + 0.5) / portRows) * 100; + return ( + + ); + })} + + {/* Port label rows */} + {Array.from({ length: portRows }).map((_, i) => { + const inPort = inputPorts[i]; + const outPort = outputPorts[i]; + return ( +
+ + {inPort?.key ?? ""} + + + {outPort?.key ?? ""} + +
+ ); + })} + + {/* Output port handles (right side) */} + {outputPorts.map((port, i) => { + const topPct = ((i + 0.5) / portRows) * 100; + return ( + + ); + })} +
+
+ ); +}; + +export { HEADER_PX, PORT_ROW_PX }; +export default RoutingDeviceNode; diff --git a/src/features/TopNav.tsx b/src/features/TopNav.tsx index ef68d8d..b7333b1 100644 --- a/src/features/TopNav.tsx +++ b/src/features/TopNav.tsx @@ -128,6 +128,16 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { > Types + + Routing + ({ - getVersions: builder.query({ + getVersions: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/versions`, method: "GET", @@ -36,7 +35,7 @@ const apiSlice = createApi({ providesTags: ["Version"], }), - getDevices: builder.query({ + getDevices: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/devices`, method: "GET", @@ -44,7 +43,7 @@ const apiSlice = createApi({ providesTags: ["Device"], }), - getTypes: builder.query({ + getTypes: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/types`, method: "GET", @@ -52,7 +51,10 @@ const apiSlice = createApi({ providesTags: ["Type"], }), - getDeviceProperties: builder.query({ + getDeviceProperties: builder.query< + DeviceProperties[], + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceProperties/${key}`, method: "GET", @@ -60,14 +62,20 @@ const apiSlice = createApi({ providesTags: ["DeviceProperty"], }), - getDeviceMethods: builder.query({ + getDeviceMethods: builder.query< + DeviceMethods[], + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceMethods/${key}`, method: "GET", }), }), - getDeviceFeedbacks: builder.query({ + getDeviceFeedbacks: builder.query< + DeviceFeedbacks, + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceFeedbacks/${key}`, method: "GET", @@ -75,15 +83,33 @@ const apiSlice = createApi({ providesTags: ["DeviceFeedback"], }), - setDeviceJsonCommand: builder.mutation({ + setDeviceJsonCommand: builder.mutation< + void, + { + appId: string; + deviceKey: string; + methodName: string; + params?: unknown[]; + } + >({ query: ({ appId, deviceKey, methodName, params }) => ({ url: `/${appId}/api/deviceCommands/${deviceKey}`, method: "POST", - data: {deviceKey, methodName, params}, + data: { deviceKey, methodName, params }, + }), + }), + + getRoutingDevicesAndTieLines: builder.query< + RoutingDevicesAndTieLines, + { appId: string } + >({ + query: ({ appId }) => ({ + url: `/${appId}/api/routingDevicesAndTieLines`, + method: "GET", }), }), - getConfig: builder.query({ + getConfig: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/config`, method: "GET", @@ -91,28 +117,37 @@ const apiSlice = createApi({ providesTags: ["Config"], }), - getMobileControlInfo: builder.query({ + getMobileControlInfo: builder.query< + MobileControlInfo, + { appId: string; deviceKey: string } + >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/info`, method: "GET", }), }), - getMobileControlActionPaths: builder.query({ + getMobileControlActionPaths: builder.query< + any, + { appId: string; deviceKey: string } + >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/actionPaths`, method: "GET", }), }), - getDebugSession: builder.mutation({ + getDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, method: "GET", }), }), - getMinimumLogLevel: builder.query<{ minimumLevel: LogEventLevel }, {appId: string}>({ + getMinimumLogLevel: builder.query< + { minimumLevel: LogEventLevel }, + { appId: string } + >({ query: ({ appId }) => ({ url: `/${appId}/api/appdebug`, method: "GET", @@ -120,7 +155,10 @@ const apiSlice = createApi({ providesTags: ["MinimumLogLevel"], }), - setMinimumLogLevel: builder.mutation({ + setMinimumLogLevel: builder.mutation< + void, + { appId: string; minimumLevel: LogEventLevel } + >({ query: ({ appId, minimumLevel }) => ({ url: `/${appId}/api/appdebug`, method: "POST", @@ -129,7 +167,7 @@ const apiSlice = createApi({ invalidatesTags: ["MinimumLogLevel"], }), - stopDebugSession: builder.mutation({ + stopDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, method: "POST", @@ -138,7 +176,7 @@ const apiSlice = createApi({ getDoNotLoadConfigOnNextBoot: builder.query< { doNotLoadConfigOnNextBoot: boolean }, - {appId: string} + { appId: string } >({ query: ({ appId }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, @@ -147,7 +185,10 @@ const apiSlice = createApi({ providesTags: ["DoNotLoadConfigOnNextBoot"], }), - setDoNotLoadConfigOnNextBoot: builder.mutation({ + setDoNotLoadConfigOnNextBoot: builder.mutation< + void, + { appId: string; doNotLoadConfigOnNextBoot: boolean } + >({ query: ({ appId, doNotLoadConfigOnNextBoot }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, method: "POST", @@ -156,14 +197,14 @@ const apiSlice = createApi({ invalidatesTags: ["DoNotLoadConfigOnNextBoot"], }), - setRestart: builder.mutation({ + setRestart: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/restartProgram`, method: "POST", }), }), - setLoadConfig: builder.mutation({ + setLoadConfig: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/loadConfig`, method: "POST", @@ -191,6 +232,7 @@ export const { useSetLoadConfigMutation, useGetMinimumLogLevelQuery, useSetMinimumLogLevelMutation, + useGetRoutingDevicesAndTieLinesQuery, } = apiSlice; export const oneSliceToRuleThemAll = { @@ -269,6 +311,37 @@ export interface MobileControlInfo { directServer: MobileControlDirectServer; } +export interface RoutingPort { + key: string; + signalType: string; + connectionType: string; + isInternal: boolean; +} + +export interface RoutingDevice { + key: string; + name: string; + hasInputs: boolean; + hasOutputs: boolean; + hasInputsAndOutputs: boolean; + inputPorts?: RoutingPort[]; + outputPorts?: RoutingPort[]; +} + +export interface TieLine { + sourceDeviceKey: string; + sourcePortKey: string; + destinationDeviceKey: string; + destinationPortKey: string; + signalType: string; + isInternal: boolean; +} + +export interface RoutingDevicesAndTieLines { + devices: RoutingDevice[]; + tieLines: TieLine[]; +} + export type LogEventLevel = | "Verbose" | "Debug" diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 71535fa..f54a7a7 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorbox.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorbox.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file From 8ff88edb9efdbb30fbf419ec28595a8467aa93c9 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 10 Apr 2026 18:46:36 -0600 Subject: [PATCH 3/7] feat: enhance RoutingDeviceNode with hide functionality and improve layout --- src/features/Routing.tsx | 15 ++++++++++++- src/features/RoutingDeviceNode.module.scss | 19 ++++++++++++++++ src/features/RoutingDeviceNode.tsx | 26 ++++++++++++++++------ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index ca48b75..4b2c53e 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -231,7 +231,20 @@ const Routing = () => { hideUnconnected, hiddenDevices, ); - setNodes(layoutNodes); + setNodes( + layoutNodes.map((n) => ({ + ...n, + data: { + ...n.data, + onHide: () => + setHiddenDevices((prev) => { + const next = new Set(prev); + next.add(n.id); + return next; + }), + }, + })), + ); setEdges(layoutEdges); }, [data, hiddenTypes, hideUnconnected, hiddenDevices, setNodes, setEdges]); diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss index d1d09c9..b1c6ea6 100644 --- a/src/features/RoutingDeviceNode.module.scss +++ b/src/features/RoutingDeviceNode.module.scss @@ -5,6 +5,25 @@ .nodeHeader { line-height: 1.4; + overflow: hidden; +} + +.hideBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 0; + margin-top: 1px; + line-height: 1; + font-size: 1rem; + color: #888; + cursor: pointer; + opacity: 0.5; + + &:hover { + opacity: 1; + color: #dc3545; + } } .nodeKeyLabel { diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 7e9a6c4..5a78afc 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -4,13 +4,14 @@ import styles from "./RoutingDeviceNode.module.scss"; export type RoutingDeviceNodeData = { device: RoutingDevice; + onHide?: () => void; }; const PORT_ROW_PX = 28; const HEADER_PX = 38; const RoutingDeviceNode = ({ data }: NodeProps) => { - const device = (data as RoutingDeviceNodeData).device; + const { device, onHide } = data as RoutingDeviceNodeData; const inputPorts = device.inputPorts ?? []; const outputPorts = device.outputPorts ?? []; const portRows = Math.max(inputPorts.length, outputPorts.length, 1); @@ -22,15 +23,26 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { style={{ minHeight: HEADER_PX + bodyHeight }} >
-
{device.name || device.key}
- {device.name && ( -
- {device.key} -
+
+
{device.name || device.key}
+ {device.name && ( +
+ {device.key} +
+ )} +
+ {onHide && ( + )}
From 122704b59c135d2f756feb4cc70adeb5c5ca7894 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 10 Apr 2026 18:51:37 -0600 Subject: [PATCH 4/7] feat: add version check for routing feature availability --- src/features/Routing.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 4b2c53e..679dfd5 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -22,6 +22,7 @@ import { RoutingDevicesAndTieLines, TieLine, useGetRoutingDevicesAndTieLinesQuery, + useGetVersionsQuery, } from "../store/apiSlice"; import styles from "./Routing.module.scss"; import RoutingDeviceNode, { @@ -176,6 +177,18 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); } +// Compares dot-separated version strings numerically (e.g. "2.29" > "2.9"). +function meetsMinVersion(version: string, minimum: string): boolean { + const vParts = version.split(".").map(Number); + const mParts = minimum.split(".").map(Number); + for (let i = 0; i < Math.max(vParts.length, mParts.length); i++) { + const a = vParts[i] ?? 0; + const b = mParts[i] ?? 0; + if (a !== b) return a > b; + } + return true; +} + // ─── Node types registry (stable reference outside component) ──────────────── const nodeTypes: NodeTypes = { @@ -186,6 +199,7 @@ const nodeTypes: NodeTypes = { const Routing = () => { const { appId } = useAppParams(); + const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); const { data, isLoading, isError } = useGetRoutingDevicesAndTieLinesQuery( appId ? { appId } : skipToken, ); @@ -285,6 +299,20 @@ const Routing = () => { if (isLoading) return
Loading routing data…
; if (isError || !data) return
Failed to load routing data.
; + if ( + versions && + !versions.some( + (v) => + v.Name === "PepperDashEssentials" && + meetsMinVersion(v.Version, "2.29"), + ) + ) { + return ( +
+ Routing feature is not available for this version. +
+ ); + } return (
From 50184a10bf6611309c531bfd80d3b7f0a2f0b668 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 13 Apr 2026 22:00:04 -0600 Subject: [PATCH 5/7] feat: add ErrorBoundary and InitializationExceptions components, enhance Routing with dark mode and unconnected ports functionality --- src/App.tsx | 13 +- src/features/ErrorBoundary.tsx | 31 ++++ src/features/InitializationExceptions.tsx | 94 +++++++++++ src/features/Routing.tsx | 166 ++++++++++++++++---- src/features/RoutingDeviceNode.module.scss | 25 ++- src/features/RoutingDeviceNode.tsx | 9 +- src/features/TopNav.tsx | 86 +++++++++- src/shared/functions/meetsMinimumVersion.ts | 12 ++ src/store/apiSlice.ts | 22 +++ tsconfig.tsbuildinfo | 2 +- 10 files changed, 413 insertions(+), 47 deletions(-) create mode 100644 src/features/ErrorBoundary.tsx create mode 100644 src/features/InitializationExceptions.tsx create mode 100644 src/shared/functions/meetsMinimumVersion.ts diff --git a/src/App.tsx b/src/App.tsx index d064219..d55fb99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import { Navigate, Route, Routes } from "react-router-dom"; import ConfigFile from "./features/ConfigFile"; import DebugConsole from "./features/DebugConsole/DebugConsole"; import DeviceList from "./features/DeviceList"; +import ErrorBoundary from "./features/ErrorBoundary"; +import InitializationExceptions from "./features/InitializationExceptions"; import MainLayout from "./features/MainLayout"; import MobileControl from './features/MobileControl'; import Routing from './features/Routing'; @@ -43,11 +45,13 @@ function App() { }; return ( - - + + + } /> }> } /> + } /> } /> } /> } /> @@ -65,8 +69,9 @@ function App() { } /> - - + + + ); } diff --git a/src/features/ErrorBoundary.tsx b/src/features/ErrorBoundary.tsx new file mode 100644 index 0000000..46087e6 --- /dev/null +++ b/src/features/ErrorBoundary.tsx @@ -0,0 +1,31 @@ +import { Component, ErrorInfo, ReactNode } from "react"; +import ErrorBox from "./ErrorBox"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +class ErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ; + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/features/InitializationExceptions.tsx b/src/features/InitializationExceptions.tsx new file mode 100644 index 0000000..6c372a3 --- /dev/null +++ b/src/features/InitializationExceptions.tsx @@ -0,0 +1,94 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import { useState } from "react"; +import useAppParams from "../shared/hooks/useAppParams"; +import { + EssentialsException, + useGetInitializationExceptionsQuery, +} from "../store/apiSlice"; + +const InitializationExceptions = () => { + const { appId } = useAppParams(); + const { data, isLoading, isError } = useGetInitializationExceptionsQuery( + appId ? { appId } : skipToken, + ); + + console.log("Initialization exceptions:", data?.Exceptions); + + const [expandedIndex, setExpandedIndex] = useState(null); + + if (isLoading) return
Loading…
; + if (isError) + return ( +
+ Failed to load initialization exceptions. +
+ ); + + if (!data || data.Exceptions.length === 0) { + return ( +
+ No initialization exceptions reported. +
+ ); + } + + return ( +
+

Initialization Exceptions

+
+ + + + + + + + + + {data.Exceptions.map((ex: EssentialsException, idx: number) => { + const isExpanded = expandedIndex === idx; + return ( + <> + + + + + + {isExpanded && ex.StackTrace && ( + + + + )} + + ); + })} + +
#MessageStack trace
{idx + 1} + + {ex.Message} + + + {ex.StackTrace && ( + + )} +
+
+                          {ex.StackTrace}
+                        
+
+
+
+ ); +}; + +export default InitializationExceptions; diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 679dfd5..96d7c63 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -13,9 +13,10 @@ import { useNodesState, } from "@xyflow/react"; import dagre from "dagre"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Dropdown } from "react-bootstrap"; +import { meetsMinVersion } from '../shared/functions/meetsMinimumVersion'; import useAppParams from "../shared/hooks/useAppParams"; import { RoutingDevice, @@ -43,6 +44,8 @@ const SIGNAL_COLORS: Record = { Audio: "#dc3545", "Audio, SecondaryAudio": "#dc3545", "UsbOutput, UsbInput": "#fd7e14", + UsbOutput: "#fd7e14", + UsbInput: "#fd7e14", }; const FALLBACK_COLOR = "#adb5bd"; @@ -74,6 +77,7 @@ function buildGraph( hiddenTypes: Set, hideUnconnected: boolean, hiddenDevices: Set, + hideUnconnectedPorts: boolean, ): { nodes: Node[]; edges: Edge[] } { // Devices that appear in at least one *visible* tie line endpoint const connectedKeys = new Set( @@ -82,12 +86,43 @@ function buildGraph( .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]), ); + // Tie lines that pass all active filters (used for port-level filtering) + const visibleTieLines = data.tieLines.filter( + (tl) => + !hiddenTypes.has(tl.signalType) && + !hiddenDevices.has(tl.sourceDeviceKey) && + !hiddenDevices.has(tl.destinationDeviceKey), + ); + const connectedPortKeys = hideUnconnectedPorts + ? new Set( + visibleTieLines.flatMap((tl) => [ + `${tl.sourceDeviceKey}:${tl.sourcePortKey}`, + `${tl.destinationDeviceKey}:${tl.destinationPortKey}`, + ]), + ) + : null; + const devices = data.devices.filter((d) => { if (hiddenDevices.has(d.key)) return false; if (hideUnconnected && !connectedKeys.has(d.key)) return false; return true; }); + // Filter each device's ports down to only those with active tie lines + const effectiveDevices = devices.map((d) => + connectedPortKeys + ? { + ...d, + inputPorts: (d.inputPorts ?? []).filter((p) => + connectedPortKeys.has(`${d.key}:${p.key}`), + ), + outputPorts: (d.outputPorts ?? []).filter((p) => + connectedPortKeys.has(`${d.key}:${p.key}`), + ), + } + : d, + ); + // Collect one unique device-pair edge per source→destination (dagre only // needs connectivity, not multiplicity, for rank assignment). const uniquePairs = [ @@ -100,7 +135,7 @@ function buildGraph( // ── Pass 1: layout with all edges to detect cross-level (backwards) edges ── const g1 = makeGraph(); - for (const device of devices) { + for (const device of effectiveDevices) { g1.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); } for (const pair of uniquePairs) { @@ -123,7 +158,7 @@ function buildGraph( // ── Pass 2: layout without backwards edges → correct column alignment ────── const g = makeGraph(); - for (const device of devices) { + for (const device of effectiveDevices) { g.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); } for (const pair of uniquePairs) { @@ -135,7 +170,7 @@ function buildGraph( dagre.layout(g); // Map dagre positions → React Flow nodes - const nodes: Node[] = devices.map((device) => { + const nodes: Node[] = effectiveDevices.map((device) => { const pos = g.node(device.key); return { id: device.key, @@ -165,6 +200,7 @@ function buildGraph( target: tl.destinationDeviceKey, targetHandle: tl.destinationPortKey, style: { stroke: signalColor(tl.signalType), strokeWidth: 1.5 }, + data: { signalColor: signalColor(tl.signalType) }, animated: false, })); @@ -177,17 +213,6 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); } -// Compares dot-separated version strings numerically (e.g. "2.29" > "2.9"). -function meetsMinVersion(version: string, minimum: string): boolean { - const vParts = version.split(".").map(Number); - const mParts = minimum.split(".").map(Number); - for (let i = 0; i < Math.max(vParts.length, mParts.length); i++) { - const a = vParts[i] ?? 0; - const b = mParts[i] ?? 0; - if (a !== b) return a > b; - } - return true; -} // ─── Node types registry (stable reference outside component) ──────────────── @@ -208,6 +233,9 @@ const Routing = () => { const [hideUnconnected, setHideUnconnected] = useState(false); const [hiddenDevices, setHiddenDevices] = useState>(new Set()); const [deviceSearch, setDeviceSearch] = useState(""); + const [hideUnconnectedPorts, setHideUnconnectedPorts] = useState(false); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [darkMode, setDarkMode] = useState(false); const sortedDevices = useMemo( () => @@ -244,12 +272,14 @@ const Routing = () => { hiddenTypes, hideUnconnected, hiddenDevices, + hideUnconnectedPorts, ); setNodes( layoutNodes.map((n) => ({ ...n, data: { ...n.data, + darkMode, onHide: () => setHiddenDevices((prev) => { const next = new Set(prev); @@ -260,7 +290,39 @@ const Routing = () => { })), ); setEdges(layoutEdges); - }, [data, hiddenTypes, hideUnconnected, hiddenDevices, setNodes, setEdges]); + setSelectedEdgeId(null); + }, [data, hiddenTypes, hideUnconnected, hiddenDevices, hideUnconnectedPorts, darkMode, setNodes, setEdges]); + + // Re-style edges when selection changes without triggering a layout rebuild. + useEffect(() => { + setEdges((eds) => + eds.map((e) => { + const baseColor = + (e.data as { signalColor?: string } | undefined)?.signalColor ?? + FALLBACK_COLOR; + if (selectedEdgeId === null) { + return { ...e, style: { stroke: baseColor, strokeWidth: 1.5 } }; + } + const isSelected = e.id === selectedEdgeId; + return { + ...e, + style: { + stroke: isSelected ? baseColor : "#ccc", + strokeWidth: isSelected ? 3.5 : 1.5, + opacity: isSelected ? 1 : 0.35, + }, + }; + }), + ); + }, [selectedEdgeId, setEdges]); + + const onEdgeClick = useCallback( + (_: React.MouseEvent, edge: Edge) => + setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id)), + [], + ); + + const onPaneClick = useCallback(() => setSelectedEdgeId(null), []); function toggleSignalType(type: string) { setHiddenTypes((prev) => { @@ -299,11 +361,13 @@ const Routing = () => { if (isLoading) return
Loading routing data…
; if (isError || !data) return
Failed to load routing data.
; + + if ( versions && !versions.some( (v) => - v.Name === "PepperDashEssentials" && + v.Name === "PepperDashEssentials.dll" && meetsMinVersion(v.Version, "2.29"), ) ) { @@ -415,32 +479,70 @@ const Routing = () => {
-
- setHideUnconnected(e.target.checked)} - /> - +
+
+ setHideUnconnected(e.target.checked)} + /> + +
+
+ setHideUnconnectedPorts(e.target.checked)} + /> + +
+
+ setDarkMode(e.target.checked)} + /> + +
{/* React Flow canvas */} -
+
void; + darkMode?: boolean; }; const PORT_ROW_PX = 28; const HEADER_PX = 38; const RoutingDeviceNode = ({ data }: NodeProps) => { - const { device, onHide } = data as RoutingDeviceNodeData; + const { device, onHide, darkMode } = data as RoutingDeviceNodeData; const inputPorts = device.inputPorts ?? []; const outputPorts = device.outputPorts ?? []; const portRows = Math.max(inputPorts.length, outputPorts.length, 1); @@ -19,11 +20,11 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { return (
@@ -70,7 +71,7 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { return (
diff --git a/src/features/TopNav.tsx b/src/features/TopNav.tsx index b7333b1..dbf6341 100644 --- a/src/features/TopNav.tsx +++ b/src/features/TopNav.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { Dropdown, Nav, Navbar } from "react-bootstrap"; import { NavLink, useLocation } from "react-router-dom"; +import { meetsMinVersion } from '../shared/functions/meetsMinimumVersion'; import useAppParams from "../shared/hooks/useAppParams"; import { IconDarkChevronDown, IconDarkEllipse } from "../shared/icons"; import { useGetVersionsQuery } from "../store/apiSlice"; @@ -48,6 +49,71 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { app10versions, ]); + // Extract the current sub-route (e.g. "routing", "console") so we can + // preserve it when switching apps. Falls back to "console" if not on an + // app-scoped route yet. + const currentSubRoute = useMemo(() => { + const match = location.pathname.match(/^\/app\d+\/(.+)/); + return match ? match[1] : "console"; + }, [location.pathname]); + + // based on the curren params.appId, use the matching versions response to determine if the current version of PepperDashEssentials.dll is >= 3.0 + const showInitializationExceptions = useMemo(() => { + let versions; + switch (params.appId) { + case "app01": + versions = app01versions; + break; + case "app02": + versions = app02versions; + break; + case "app03": + versions = app03versions; + break; + case "app04": + versions = app04versions; + break; + case "app05": + versions = app05versions; + break; + case "app06": + versions = app06versions; + break; + case "app07": + versions = app07versions; + break; + case "app08": + versions = app08versions; + break; + case "app09": + versions = app09versions; + break; + case "app10": + versions = app10versions; + break; + default: + return false; + } + const essentialsVersion = versions?.find( + (v) => v.Name === "PepperDashEssentials.dll", + )?.Version; + if (!essentialsVersion) return false; + + return meetsMinVersion(essentialsVersion, "3.0.0"); + }, [ + params.appId, + app01versions, + app02versions, + app03versions, + app04versions, + app05versions, + app06versions, + app07versions, + app08versions, + app09versions, + app10versions, + ]); + return ( { {appIdOptions.map((id) => ( - + {id} ))} @@ -88,6 +158,20 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { > Versions + {showInitializationExceptions && ( + + Initialization Exceptions + + )} "2.9"). +// Strips semver pre-release suffixes (e.g. "2.29.0-alpha.1" → "2.29.0"). +export function meetsMinVersion(version: string, minimum: string): boolean { + const vParts = version.split("-")[0].split(".").map((s) => parseInt(s, 10)); + const mParts = minimum.split("-")[0].split(".").map((s) => parseInt(s, 10)); + for (let i = 0; i < Math.max(vParts.length, mParts.length); i++) { + const a = vParts[i] ?? 0; + const b = mParts[i] ?? 0; + if (a !== b) return a > b; + } + return true; +} \ No newline at end of file diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 48407f4..0a48049 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -35,6 +35,13 @@ const apiSlice = createApi({ providesTags: ["Version"], }), + getInitializationExceptions: builder.query({ + query: ({ appId }) => ({ + url: `/${appId}/api/initializationExceptions`, + method: "GET", + }), + }), + getDevices: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/devices`, @@ -215,6 +222,7 @@ const apiSlice = createApi({ export const { useGetVersionsQuery, + useGetInitializationExceptionsQuery, useGetDevicesQuery, useGetTypesQuery, useGetDevicePropertiesQuery, @@ -241,6 +249,20 @@ export const oneSliceToRuleThemAll = { getBaseApiPath, }; +export interface EssentialsExceptionReturn { + Exceptions: EssentialsException[]; +} + +export interface EssentialsException extends EssentialsExceptionBase { + InnerException?: EssentialsExceptionBase; +} + +export interface EssentialsExceptionBase { + Message: string; + StackTrace: string; + Type: string; +} + export interface Type { Type: string; Description: string; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index f54a7a7..5f4f2c5 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorbox.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorboundary.tsx","./src/features/errorbox.tsx","./src/features/initializationexceptions.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/functions/meetsminimumversion.ts","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file From 610788fe2b912fa04fc8beea2100f80ad797cdea Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 14 Apr 2026 22:13:59 -0600 Subject: [PATCH 6/7] feat: enhance DebugConsole with connection failure alerts, improve MobileControl UI, and add TieLineEdge component for routing visualization --- .gitignore | 1 + src/features/DebugConsole/DebugConsole.tsx | 15 +- .../DebugConsole/hooks/useFilteredMessages.ts | 2 +- src/features/MobileControl.tsx | 334 ++++++++++++++---- src/features/Routing.module.scss | 26 ++ src/features/Routing.tsx | 49 ++- src/features/RoutingDeviceNode.module.scss | 32 +- src/features/RoutingDeviceNode.tsx | 25 +- src/features/TieLineEdge.tsx | 83 +++++ src/features/TopNav.tsx | 2 +- src/store/apiSlice.ts | 70 +++- src/store/websocketMiddleware.ts | 9 +- src/store/websocketSlice.ts | 12 +- tsconfig.tsbuildinfo | 2 +- 14 files changed, 581 insertions(+), 81 deletions(-) create mode 100644 src/features/TieLineEdge.tsx diff --git a/.gitignore b/.gitignore index e149012..41fa25d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yarn-debug.log* yarn-error.log* dist/assets/index-C_SGWbYA.js tsconfig.tsbuildinfo +tsconfig.tsbuildinfo diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index 4d0ead3..24e7f67 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from "react"; -import { Button, Form } from "react-bootstrap"; +import { Alert, Button, Form } from "react-bootstrap"; import { useSelector } from 'react-redux'; import ListFiltersHeader from "../../shared/ListFiltersHeader"; import useAppParams from '../../shared/hooks/useAppParams'; @@ -22,6 +22,10 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); const messages = useSelector((state: RootState) => state.websocket.messages); + const failedUrl = useSelector((state: RootState) => state.websocket.failedUrl); + const certUrl = failedUrl + ? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') + : null; const { data: doNotLoadConfigOnNextBoot } = useGetDoNotLoadConfigOnNextBootQuery(appId ? { appId } : skipToken); @@ -106,6 +110,15 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { Message Count: {messages.length}
+ {certUrl && ( + + Connection failed. The debug server may have an untrusted certificate.{' '} + + Open {certUrl} in a new tab + + {', accept the certificate, then try "Start Debug Session" again.'} + + )} } />
diff --git a/src/features/DebugConsole/hooks/useFilteredMessages.ts b/src/features/DebugConsole/hooks/useFilteredMessages.ts index 605c4dc..a2e00d4 100644 --- a/src/features/DebugConsole/hooks/useFilteredMessages.ts +++ b/src/features/DebugConsole/hooks/useFilteredMessages.ts @@ -55,7 +55,7 @@ export function useFilteredMessages(listItems: LogMessage[]) { ]; // true if for every search text word, some field contains it textMatch = searchText.every((st) => - textMatchFields.some((f) => f?.toLowerCase().includes(st)) + textMatchFields.some((f) => f?.toLowerCase().includes(st.toLowerCase())) ); } return ( diff --git a/src/features/MobileControl.tsx b/src/features/MobileControl.tsx index 87dfa39..a8a81f0 100644 --- a/src/features/MobileControl.tsx +++ b/src/features/MobileControl.tsx @@ -1,80 +1,296 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import useAppParams from '../shared/hooks/useAppParams'; -import { useGetMobileControlInfoQuery } from '../store/apiSlice'; - +import { skipToken } from "@reduxjs/toolkit/query"; +import { useState } from "react"; +import { Button, Modal } from "react-bootstrap"; +import useAppParams from "../shared/hooks/useAppParams"; +import { + ActionPath, + ClientRequest, + ClientResponse, + MobileControlClient, + useCreateMobileControlUiClientMutation, + useDeleteAllMobileControlUiClientsMutation, + useDeleteMobileControlUiClientMutation, + useGetMobileControlActionPathsQuery, + useGetMobileControlInfoQuery, +} from "../store/apiSlice"; const MobileControl = () => { const { appId } = useAppParams(); console.log("AppId in MobileControl", appId); - const { data: info } = useGetMobileControlInfoQuery(appId ? { appId, deviceKey: "appServer" } : skipToken); - console.log("Mobile Control Info", info); + const { data: info } = useGetMobileControlInfoQuery( + appId ? { appId, deviceKey: "appServer" } : skipToken, + ); + const { data: actionPaths } = useGetMobileControlActionPathsQuery( + appId ? { appId, deviceKey: "appServer" } : skipToken, + ); + + const [deleteClient] = useDeleteMobileControlUiClientMutation(); + const [deleteAllClients] = useDeleteAllMobileControlUiClientsMutation(); + const [createClient] = useCreateMobileControlUiClientMutation(); + const [pendingDelete, setPendingDelete] = + useState(null); + const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newRoomKey, setNewRoomKey] = useState(""); + const [newGrantCode, setNewGrantCode] = useState(""); + + const handleConfirmDelete = async () => { + if (!appId || !pendingDelete) return; + const clientPayload: ClientResponse = { + error: "", + token: pendingDelete.token, + path: "", + }; + await deleteClient({ + appId, + deviceKey: "appServer-directServer", + client: clientPayload, + }); + setPendingDelete(null); + }; + + const handleConfirmDeleteAll = async () => { + if (!appId) return; + await deleteAllClients({ appId, deviceKey: "appServer-directServer" }); + setConfirmDeleteAll(false); + }; - if (!info) { + const handleCreateClient = async () => { + if (!appId || !newRoomKey.trim()) return; + const request: ClientRequest = { + roomKey: newRoomKey.trim(), + grantCode: newGrantCode.trim(), + token: "", + }; + await createClient({ appId, deviceKey: "appServer-directServer", request }); + setShowCreateModal(false); + setNewRoomKey(""); + setNewGrantCode(""); + }; + + if (!info || !actionPaths) { return
Mobile Control Not Available
; } const { directServer: ds } = info; return ( -
+

Mobile Control

-
-
Direct Server
- - - - - - - - - - - - - - - - - - - -
User App URL{ds.userAppUrl}
Server Port{ds.serverPort}
Tokens Defined{ds.tokensDefined}
Clients Connected{ds.clientsConnected}
-
- -
-
Clients
- - - - - - - - - - - - {ds.clients.map((client) => ( - - - - - + {ds && ( +
+
Direct Server
+
#Room KeyTouchpanel KeyTokenURL
{client.clientNumber}{client.roomKey}{client.touchpanelKey}{client.token}
+ + + - ))} - -
User App URL - - {client.url} - + {ds.userAppUrl}
+ + Server Port + {ds.serverPort} + + + Tokens Defined + {ds.tokensDefined} + + + Clients Connected + {ds.clientsConnected} + + + +
+ )} + +
+
Clients
+
+ + + + + + + + + + + + + {ds.clients.map((client) => ( + + + + + + + + + ))} + +
#Room KeyTouchpanel Key + Token + URL +
+ + {ds.clients.length > 0 && ( + + )} +
+
{client.clientNumber}{client.roomKey}{client.touchpanelKey} + {client.token} + + + {client.url} + + + +
+
+ +
+
Action Paths
+
+ + + + + + + + + {actionPaths.actionPaths.map((ap: ActionPath) => ( + + + + + ))} + +
Messenger KeyPath
{ap.messengerKey} + {ap.path} +
+
+
+ + setPendingDelete(null)} + centered + > + + Delete Client + + + Are you sure you want to delete client{" "} + {pendingDelete?.clientNumber}? + + + + + + + + setConfirmDeleteAll(false)} + centered + > + + Delete All Clients + + + Are you sure you want to delete all clients? + + + + + + + + setShowCreateModal(false)} + centered + > + + New Client + + +
+ + setNewRoomKey(e.target.value)} + placeholder="e.g. room1" + /> +
+
+ + setNewGrantCode(e.target.value)} + placeholder="e.g. abc123" + /> +
+
+ + + + +
); -} +}; -export default MobileControl; \ No newline at end of file +export default MobileControl; diff --git a/src/features/Routing.module.scss b/src/features/Routing.module.scss index ada5e5b..eb8b295 100644 --- a/src/features/Routing.module.scss +++ b/src/features/Routing.module.scss @@ -51,3 +51,29 @@ .hideUnconnectedSwitch { font-size: 0.8rem; } + +.tieLineTooltip { + position: absolute; + background: rgba(30, 30, 30, 0.92); + color: #f0f0f0; + border-radius: 5px; + padding: 6px 10px; + font-size: 0.72rem; + pointer-events: none; + white-space: nowrap; + z-index: 10; + line-height: 1.6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.tieLineTooltipRow { + display: flex; + gap: 8px; +} + +.tieLineTooltipLabel { + color: #aaa; + min-width: 52px; + text-align: right; + flex-shrink: 0; +} diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 96d7c63..0d573bf 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -5,6 +5,7 @@ import { Background, Controls, Edge, + EdgeTypes, MiniMap, Node, NodeTypes, @@ -31,6 +32,7 @@ import RoutingDeviceNode, { PORT_ROW_PX, RoutingDeviceNodeData, } from "./RoutingDeviceNode"; +import TieLineEdge from "./TieLineEdge"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -200,7 +202,15 @@ function buildGraph( target: tl.destinationDeviceKey, targetHandle: tl.destinationPortKey, style: { stroke: signalColor(tl.signalType), strokeWidth: 1.5 }, - data: { signalColor: signalColor(tl.signalType) }, + data: { + signalColor: signalColor(tl.signalType), + sourceDeviceKey: tl.sourceDeviceKey, + sourcePortKey: tl.sourcePortKey, + destinationDeviceKey: tl.destinationDeviceKey, + destinationPortKey: tl.destinationPortKey, + signalType: tl.signalType, + }, + type: "tieLine", animated: false, })); @@ -220,6 +230,10 @@ const nodeTypes: NodeTypes = { routingDevice: RoutingDeviceNode, }; +const edgeTypes: EdgeTypes = { + tieLine: TieLineEdge, +}; + // ─── Component ─────────────────────────────────────────────────────────────── const Routing = () => { @@ -300,12 +314,17 @@ const Routing = () => { const baseColor = (e.data as { signalColor?: string } | undefined)?.signalColor ?? FALLBACK_COLOR; + const isSelected = selectedEdgeId !== null && e.id === selectedEdgeId; if (selectedEdgeId === null) { - return { ...e, style: { stroke: baseColor, strokeWidth: 1.5 } }; + return { + ...e, + data: { ...e.data, selected: false }, + style: { stroke: baseColor, strokeWidth: 1.5 }, + }; } - const isSelected = e.id === selectedEdgeId; return { ...e, + data: { ...e.data, selected: isSelected }, style: { stroke: isSelected ? baseColor : "#ccc", strokeWidth: isSelected ? 3.5 : 1.5, @@ -324,6 +343,27 @@ const Routing = () => { const onPaneClick = useCallback(() => setSelectedEdgeId(null), []); + const onEdgeMouseEnter = useCallback( + (_: React.MouseEvent, edge: Edge) => + setEdges((eds) => { + if (eds.some((e) => e.data?.selected)) return eds; + return eds.map((e) => + e.id === edge.id ? { ...e, data: { ...e.data, hovered: true } } : e, + ); + }), + [setEdges], + ); + + const onEdgeMouseLeave = useCallback( + (_: React.MouseEvent, edge: Edge) => + setEdges((eds) => + eds.map((e) => + e.id === edge.id ? { ...e, data: { ...e.data, hovered: false } } : e, + ), + ), + [setEdges], + ); + function toggleSignalType(type: string) { setHiddenTypes((prev) => { const next = new Set(prev); @@ -538,8 +578,11 @@ const Routing = () => { edges={edges} onNodesChange={onNodesChange} onEdgeClick={onEdgeClick} + onEdgeMouseEnter={onEdgeMouseEnter} + onEdgeMouseLeave={onEdgeMouseLeave} onPaneClick={onPaneClick} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} nodesConnectable={false} elementsSelectable={false} colorMode={darkMode ? "dark" : "light"} diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss index 59d1e18..b46bb98 100644 --- a/src/features/RoutingDeviceNode.module.scss +++ b/src/features/RoutingDeviceNode.module.scss @@ -57,6 +57,36 @@ } } -.portLabel { +.portLabelWrap { + position: relative; max-width: 46%; + min-width: 0; + + &:hover .portTooltip { + opacity: 1; + } +} + +.portLabelText { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portTooltip { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: rgba(30, 30, 30, 0.92); + color: #f0f0f0; + border-radius: 4px; + padding: 3px 8px; + font-size: 0.68rem; + white-space: nowrap; + pointer-events: none; + z-index: 100; + opacity: 0; + transition: opacity 0.12s; } diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 7bc715b..78fc558 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -74,15 +74,22 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { className={`d-flex justify-content-between align-items-center px-3 ${styles.portRow} ${darkMode ? styles.portRowDark : ""}`} style={{ height: PORT_ROW_PX }} > - - {inPort?.key ?? ""} - - - {outPort?.key ?? ""} - +
+ + {inPort?.key ?? ""} + + {inPort && ( + {inPort.signalType} + )} +
+
+ + {outPort?.key ?? ""} + + {outPort && ( + {outPort.signalType} + )} +
); })} diff --git a/src/features/TieLineEdge.tsx b/src/features/TieLineEdge.tsx new file mode 100644 index 0000000..64e93a9 --- /dev/null +++ b/src/features/TieLineEdge.tsx @@ -0,0 +1,83 @@ +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, +} from "@xyflow/react"; +import styles from "./Routing.module.scss"; + +export interface TieLineEdgeData { + signalColor: string; + sourceDeviceKey: string; + sourcePortKey: string; + destinationDeviceKey: string; + destinationPortKey: string; + signalType: string; + hovered?: boolean; + selected?: boolean; + [key: string]: unknown; +} + +const TieLineEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style, + data, + markerEnd, +}: EdgeProps) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const d = data as TieLineEdgeData | undefined; + const showTooltip = (d?.hovered || d?.selected) ?? false; + + return ( + <> + + {showTooltip && d && ( + +
+
+ Type + {d.signalType} +
+
+ Source + {d.sourceDeviceKey} +
+
+ Src Port + {d.sourcePortKey} +
+
+ Dest + {d.destinationDeviceKey} +
+
+ Dst Port + {d.destinationPortKey} +
+
+
+ )} + + ); +}; + +export default TieLineEdge; diff --git a/src/features/TopNav.tsx b/src/features/TopNav.tsx index dbf6341..0263b33 100644 --- a/src/features/TopNav.tsx +++ b/src/features/TopNav.tsx @@ -238,7 +238,7 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { className={isConnected ? "text-success" : "text-danger"} /> - {isConnected ? "Connected" : "Disconnected"} + Debug Console {isConnected ? "Connected" : "Disconnected"}
diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 0a48049..d9ae582 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -25,6 +25,7 @@ const apiSlice = createApi({ "DebugSession", "DoNotLoadConfigOnNextBoot", "MinimumLogLevel", + "MobileControlInfo", ], endpoints: (builder) => ({ getVersions: builder.query({ @@ -35,7 +36,10 @@ const apiSlice = createApi({ providesTags: ["Version"], }), - getInitializationExceptions: builder.query({ + getInitializationExceptions: builder.query< + EssentialsExceptionReturn, + { appId: string } + >({ query: ({ appId }) => ({ url: `/${appId}/api/initializationExceptions`, method: "GET", @@ -132,10 +136,11 @@ const apiSlice = createApi({ url: `/${appId}/api/device/${deviceKey}/info`, method: "GET", }), + providesTags: ["MobileControlInfo"], }), getMobileControlActionPaths: builder.query< - any, + MobileControlActionPaths, { appId: string; deviceKey: string } >({ query: ({ appId, deviceKey }) => ({ @@ -217,6 +222,41 @@ const apiSlice = createApi({ method: "POST", }), }), + + createMobileControlUiClient: builder.mutation< + ClientResponse, + { appId: string; deviceKey: string, request: ClientRequest } + >({ + query: ({ appId, deviceKey, request }) => ({ + url: `/${appId}/api/device/${deviceKey}/client`, + method: "POST", + data: request, + }), + invalidatesTags: ["MobileControlInfo"], + }), + + deleteMobileControlUiClient: builder.mutation< + void, + { appId: string; deviceKey: string; client: ClientResponse } + >({ + query: ({ appId, deviceKey, client }) => ({ + url: `/${appId}/api/device/${deviceKey}/client`, + method: "DELETE", + data: client, + }), + invalidatesTags: ["MobileControlInfo"], + }), + + deleteAllMobileControlUiClients: builder.mutation< + void, + { appId: string; deviceKey: string } + >({ + query: ({ appId, deviceKey }) => ({ + url: `/${appId}/api/device/${deviceKey}/deleteAllUiClients`, + method: "DELETE", + }), + invalidatesTags: ["MobileControlInfo"], + }), }), }); @@ -241,6 +281,9 @@ export const { useGetMinimumLogLevelQuery, useSetMinimumLogLevelMutation, useGetRoutingDevicesAndTieLinesQuery, + useCreateMobileControlUiClientMutation, + useDeleteMobileControlUiClientMutation, + useDeleteAllMobileControlUiClientsMutation, } = apiSlice; export const oneSliceToRuleThemAll = { @@ -253,7 +296,7 @@ export interface EssentialsExceptionReturn { Exceptions: EssentialsException[]; } -export interface EssentialsException extends EssentialsExceptionBase { +export interface EssentialsException extends EssentialsExceptionBase { InnerException?: EssentialsExceptionBase; } @@ -333,6 +376,27 @@ export interface MobileControlInfo { directServer: MobileControlDirectServer; } +export interface MobileControlActionPaths { + actionPaths: ActionPath[]; +} + +export interface ActionPath { + messengerKey: string; + path: string; +} + +export interface ClientRequest { + roomKey: string; + grantCode: string; + token: string; +} + +export interface ClientResponse { + error: string; + token: string; + path: string; +} + export interface RoutingPort { key: string; signalType: string; diff --git a/src/store/websocketMiddleware.ts b/src/store/websocketMiddleware.ts index eb797ea..6b60a7f 100644 --- a/src/store/websocketMiddleware.ts +++ b/src/store/websocketMiddleware.ts @@ -1,6 +1,8 @@ import { Middleware } from "@reduxjs/toolkit"; import { connected, + connectionAttemptStarted, + connectionFailed, disconnected, messageReceived, WS_CONNECT, @@ -20,6 +22,8 @@ export const websocketMiddleware: Middleware = (store) => { console.log("[ws] Connecting to", url); + store.dispatch(connectionAttemptStarted()); + // Close any existing connection before opening a new one if (socket) { socket.close(); @@ -31,7 +35,10 @@ export const websocketMiddleware: Middleware = (store) => { store.dispatch(disconnected()); socket = null; }; - socket.onerror = (err) => console.error("WebSocket error", err); + socket.onerror = (err) => { + console.error("WebSocket error", err); + store.dispatch(connectionFailed(url)); + }; socket.onmessage = (event: MessageEvent) => { try { store.dispatch(messageReceived(JSON.parse(event.data))); diff --git a/src/store/websocketSlice.ts b/src/store/websocketSlice.ts index 4c19968..a46b7b0 100644 --- a/src/store/websocketSlice.ts +++ b/src/store/websocketSlice.ts @@ -4,11 +4,13 @@ import { LogMessage } from "../shared/types/LogMessage"; interface WebsocketState { messages: LogMessage[]; isConnected: boolean; + failedUrl: string | null; } const initialState: WebsocketState = { messages: [], isConnected: false, + failedUrl: null, }; const websocketSlice = createSlice({ @@ -31,10 +33,18 @@ const websocketSlice = createSlice({ messagesCleared(state) { state.messages = []; }, + /** Dispatched by the middleware when a connection attempt fails */ + connectionFailed(state, action: PayloadAction) { + state.failedUrl = action.payload; + }, + /** Dispatched by the middleware when a new connection attempt starts */ + connectionAttemptStarted(state) { + state.failedUrl = null; + }, }, }); -export const { connected, disconnected, messageReceived, messagesCleared } = +export const { connected, disconnected, messageReceived, messagesCleared, connectionFailed, connectionAttemptStarted } = websocketSlice.actions; export default websocketSlice.reducer; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 5f4f2c5..d207768 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorboundary.tsx","./src/features/errorbox.tsx","./src/features/initializationexceptions.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/functions/meetsminimumversion.ts","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorboundary.tsx","./src/features/errorbox.tsx","./src/features/initializationexceptions.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/tielineedge.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/functions/meetsminimumversion.ts","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file From b150ea41851deeaa8bfb72402af9c3cc85cbd271 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 16 Apr 2026 20:58:09 -0600 Subject: [PATCH 7/7] feat: Implement login functionality and API path management - Added LoginForm component for user authentication. - Introduced RequireAuth component to protect routes. - Created ApiPaths component to display available API paths. - Added ApiPathDetailDrawer for detailed view of selected API paths. - Updated routing to include login and API paths. - Integrated Redux for authentication state management. - Enhanced DebugConsole with device filtering and search capabilities. - Refactored TopNav to dynamically display available apps based on authentication. - Added selectors and slice for authentication and debug console state management. --- src/App.tsx | 49 ++++--- src/features/ApiPathDetailDrawer.tsx | 54 ++++++++ src/features/ApiPaths.tsx | 62 +++++++++ src/features/DebugConsole/DebugFilters.tsx | 44 +++--- .../DebugConsole/DeviceFilterDropdown.tsx | 111 +++++++++++++++ src/features/DebugConsole/debugConsts.ts | 9 ++ .../DebugConsole/hooks/useFilteredMessages.ts | 82 +++++------ src/features/LoginForm.tsx | 114 ++++++++++++++++ src/features/MobileControl.tsx | 6 +- src/features/RequireAuth.tsx | 26 ++++ src/features/Routing.tsx | 2 +- src/features/TopNav.tsx | 129 ++++-------------- src/store/apiSlice.ts | 35 +++++ src/store/auth/authSelectors.ts | 14 ++ src/store/auth/authSlice.ts | 26 ++++ .../debugConsole/debugConsoleSelectors.ts | 19 +++ src/store/debugConsole/debugConsoleSlice.ts | 46 +++++++ src/store/store.ts | 6 +- tsconfig.tsbuildinfo | 2 +- 19 files changed, 638 insertions(+), 198 deletions(-) create mode 100644 src/features/ApiPathDetailDrawer.tsx create mode 100644 src/features/ApiPaths.tsx create mode 100644 src/features/DebugConsole/DeviceFilterDropdown.tsx create mode 100644 src/features/LoginForm.tsx create mode 100644 src/features/RequireAuth.tsx create mode 100644 src/store/auth/authSelectors.ts create mode 100644 src/store/auth/authSlice.ts create mode 100644 src/store/debugConsole/debugConsoleSelectors.ts create mode 100644 src/store/debugConsole/debugConsoleSlice.ts diff --git a/src/App.tsx b/src/App.tsx index d55fb99..f150db5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,16 @@ import { Suspense } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Navigate, Route, Routes } from "react-router-dom"; +import { ApiPaths } from './features/ApiPaths'; import ConfigFile from "./features/ConfigFile"; import DebugConsole from "./features/DebugConsole/DebugConsole"; import DeviceList from "./features/DeviceList"; import ErrorBoundary from "./features/ErrorBoundary"; import InitializationExceptions from "./features/InitializationExceptions"; +import LoginForm from "./features/LoginForm"; import MainLayout from "./features/MainLayout"; import MobileControl from './features/MobileControl'; +import RequireAuth from "./features/RequireAuth"; import Routing from './features/Routing'; import Types from "./features/Types"; import Versions from "./features/Versions"; @@ -48,26 +51,34 @@ function App() { - } /> + } /> + }> + } /> + + }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + diff --git a/src/features/ApiPathDetailDrawer.tsx b/src/features/ApiPathDetailDrawer.tsx new file mode 100644 index 0000000..51fd591 --- /dev/null +++ b/src/features/ApiPathDetailDrawer.tsx @@ -0,0 +1,54 @@ +import { Offcanvas } from "react-bootstrap"; +import { Route } from "../store/apiSlice"; + +const ApiPathDetailDrawer = ({ + show, + route, + handleClose, + url, +}: ApiPathDetailDrawerProps) => { + if (!route) return null; + + return ( + + + API Path Detail + + +
+
+
Name
+ {route.Name} +
+ + {route.DataTokens?.Name && ( +
+
Data Token Name
+ {route.DataTokens.Name} +
+ )} +
+
+
+ ); +}; + +export default ApiPathDetailDrawer; + +interface ApiPathDetailDrawerProps { + show: boolean; + route: Route | undefined; + handleClose: () => void; + url: string; +} diff --git a/src/features/ApiPaths.tsx b/src/features/ApiPaths.tsx new file mode 100644 index 0000000..4fc88d1 --- /dev/null +++ b/src/features/ApiPaths.tsx @@ -0,0 +1,62 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useState } from 'react'; +import useAppParams from '../shared/hooks/useAppParams'; +import { Route, useGetPathsQuery } from '../store/apiSlice'; +import ApiPathDetailDrawer from './ApiPathDetailDrawer'; + + +export const ApiPaths = () => { + const { appId } = useAppParams(); + const { data: apiPathData, isLoading } = useGetPathsQuery(appId ? { appId } : skipToken); + const [showDrawer, setShowDrawer] = useState(false); + const [selectedRoute, setSelectedRoute] = useState(); + + if (isLoading) return
Loading...
; + + if (!apiPathData?.routes) return
No paths available
; + + const sorted = [...apiPathData.routes].sort((a, b) => a.Name.localeCompare(b.Name)); + + function clickRow(route: Route) { + setSelectedRoute(route); + setShowDrawer(true); + } + + function handleClose() { + setShowDrawer(false); + setSelectedRoute(undefined); + } + + return ( +
+

Available API Paths

+ + + + + + + + + {sorted.map((path) => ( + clickRow(path)} + className={'cursor-pointer hover' + (selectedRoute === path ? ' table-primary' : '')} + > + + + + ))} + +
NameURL
{path.Name}{path.Url}
+ + +
+ ); +} \ No newline at end of file diff --git a/src/features/DebugConsole/DebugFilters.tsx b/src/features/DebugConsole/DebugFilters.tsx index 0eb2e7b..36ae562 100644 --- a/src/features/DebugConsole/DebugFilters.tsx +++ b/src/features/DebugConsole/DebugFilters.tsx @@ -1,48 +1,44 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useMemo } from 'react'; -import { FilterClearButton } from '../../shared/FilterClearButton'; -import { FilterDropdownSearchParams } from '../../shared/FilterDropdownSearchParams'; +import { Button } from 'react-bootstrap'; import useAppParams from '../../shared/hooks/useAppParams'; import { IdLabel } from '../../shared/types/IdLabel'; import { useGetDevicesQuery } from '../../store/apiSlice'; -import { debugConsts, debugSearchParams, logLevelOpts } from "./debugConsts"; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +import { useAppDispatch } from '../../store/hooks'; +import { debugConsts } from './debugConsts'; +import { DeviceFilterDropdown } from './DeviceFilterDropdown'; export const DebugFilters = () => { const { appId } = useAppParams(); const { data: devices } = useGetDevicesQuery(appId ? { appId } : skipToken); + const dispatch = useAppDispatch(); const items = useMemo(() => { - if (!devices) return [{ id: debugConsts.GLOBAL, label: "Global"}]; + if (!devices) return [{ id: debugConsts.GLOBAL, label: 'Global' }]; - let fullList: IdLabel[] = [ - { id: debugConsts.GLOBAL, label: "Global"} - ]; + const deviceItems: IdLabel[] = devices + .map((d) => ({ id: d.Key, label: d.Name || d.Key })) + .sort((a, b) => a.label.localeCompare(b.label)); - devices.forEach((d) => { - fullList.push({ id: d.Key, label: d.Name}); - }); - - return fullList; + return [{ id: debugConsts.GLOBAL, label: 'Global' }, ...deviceItems]; }, [devices]); - if (!devices) return null; + if (!devices) return null; return (
- - - + +
); diff --git a/src/features/DebugConsole/DeviceFilterDropdown.tsx b/src/features/DebugConsole/DeviceFilterDropdown.tsx new file mode 100644 index 0000000..c14e72a --- /dev/null +++ b/src/features/DebugConsole/DeviceFilterDropdown.tsx @@ -0,0 +1,111 @@ +import { ChangeEvent, useState } from 'react'; +import { Badge } from 'react-bootstrap'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Form from 'react-bootstrap/Form'; +import { IconDarkChevronDown } from '../../shared/icons'; +import { IdLabel } from '../../shared/types/IdLabel'; +import { + selectCheckedDevices, + selectDeviceLevels, +} from '../../store/debugConsole/debugConsoleSelectors'; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { logLevelOpts } from './debugConsts'; + +interface DeviceFilterDropdownProps { + items: IdLabel[]; +} + +export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { + const dispatch = useAppDispatch(); + const checkedDevices = useAppSelector(selectCheckedDevices); + const deviceLevels = useAppSelector(selectDeviceLevels); + const [openLevelDropdowns, setOpenLevelDropdowns] = useState>({}); + + function handleCheckChange( + event: ChangeEvent, + deviceId: string + ) { + if (event.target.checked) { + dispatch(debugConsoleActions.checkDevice(deviceId)); + } else { + dispatch(debugConsoleActions.uncheckDevice(deviceId)); + } + } + + function handleLevelChange(deviceId: string, level: string) { + dispatch(debugConsoleActions.setDeviceLevel({ deviceId, level })); + setOpenLevelDropdowns((prev) => ({ ...prev, [deviceId]: false })); + } + + return ( + + + Devices + {checkedDevices.length > 0 && ( + + {checkedDevices.length} + + )} + + + + + {items.map((item) => { + const stringId = item.id.toString(); + const isChecked = checkedDevices.includes(stringId); + const currentLevel = deviceLevels[stringId] ?? 'Information'; + + return ( + e.stopPropagation()} + > + handleCheckChange(e, stringId)} + className="flex-grow-1 m-0" + /> + {isChecked && ( + + setOpenLevelDropdowns((prev) => ({ ...prev, [stringId]: isOpen })) + } + className="ms-auto" + > + + {currentLevel} + + + {logLevelOpts.map((opt) => ( + { + e.stopPropagation(); + handleLevelChange(stringId, opt.id.toString()); + }} + > + {opt.label} + + ))} + + )} + + ); + })} + + + ); +}; diff --git a/src/features/DebugConsole/debugConsts.ts b/src/features/DebugConsole/debugConsts.ts index c305d5e..4241db7 100644 --- a/src/features/DebugConsole/debugConsts.ts +++ b/src/features/DebugConsole/debugConsts.ts @@ -17,6 +17,15 @@ const DEBUG = "Debug"; const LOG_LEVELS = [ERROR, WARNING, INFORMATION, FATAL, VERBOSE, DEBUG]; +export const LOG_LEVEL_ORDER: Record = { + [VERBOSE]: 0, + [DEBUG]: 1, + [INFORMATION]: 2, + [WARNING]: 3, + [ERROR]: 4, + [FATAL]: 5, +}; + export const logLevelOpts: IdLabel[] = [ { id: FATAL, label: "Fatal" }, { id: ERROR, label: "Error" }, diff --git a/src/features/DebugConsole/hooks/useFilteredMessages.ts b/src/features/DebugConsole/hooks/useFilteredMessages.ts index a2e00d4..21c298e 100644 --- a/src/features/DebugConsole/hooks/useFilteredMessages.ts +++ b/src/features/DebugConsole/hooks/useFilteredMessages.ts @@ -1,51 +1,43 @@ import filter from 'lodash/filter'; import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { LogMessage } from '../../../shared/types/LogMessage'; -import { debugConsts } from '../debugConsts'; +import { + selectCheckedDevices, + selectDeviceLevels, + selectSearchText, +} from '../../../store/debugConsole/debugConsoleSelectors'; +import { useAppSelector } from '../../../store/hooks'; +import { debugConsts, LOG_LEVEL_ORDER } from '../debugConsts'; export function useFilteredMessages(listItems: LogMessage[]) { - const [searchParams] = useSearchParams(); + const checkedDevices = useAppSelector(selectCheckedDevices); + const deviceLevels = useAppSelector(selectDeviceLevels); + const searchText = useAppSelector(selectSearchText); return useMemo(() => { if (!listItems?.length) return []; - - const deviceValues = searchParams.getAll( - debugConsts.DEVICE - ); - const logLevelValues = searchParams.getAll( - debugConsts.LOG_LEVEL - ); - const searchText = searchParams.getAll(debugConsts.SEARCH_TEXT); - - // filter for other criteria const filtered = filter(listItems, (item) => { - - + // Device filter let deviceMatch = true; - // if (deviceValues.length) { - // deviceValues.forEach((val) => { - // // TODO: handle global - // // set deviceMatch to false if global and no key - // if((val === debugConsts.GLOBAL && item.Properties?.Key) || item.Properties?.Key !== val) - // deviceMatch = false; - - // }); - // } - - if (deviceValues.length) - deviceMatch = deviceValues.some((val) => { - if(!item.Properties?.Key) return val === debugConsts.GLOBAL; - return item.Properties?.Key === val; - }) + if (checkedDevices.length) { + deviceMatch = checkedDevices.some((val: string) => { + if (!item.Properties?.Key) return val === debugConsts.GLOBAL; + return item.Properties?.Key === val; + }); + } - let levelMatch = true; - if (logLevelValues.length) { - levelMatch = logLevelValues.includes(item.Level); + // Per-device minimum log level filter + let deviceLevelMatch = true; + const messageKey = item.Properties?.Key ?? debugConsts.GLOBAL; + const minLevel = deviceLevels[messageKey]; + if (minLevel !== undefined) { + const msgOrder = LOG_LEVEL_ORDER[item.Level] ?? -1; + const minOrder = LOG_LEVEL_ORDER[minLevel] ?? 0; + deviceLevelMatch = msgOrder >= minOrder; } - // Match search string on visible things + // Text search filter let textMatch = true; if (searchText.length) { const textMatchFields = [ @@ -53,19 +45,19 @@ export function useFilteredMessages(listItems: LogMessage[]) { item.Timestamp, item.Properties?.Key, ]; - // true if for every search text word, some field contains it - textMatch = searchText.every((st) => - textMatchFields.some((f) => f?.toLowerCase().includes(st.toLowerCase())) - ); + textMatch = searchText + .split(' ') + .filter(Boolean) + .every((st: string) => + textMatchFields.some((f) => + f?.toLowerCase().includes(st.toLowerCase()) + ) + ); } - return ( - deviceMatch && - levelMatch && - textMatch - ); // && otherMatches + + return deviceMatch && deviceLevelMatch && textMatch; }); - // DONE return filtered; - }, [searchParams, listItems]); + }, [listItems, checkedDevices, deviceLevels, searchText]); } diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx new file mode 100644 index 0000000..8e0d44f --- /dev/null +++ b/src/features/LoginForm.tsx @@ -0,0 +1,114 @@ +import { FormEvent, useState } from 'react'; +import { Alert, Button, Form, Spinner } from 'react-bootstrap'; +import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import useAppParams from '../shared/hooks/useAppParams'; +import { useSetLoginCredentialsMutation } from '../store/apiSlice'; +import { selectIsAuthenticated } from '../store/auth/authSelectors'; +import { authActions } from '../store/auth/authSlice'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; + +const ALL_APP_IDS = [ + 'app01', 'app02', 'app03', 'app04', 'app05', + 'app06', 'app07', 'app08', 'app09', 'app10', +]; + +const LoginForm = () => { + const { appId } = useAppParams(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const [setLoginCredentials] = useSetLoginCredentialsMutation(); + + const from = (location.state as { from?: Location })?.from?.pathname; + + const isValidAppId = appId && ALL_APP_IDS.includes(appId); + const probeAppId = isValidAppId ? appId : ALL_APP_IDS[0]; + + if (isAuthenticated) { + return ; + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + // First authenticate using the current (or first) appId to confirm credentials are valid + await setLoginCredentials({ appId: probeAppId, username, password }).unwrap(); + } catch { + setIsLoading(false); + setError('Invalid credentials. Please try again.'); + return; + } + + // Credentials are valid — now probe all slots in parallel to discover which are running + const results = await Promise.allSettled( + ALL_APP_IDS.map((id) => + setLoginCredentials({ appId: id, username, password }).unwrap() + ) + ); + + const availableApps = ALL_APP_IDS.filter( + (_, i) => results[i].status === 'fulfilled' + ); + + setIsLoading(false); + dispatch(authActions.loginSuccess(availableApps)); + + const destination = from ?? `/${availableApps[0] ?? probeAppId}/versions`; + navigate(destination, { replace: true }); + } + + return ( +
+
+

Sign In

+ {error && {error}} +
+ + Username + setUsername(e.target.value)} + required + disabled={isLoading} + /> + + + Password + setPassword(e.target.value)} + required + disabled={isLoading} + /> + + +
+
+
+ ); +}; + +export default LoginForm; diff --git a/src/features/MobileControl.tsx b/src/features/MobileControl.tsx index a8a81f0..c8750dd 100644 --- a/src/features/MobileControl.tsx +++ b/src/features/MobileControl.tsx @@ -116,12 +116,12 @@ const MobileControl = () => { # Room Key Touchpanel Key - + Token URL -
+