diff --git a/package-lock.json b/package-lock.json index e1267c2..9585cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "essentials-web-config-app", - "version": "0.1.0", + "version": "1.0.0-local", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "essentials-web-config-app", - "version": "0.1.0", + "version": "1.0.0-local", "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", @@ -812,18 +812,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2380,21 +2368,6 @@ "d3-zoom": "^3.0.0" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2553,14 +2526,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3306,17 +3271,6 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4519,29 +4473,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4604,35 +4535,6 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, - "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5119,24 +5021,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "devOptional": true }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "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", diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 922a71c..10a460f 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -1,6 +1,7 @@ import { FormEvent, useState } from 'react'; -import { Alert, Button, Form, Spinner } from 'react-bootstrap'; +import { Alert, Button, Form, InputGroup, Spinner } from 'react-bootstrap'; import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import EyeIcon from '../shared/components/EyeIcon'; import useAppParams from '../shared/hooks/useAppParams'; import { useSetLoginCredentialsMutation } from '../store/apiSlice'; import { @@ -33,6 +34,7 @@ const LoginForm = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -102,14 +104,26 @@ const LoginForm = () => { Password - setPassword(e.target.value)} - required - disabled={isLoading} - /> + + setPassword(e.target.value)} + required + disabled={isLoading} + /> + + + +
+ +
+ + ); +}; + +export default MultiviewLayoutPanel; diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 17fd37b..a42e442 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -14,18 +14,27 @@ import { useNodesState, } from "@xyflow/react"; import dagre from "dagre"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Dropdown } from "react-bootstrap"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Dropdown } from "react-bootstrap"; import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; import useAppParams from "../shared/hooks/useAppParams"; import { + MidpointRoute, RoutingDevice, RoutingDevicesAndTieLines, + SinkRoute, TieLine, useGetRoutingDevicesAndTieLinesQuery, useGetVersionsQuery, } from "../store/apiSlice"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; +import { + ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, + routingSnapshotReceived, +} from "../store/routingFeedbackSlice"; +import MultiviewLayoutPanel, { MultiviewLayoutPanelPosition } from "./MultiviewLayoutPanel"; import styles from "./Routing.module.scss"; import RoutingDeviceNode, { HEADER_PX, @@ -80,16 +89,51 @@ function buildGraph( hideUnconnected: boolean, hiddenDevices: Set, hideUnconnectedPorts: boolean, + sinkRoutes: Record, ): { nodes: Node[]; edges: Edge[] } { - // Devices that appear in at least one *visible* tie line endpoint + // Live, tile-aware current-source feedback (sinkRoutes) reflects routes made via + // device-specific bulk APIs (e.g. ApplyDynamicLayout) that never create a real TieLine. Turn + // these into synthetic tie-line-shaped edges so they're visualized just like static wiring - + // but only where no real tie line already targets that exact device+port (a real tie line's + // active route is already covered by midpointRoutes/tie-line tracing). + const realTieLineDestinations = new Set( + data.tieLines.map((tl) => `${tl.destinationDeviceKey}:${tl.destinationPortKey}`), + ); + const deviceOutputPortKey = new Map( + data.devices + .filter((d) => (d.outputPorts ?? []).length === 1) + .map((d) => [d.key, d.outputPorts![0].key]), + ); + const syntheticTieLines: TieLine[] = Object.entries(sinkRoutes).flatMap( + ([deviceKey, routes]) => + routes + .filter((r) => !realTieLineDestinations.has(`${deviceKey}:${r.inputPortKey}`)) + .filter((r) => deviceOutputPortKey.has(r.sourceDeviceKey)) + .map((r) => ({ + sourceDeviceKey: r.sourceDeviceKey, + sourcePortKey: deviceOutputPortKey.get(r.sourceDeviceKey)!, + destinationDeviceKey: deviceKey, + destinationPortKey: r.inputPortKey, + signalType: r.signalType, + isInternal: false, + })), + ); + const allTieLines = [...data.tieLines, ...syntheticTieLines]; + const syntheticTieLineKeys = new Set( + syntheticTieLines.map( + (tl) => `${tl.sourceDeviceKey}|${tl.sourcePortKey}|${tl.destinationDeviceKey}|${tl.destinationPortKey}`, + ), + ); + + // Devices that appear in at least one *visible* tie line endpoint (real or synthetic) const connectedKeys = new Set( - data.tieLines + allTieLines .filter((tl) => !hiddenTypes.has(tl.signalType)) .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]), ); - // Tie lines that pass all active filters (used for port-level filtering) - const visibleTieLines = data.tieLines.filter( + // Tie lines (real or synthetic) that pass all active filters (used for port-level filtering) + const visibleTieLines = allTieLines.filter( (tl) => !hiddenTypes.has(tl.signalType) && !hiddenDevices.has(tl.sourceDeviceKey) && @@ -194,34 +238,44 @@ function buildGraph( }, ); - // 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 + // Map tieLines (real + synthetic sink-route edges) → 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[] = allTieLines .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 }, - data: { - signalColor: signalColor(tl.signalType), - sourceDeviceKey: tl.sourceDeviceKey, - sourcePortKey: tl.sourcePortKey, - destinationDeviceKey: tl.destinationDeviceKey, - destinationPortKey: tl.destinationPortKey, - signalType: tl.signalType, - }, - type: "tieLine", - animated: false, - })); + .map((tl, idx) => { + const isLiveOnly = syntheticTieLineKeys.has( + `${tl.sourceDeviceKey}|${tl.sourcePortKey}|${tl.destinationDeviceKey}|${tl.destinationPortKey}`, + ); + return { + 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, + ...(isLiveOnly ? { strokeDasharray: "6 4" } : {}), + }, + data: { + signalColor: signalColor(tl.signalType), + sourceDeviceKey: tl.sourceDeviceKey, + sourcePortKey: tl.sourcePortKey, + destinationDeviceKey: tl.destinationDeviceKey, + destinationPortKey: tl.destinationPortKey, + signalType: tl.signalType, + isLiveOnly, + }, + type: "tieLine", + animated: false, + }; + }); return { nodes, edges }; } @@ -232,6 +286,87 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); } +// ─── Signal path tracing ───────────────────────────────────────────────────── + +/** + * Given a clicked edge, traces the full signal path from source to sink through + * midpoint devices using midpointRoutes data. Returns a Set of edge IDs that + * form the continuous path. + */ +function traceSignalPath( + edges: Edge[], + clickedEdgeId: string, + midpointRoutes: Record, +): Set { + interface EdgeData { + sourceDeviceKey?: string; + sourcePortKey?: string; + destinationDeviceKey?: string; + destinationPortKey?: string; + } + + const result = new Set(); + const clickedEdge = edges.find((e) => e.id === clickedEdgeId); + if (!clickedEdge) return result; + + result.add(clickedEdgeId); + + const d = (e: Edge) => (e.data ?? {}) as EdgeData; + + // Build lookup maps: + // "deviceKey:portKey" → edge that ARRIVES at that input port + const edgeByDestPort = new Map(); + // "deviceKey:portKey" → edge that LEAVES from that output port + const edgeBySrcPort = new Map(); + + for (const e of edges) { + const ed = d(e); + const srcKey = `${ed.sourceDeviceKey}:${ed.sourcePortKey}`; + const dstKey = `${ed.destinationDeviceKey}:${ed.destinationPortKey}`; + edgeBySrcPort.set(srcKey, e); + edgeByDestPort.set(dstKey, e); + } + + // Trace upstream from the clicked edge's source + function traceUpstream(deviceKey: string | undefined, outputPortKey: string | undefined) { + if (!deviceKey || !outputPortKey) return; + const routes = midpointRoutes[deviceKey]; + if (!routes) return; + // Find ALL input ports that feed this output port + const matchingRoutes = routes.filter((r) => r.outputPortKey === outputPortKey); + for (const route of matchingRoutes) { + const incomingEdge = edgeByDestPort.get(`${deviceKey}:${route.inputPortKey}`); + if (!incomingEdge || result.has(incomingEdge.id)) continue; + result.add(incomingEdge.id); + const ed = d(incomingEdge); + traceUpstream(ed.sourceDeviceKey, ed.sourcePortKey); + } + } + + // Trace downstream from the clicked edge's destination + function traceDownstream(deviceKey: string | undefined, inputPortKey: string | undefined) { + if (!deviceKey || !inputPortKey) return; + const routes = midpointRoutes[deviceKey]; + if (!routes) return; + // Find ALL output ports that this input port feeds + const matchingRoutes = routes.filter((r) => r.inputPortKey === inputPortKey); + for (const route of matchingRoutes) { + const outgoingEdge = edgeBySrcPort.get(`${deviceKey}:${route.outputPortKey}`); + if (!outgoingEdge || result.has(outgoingEdge.id)) continue; + result.add(outgoingEdge.id); + const ed = d(outgoingEdge); + traceDownstream(ed.destinationDeviceKey, ed.destinationPortKey); + } + } + + // Start tracing in both directions from the clicked edge + const clickedData = d(clickedEdge); + traceUpstream(clickedData.sourceDeviceKey, clickedData.sourcePortKey); + traceDownstream(clickedData.destinationDeviceKey, clickedData.destinationPortKey); + + return result; +} + // ─── Node types registry (stable reference outside component) ──────────────── const nodeTypes: NodeTypes = { @@ -246,8 +381,14 @@ const edgeTypes: EdgeTypes = { const Routing = () => { const { appId } = useAppParams(); + const dispatch = useAppDispatch(); + const midpointRoutes = useAppSelector((s) => s.routingFeedback.midpointRoutes); + const sinkRoutes = useAppSelector((s) => s.routingFeedback.sinkRoutes); + const layouts = useAppSelector((s) => s.routingFeedback.layouts); + const routingWsConnected = useAppSelector((s) => s.routingFeedback.connected); + const failedUrls = useAppSelector((s) => s.routingFeedback.failedUrls); const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); - const { data, isLoading, isError } = useGetRoutingDevicesAndTieLinesQuery( + const { data, isLoading, isError, refetch } = useGetRoutingDevicesAndTieLinesQuery( appId ? { appId } : skipToken, ); @@ -256,9 +397,146 @@ const Routing = () => { const [hiddenDevices, setHiddenDevices] = useState>(new Set()); const [deviceSearch, setDeviceSearch] = useState(""); const [hideUnconnectedPorts, setHideUnconnectedPorts] = useState(false); - const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [selectedEdgeIds, setSelectedEdgeIds] = useState>(new Set()); const [darkMode, setDarkMode] = useState(true); + // Floating, freely-draggable multiview layout panels - independent of graph node positions + // (which move whenever dagre re-runs in response to routing/filter changes). Keyed by device + // key; presence in the record means the panel is open. Persists until explicitly closed. + const [layoutPanels, setLayoutPanels] = useState>({}); + // Tile number to highlight in each device's open layout panel, based on the current graph + // edge/path selection (see the selection-highlight effect below). + const [selectedTileNumberByDevice, setSelectedTileNumberByDevice] = useState>({}); + + const toggleLayoutPanel = useCallback((deviceKey: string) => { + setLayoutPanels((prev) => { + if (prev[deviceKey]) { + const next = { ...prev }; + delete next[deviceKey]; + return next; + } + // Cascade new panels so they don't stack exactly on top of one another. + const count = Object.keys(prev).length; + return { ...prev, [deviceKey]: { x: 40 + count * 24, y: 40 + count * 24 } }; + }); + }, []); + + const closeLayoutPanel = useCallback((deviceKey: string) => { + setLayoutPanels((prev) => { + if (!(deviceKey in prev)) return prev; + const next = { ...prev }; + delete next[deviceKey]; + return next; + }); + }, []); + + const moveLayoutPanel = useCallback((deviceKey: string, position: MultiviewLayoutPanelPosition) => { + setLayoutPanels((prev) => (prev[deviceKey] ? { ...prev, [deviceKey]: position } : prev)); + }, []); + + const isV3 = useMemo(() => { + const essentialsVersion = versions?.find( + (v) => v.Name === "PepperDashEssentials.dll", + )?.Version; + return essentialsVersion ? meetsMinVersion(essentialsVersion, "3.0.0") : false; + }, [versions]); + + // Seed routing feedback state from the HTTP response before the WebSocket connects + useEffect(() => { + if (!data || !isV3) return; + const midpoints: Record = {}; + + for (const group of data.currentRoutes ?? []) { + for (const route of group.routes) { + // Each step is a switching device in the route path + for (const step of route.steps) { + if (!midpoints[step.switchingDeviceKey]) { + midpoints[step.switchingDeviceKey] = []; + } + midpoints[step.switchingDeviceKey].push({ + inputPortKey: step.inputPortKey, + outputPortKey: step.outputPortKey, + signalType: group.signalType, + }); + } + } + } + + // Sink current sources come from sinkCurrentSources, read directly from each sink's own + // current-source bookkeeping on the backend - unlike currentRoutes, this also reflects routes + // made via device-specific bulk APIs (e.g. dynamic multiview layouts) that never create a + // RouteDescriptor/TieLine at all. A device implementing IRoutingSinkWithLayouts (e.g. a + // multiview decoder) can have multiple simultaneous tile routes under its one device key, so + // this is a list per device. + const sinks: Record = {}; + for (const source of data.sinkCurrentSources ?? []) { + if (!sinks[source.deviceKey]) { + sinks[source.deviceKey] = []; + } + sinks[source.deviceKey].push({ + inputPortKey: source.inputPortKey, + sourceDeviceKey: source.sourceDeviceKey, + signalType: source.signalType, + }); + } + + dispatch( + routingSnapshotReceived({ + type: "snapshot", + midpointRoutes: midpoints, + sinkRoutes: sinks, + layouts: data.multiviewLayouts ?? {}, + }), + ); + }, [data, isV3, dispatch]); + + // Fetches the routing feedback session URL from the API, then connects the WebSocket. Shared + // by the mount effect below and the manual refresh button. + const connectRoutingWebSocket = useCallback( + (onCancelledRef?: { current: boolean }) => { + if (!appId || !isV3) return; + const baseUrl = `/cws/${appId}/api/routingFeedbackSession`; + + fetch(baseUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((session: { url: string; fallbackUrl?: string }) => { + if (onCancelledRef?.current) return; + dispatch({ + type: ROUTING_WS_CONNECT, + payload: { url: session.url, fallbackUrl: session.fallbackUrl }, + }); + }) + .catch((err) => { + console.warn("[routing-ws] Failed to start feedback session:", err); + }); + }, + [appId, isV3, dispatch], + ); + + // Connect to routing feedback WebSocket when data is available (v3+ only) + useEffect(() => { + if (!appId || !isV3) return; + const cancelledRef = { current: false }; + connectRoutingWebSocket(cancelledRef); + + return () => { + cancelledRef.current = true; + dispatch({ type: ROUTING_WS_DISCONNECT }); + }; + }, [appId, isV3, dispatch, connectRoutingWebSocket]); + + // Manual refresh: reloads the routing devices/tie-lines snapshot over HTTP and forces the + // WebSocket to disconnect and reconnect (e.g. after the routing feedback server was restarted, + // or its connection got stuck). + const handleRefreshClick = useCallback(() => { + refetch(); + dispatch({ type: ROUTING_WS_DISCONNECT }); + connectRoutingWebSocket(); + }, [refetch, dispatch, connectRoutingWebSocket]); + const sortedDevices = useMemo( () => [...(data?.devices ?? [])].sort((a, b) => @@ -284,6 +562,68 @@ const Routing = () => { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); + // Keep a ref to current edges for path tracing without triggering re-renders + const edgesRef = useRef([]); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + // Keep refs to the latest feedback data so the layout effect can read current + // values without depending on them (and thus without re-running dagre on every + // WebSocket update). + const midpointRoutesRef = useRef(midpointRoutes); + useEffect(() => { + midpointRoutesRef.current = midpointRoutes; + }, [midpointRoutes]); + const layoutsRef = useRef(layouts); + useEffect(() => { + layoutsRef.current = layouts; + }, [layouts]); + + // Resolves a device key (e.g. a multiview tile's routed source) to a display name. + const deviceNameByKey = useMemo(() => { + const map = new Map(); + for (const d of data?.devices ?? []) { + map.set(d.key, d.name || d.key); + } + return map; + }, [data]); + const resolveSourceName = useCallback( + (key: string) => deviceNameByKey.get(key) ?? key, + [deviceNameByKey], + ); + + // Tile click (from a device node's layout popover): find the tie-line/synthetic edge feeding + // that tile (its destination port is qualified as "tile{N}:...", per RoutingGraphHelpers on the + // backend) and trace/highlight its signal path the same way clicking a graph edge/node does. + const handleTileClick = useCallback( + (deviceKey: string, tile: { tileNumber: number }) => { + const currentEdges = edgesRef.current; + const targetEdge = currentEdges.find((e) => { + const ed = e.data as + | { destinationDeviceKey?: string; destinationPortKey?: string } + | undefined; + return ( + ed?.destinationDeviceKey === deviceKey && + ed?.destinationPortKey?.startsWith(`tile${tile.tileNumber}:`) + ); + }); + + if (!targetEdge) { + setSelectedEdgeIds(new Set()); + return; + } + + const pathEdges = traceSignalPath(currentEdges, targetEdge.id, midpointRoutes); + setSelectedEdgeIds((prev) => { + const allMatch = + prev.size === pathEdges.size && [...prev].every((id) => pathEdges.has(id)); + return allMatch ? new Set() : pathEdges; + }); + }, + [midpointRoutes], + ); + // 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. @@ -295,6 +635,7 @@ const Routing = () => { hideUnconnected, hiddenDevices, hideUnconnectedPorts, + sinkRoutes, ); setNodes( layoutNodes.map((n) => ({ @@ -302,6 +643,9 @@ const Routing = () => { data: { ...n.data, darkMode, + currentRoutes: midpointRoutesRef.current[n.id], + hasLayout: Boolean(layoutsRef.current[n.id]), + onToggleLayoutPanel: () => toggleLayoutPanel(n.id), onHide: () => setHiddenDevices((prev) => { const next = new Set(prev); @@ -312,7 +656,7 @@ const Routing = () => { })), ); setEdges(layoutEdges); - setSelectedEdgeId(null); + setSelectedEdgeIds(new Set()); }, [ data, hiddenTypes, @@ -320,23 +664,45 @@ const Routing = () => { hiddenDevices, hideUnconnectedPorts, darkMode, + sinkRoutes, + resolveSourceName, + handleTileClick, setNodes, setEdges, ]); - // Re-style edges when selection changes without triggering a layout rebuild. + // Keep node data's currentRoutes/hasLayout fresh as feedback arrives, without + // re-running dagre or resetting the current selection. + useEffect(() => { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + currentRoutes: midpointRoutes[n.id], + hasLayout: Boolean(layouts[n.id]), + }, + })), + ); + }, [midpointRoutes, layouts, setNodes]); + + // Re-style edges and update node highlights when selection changes. useEffect(() => { setEdges((eds) => eds.map((e) => { const baseColor = (e.data as { signalColor?: string } | undefined)?.signalColor ?? FALLBACK_COLOR; - const isSelected = selectedEdgeId !== null && e.id === selectedEdgeId; - if (selectedEdgeId === null) { + const isLiveOnly = Boolean( + (e.data as { isLiveOnly?: boolean } | undefined)?.isLiveOnly, + ); + const dash = isLiveOnly ? { strokeDasharray: "6 4" } : {}; + const isSelected = selectedEdgeIds.size > 0 && selectedEdgeIds.has(e.id); + if (selectedEdgeIds.size === 0) { return { ...e, data: { ...e.data, selected: false }, - style: { stroke: baseColor, strokeWidth: 1.5 }, + style: { stroke: baseColor, strokeWidth: 1.5, ...dash }, }; } return { @@ -346,19 +712,103 @@ const Routing = () => { stroke: isSelected ? baseColor : "#ccc", strokeWidth: isSelected ? 3.5 : 1.5, opacity: isSelected ? 1 : 0.35, + ...dash, }, }; }), ); - }, [selectedEdgeId, setEdges]); + // Compute which internal routes on each midpoint node are part of the path + if (selectedEdgeIds.size === 0) { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { ...n.data, highlightedRouteKeys: null }, + })), + ); + setSelectedTileNumberByDevice({}); + } else { + const selectedDestPorts = new Set(); + const selectedSrcPorts = new Set(); + const selectedTileNumberByDevice = new Map(); + for (const e of edgesRef.current) { + if (selectedEdgeIds.has(e.id)) { + const ed = e.data as { sourceDeviceKey?: string; sourcePortKey?: string; destinationDeviceKey?: string; destinationPortKey?: string } | undefined; + if (ed?.destinationDeviceKey && ed?.destinationPortKey) { + selectedDestPorts.add(`${ed.destinationDeviceKey}:${ed.destinationPortKey}`); + const tileMatch = /^tile(\d+):/.exec(ed.destinationPortKey); + if (tileMatch) { + selectedTileNumberByDevice.set(ed.destinationDeviceKey, Number(tileMatch[1])); + } + } + if (ed?.sourceDeviceKey && ed?.sourcePortKey) { + selectedSrcPorts.add(`${ed.sourceDeviceKey}:${ed.sourcePortKey}`); + } + } + } + + setNodes((nds) => + nds.map((n) => { + const routes = midpointRoutes[n.id]; + if (!routes || routes.length === 0) { + return { ...n, data: { ...n.data, highlightedRouteKeys: new Set() } }; + } + const highlighted = new Set(); + for (const r of routes) { + const inputInPath = selectedDestPorts.has(`${n.id}:${r.inputPortKey}`); + const outputInPath = selectedSrcPorts.has(`${n.id}:${r.outputPortKey}`); + if (inputInPath && outputInPath) { + highlighted.add(`${r.inputPortKey}:${r.outputPortKey}`); + } + } + return { ...n, data: { ...n.data, highlightedRouteKeys: highlighted } }; + }), + ); + setSelectedTileNumberByDevice(Object.fromEntries(selectedTileNumberByDevice)); + } + }, [selectedEdgeIds, setEdges, setNodes, midpointRoutes]); + + // Edge click: highlight only the single clicked edge const onEdgeClick = useCallback( (_: React.MouseEvent, edge: Edge) => - setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id)), + setSelectedEdgeIds((prev) => { + if (prev.has(edge.id)) return new Set(); + return new Set([edge.id]); + }), [], ); - const onPaneClick = useCallback(() => setSelectedEdgeId(null), []); + // Node click: trace all signal paths through/from/to the clicked device + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + const currentEdges = edgesRef.current; + const allPathEdges = new Set(); + + // Find all edges connected to this device and trace each one + for (const e of currentEdges) { + const ed = e.data as { sourceDeviceKey?: string; destinationDeviceKey?: string } | undefined; + if (ed?.sourceDeviceKey === node.id || ed?.destinationDeviceKey === node.id) { + const pathFromEdge = traceSignalPath(currentEdges, e.id, midpointRoutes); + for (const id of pathFromEdge) { + allPathEdges.add(id); + } + } + } + + setSelectedEdgeIds((prev) => { + // Toggle off if clicking the same node again + if (prev.size > 0 && allPathEdges.size > 0) { + const prevArr = [...prev]; + const allMatch = prevArr.every((id) => allPathEdges.has(id)) && prevArr.length === allPathEdges.size; + if (allMatch) return new Set(); + } + return allPathEdges; + }); + }, + [midpointRoutes], + ); + + const onPaneClick = useCallback(() => setSelectedEdgeIds(new Set()), []); const onEdgeMouseEnter = useCallback( (_: React.MouseEvent, edge: Edge) => @@ -434,8 +884,28 @@ const Routing = () => { ); } + const certUrls = failedUrls.length > 0 + ? [...new Set(failedUrls.map((u: string) => + new URL(u).origin.replace(/^wss:/, "https:").replace(/^ws:/, "http:"), + ))] + : null; + return (
+ {certUrls && certUrls.length > 0 && ( + + Live feedback connection failed. The routing feedback server may have an untrusted certificate.{' '} + {certUrls.map((certUrl, i) => ( + + {i > 0 && ' or '} + + Open {certUrl} + + + ))} + {' in a new tab, accept the certificate, then reload this page.'} + + )} {/* Signal type filter bar */}
@@ -584,6 +1054,22 @@ const Routing = () => { Dark mode
+ + {routingWsConnected ? "Live" : "Offline"} + +
@@ -595,6 +1081,7 @@ const Routing = () => { nodes={nodes} edges={edges} onNodesChange={onNodesChange} + onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseLeave={onEdgeMouseLeave} @@ -614,6 +1101,27 @@ const Routing = () => { + + {/* Floating multiview layout panels - siblings of the React Flow canvas (not graph + nodes), so their position is independent of node positions/dagre re-layout. */} + {Object.entries(layoutPanels).map(([deviceKey, position]) => { + const layout = layouts[deviceKey]; + if (!layout) return null; + return ( + handleTileClick(deviceKey, tile)} + onClose={() => closeLayoutPanel(deviceKey)} + onMove={(nextPosition) => moveLayoutPanel(deviceKey, nextPosition)} + /> + ); + })} ); diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss index b46bb98..e9c6874 100644 --- a/src/features/RoutingDeviceNode.module.scss +++ b/src/features/RoutingDeviceNode.module.scss @@ -35,6 +35,22 @@ } } +.layoutToggleBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 2px; + margin-top: 2px; + margin-left: 2px; + line-height: 1; + color: #555; + cursor: pointer; + + &:hover { + color: #0d6efd; + } +} + .nodeKeyLabel { font-size: 0.65rem; } @@ -59,6 +75,7 @@ .portLabelWrap { position: relative; + z-index: 2; max-width: 46%; min-width: 0; @@ -72,6 +89,13 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + background-color: #fff; + padding: 0 3px; + border-radius: 2px; + + .portRowDark & { + background-color: #1e1e1e; + } } .portTooltip { @@ -90,3 +114,11 @@ opacity: 0; transition: opacity 0.12s; } + +.internalRouteSvg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; +} diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 78fc558..db4326f 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -1,18 +1,45 @@ import { Handle, NodeProps, Position } from "@xyflow/react"; -import { RoutingDevice } from "../store/apiSlice"; + +import { MidpointRoute, RoutingDevice } from "../store/apiSlice"; import styles from "./RoutingDeviceNode.module.scss"; +const SIGNAL_COLORS: Record = { + AudioVideo: "#6f42c1", + Video: "#0d6efd", + Audio: "#dc3545", + "Audio, SecondaryAudio": "#dc3545", + "UsbOutput, UsbInput": "#fd7e14", + UsbOutput: "#fd7e14", + UsbInput: "#fd7e14", +}; +const FALLBACK_COLOR = "#adb5bd"; + export type RoutingDeviceNodeData = { device: RoutingDevice; onHide?: () => void; darkMode?: boolean; + currentRoutes?: MidpointRoute[]; + /** When edges are selected, contains the set of "inputPortKey:outputPortKey" pairs on this node that are part of the path. null = no selection active. */ + highlightedRouteKeys?: Set | null; + /** Whether this device has an active multiview canvas/tile layout (IRoutingSinkWithLayoutState). */ + hasLayout?: boolean; + /** Called when the layout toggle button is clicked - shows/hides this device's floating layout panel (see Routing.tsx). */ + onToggleLayoutPanel?: () => void; }; const PORT_ROW_PX = 28; const HEADER_PX = 38; const RoutingDeviceNode = ({ data }: NodeProps) => { - const { device, onHide, darkMode } = data as RoutingDeviceNodeData; + const { + device, + onHide, + darkMode, + currentRoutes, + highlightedRouteKeys, + hasLayout, + onToggleLayoutPanel, + } = data as RoutingDeviceNodeData; const inputPorts = device.inputPorts ?? []; const outputPorts = device.outputPorts ?? []; const portRows = Math.max(inputPorts.length, outputPorts.length, 1); @@ -36,6 +63,24 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { )} + {hasLayout && ( + + )} {onHide && (