diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 922a71c..da8ce19 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..8805e5d 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -14,18 +14,26 @@ 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 { 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 +88,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 > 0) + .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 +237,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 +285,89 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); } +// ─── Signal path tracing ───────────────────────────────────────────────────── + +import type { MidpointRoute } from "../store/apiSlice"; + +/** + * 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 +382,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 +398,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 +563,56 @@ 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]); + + // 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 +624,7 @@ const Routing = () => { hideUnconnected, hiddenDevices, hideUnconnectedPorts, + sinkRoutes, ); setNodes( layoutNodes.map((n) => ({ @@ -302,6 +632,9 @@ const Routing = () => { data: { ...n.data, darkMode, + currentRoutes: midpointRoutes[n.id], + hasLayout: Boolean(layouts[n.id]), + onToggleLayoutPanel: () => toggleLayoutPanel(n.id), onHide: () => setHiddenDevices((prev) => { const next = new Set(prev); @@ -312,7 +645,7 @@ const Routing = () => { })), ); setEdges(layoutEdges); - setSelectedEdgeId(null); + setSelectedEdgeIds(new Set()); }, [ data, hiddenTypes, @@ -320,23 +653,32 @@ const Routing = () => { hiddenDevices, hideUnconnectedPorts, darkMode, + midpointRoutes, + sinkRoutes, + layouts, + resolveSourceName, + handleTileClick, setNodes, setEdges, ]); - // Re-style edges when selection changes without triggering a layout rebuild. + // 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 +688,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 +860,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 +1030,22 @@ const Routing = () => { Dark mode
+ + {routingWsConnected ? "Live" : "Offline"} + +
@@ -595,6 +1057,7 @@ const Routing = () => { nodes={nodes} edges={edges} onNodesChange={onNodesChange} + onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseLeave={onEdgeMouseLeave} @@ -614,6 +1077,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..82a149c 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,23 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { )} + {hasLayout && ( + + )} {onHide && (