From 4382406f5eb77ccdb6d1c0c03f53f2f7e15771c7 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 22 Jun 2026 21:25:11 -0600 Subject: [PATCH 01/15] feat: implement live routing feedback with WebSocket support and signal path tracing --- src/features/Routing.tsx | 236 ++++++++++++++++++++- src/features/RoutingDeviceNode.module.scss | 16 ++ src/features/RoutingDeviceNode.tsx | 59 +++++- src/store/apiSlice.ts | 58 +++++ src/store/routingFeedbackMiddleware.ts | 129 +++++++++++ src/store/routingFeedbackSlice.ts | 87 ++++++++ src/store/store.ts | 4 + 7 files changed, 578 insertions(+), 11 deletions(-) create mode 100644 src/store/routingFeedbackMiddleware.ts create mode 100644 src/store/routingFeedbackSlice.ts diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 17fd37b..549e5a3 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -14,7 +14,7 @@ import { useNodesState, } from "@xyflow/react"; import dagre from "dagre"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dropdown } from "react-bootstrap"; import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; @@ -26,6 +26,11 @@ import { useGetRoutingDevicesAndTieLinesQuery, useGetVersionsQuery, } from "../store/apiSlice"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; +import { + ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, +} from "../store/routingFeedbackSlice"; import styles from "./Routing.module.scss"; import RoutingDeviceNode, { HEADER_PX, @@ -232,6 +237,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,6 +334,9 @@ const edgeTypes: EdgeTypes = { const Routing = () => { const { appId } = useAppParams(); + const dispatch = useAppDispatch(); + const midpointRoutes = useAppSelector((s) => s.routingFeedback.midpointRoutes); + const routingWsConnected = useAppSelector((s) => s.routingFeedback.connected); const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); const { data, isLoading, isError } = useGetRoutingDevicesAndTieLinesQuery( appId ? { appId } : skipToken, @@ -256,9 +347,45 @@ 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); + const isV3 = useMemo(() => { + const essentialsVersion = versions?.find( + (v) => v.Name === "PepperDashEssentials.dll", + )?.Version; + return essentialsVersion ? meetsMinVersion(essentialsVersion, "3.0.0") : false; + }, [versions]); + + // Connect to routing feedback WebSocket when data is available (v3+ only) + useEffect(() => { + if (!appId || !isV3) return; + // Fetch the routing feedback session URL from the API, then connect + const baseUrl = `/cws/${appId}/api/routingFeedbackSession`; + let cancelled = false; + + fetch(baseUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((session: { url: string; fallbackUrl?: string }) => { + if (cancelled) 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); + }); + + return () => { + cancelled = true; + dispatch({ type: ROUTING_WS_DISCONNECT }); + }; + }, [appId, isV3, dispatch]); + const sortedDevices = useMemo( () => [...(data?.devices ?? [])].sort((a, b) => @@ -302,6 +429,7 @@ const Routing = () => { data: { ...n.data, darkMode, + currentRoutes: midpointRoutes[n.id], onHide: () => setHiddenDevices((prev) => { const next = new Set(prev); @@ -312,7 +440,7 @@ const Routing = () => { })), ); setEdges(layoutEdges); - setSelectedEdgeId(null); + setSelectedEdgeIds(new Set()); }, [ data, hiddenTypes, @@ -320,19 +448,20 @@ const Routing = () => { hiddenDevices, hideUnconnectedPorts, darkMode, + midpointRoutes, 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 isSelected = selectedEdgeIds.size > 0 && selectedEdgeIds.has(e.id); + if (selectedEdgeIds.size === 0) { return { ...e, data: { ...e.data, selected: false }, @@ -350,15 +479,97 @@ const Routing = () => { }; }), ); - }, [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 }, + })), + ); + } else { + const selectedDestPorts = new Set(); + const selectedSrcPorts = new Set(); + 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}`); + } + 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 } }; + }), + ); + } + }, [selectedEdgeIds, setEdges, setNodes, midpointRoutes]); + + // Keep a ref to current edges for path tracing without triggering re-renders + const edgesRef = useRef([]); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + // 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) => @@ -584,6 +795,12 @@ const Routing = () => { Dark mode + + {routingWsConnected ? "Live" : "Offline"} + @@ -595,6 +812,7 @@ const Routing = () => { nodes={nodes} edges={edges} onNodesChange={onNodesChange} + onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseLeave={onEdgeMouseLeave} diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss index b46bb98..9c2ef98 100644 --- a/src/features/RoutingDeviceNode.module.scss +++ b/src/features/RoutingDeviceNode.module.scss @@ -59,6 +59,7 @@ .portLabelWrap { position: relative; + z-index: 2; max-width: 46%; min-width: 0; @@ -72,6 +73,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 +98,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..4238679 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -1,18 +1,32 @@ 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; }; 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 } = data as RoutingDeviceNodeData; const inputPorts = device.inputPorts ?? []; const outputPorts = device.outputPorts ?? []; const portRows = Math.max(inputPorts.length, outputPorts.length, 1); @@ -94,6 +108,47 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { ); })} + {/* Internal route SVG overlay */} + {currentRoutes && currentRoutes.length > 0 && ( + + {currentRoutes.map((route, idx) => { + const inIdx = inputPorts.findIndex((p) => p.key === route.inputPortKey); + const outIdx = outputPorts.findIndex((p) => p.key === route.outputPortKey); + if (inIdx === -1 || outIdx === -1) return null; + + const inY = ((inIdx + 0.5) / portRows) * bodyHeight; + const outY = ((outIdx + 0.5) / portRows) * bodyHeight; + const color = SIGNAL_COLORS[route.signalType] ?? FALLBACK_COLOR; + + // Determine if this route is highlighted or dimmed + const routeKey = `${route.inputPortKey}:${route.outputPortKey}`; + const isHighlighted = highlightedRouteKeys == null || highlightedRouteKeys.has(routeKey); + + // Bezier control points for a smooth S-curve + const x1 = 24; + const x2 = 256; + const cx1 = x1 + (x2 - x1) * 0.4; + const cx2 = x2 - (x2 - x1) * 0.4; + + return ( + + ); + })} + + )} + {/* Output port handles (right side) */} {outputPorts.map((port, i) => { const topPct = ((i + 0.5) / portRows) * 100; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 3f6bbf8..9e1c98f 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -459,11 +459,69 @@ export interface TieLine { isInternal: boolean; } +export interface RouteSwitchStepInfo { + switchingDeviceKey: string; + inputPortKey: string; + outputPortKey: string; +} + +export interface ActiveRouteInfo { + sourceDeviceKey: string; + destinationDeviceKey: string; + destinationInputPortKey: string; + steps: RouteSwitchStepInfo[]; +} + +export interface CurrentRouteGroupInfo { + signalType: string; + routes: ActiveRouteInfo[]; +} + export interface RoutingDevicesAndTieLines { devices: RoutingDevice[]; tieLines: TieLine[]; + currentRoutes: CurrentRouteGroupInfo[]; +} + +// ─── Live routing feedback (WebSocket) types ───────────────────────────────── + +export interface MidpointRoute { + inputPortKey: string; + outputPortKey: string; + signalType: string; } +export interface SinkRoute { + inputPortKey: string; + sourceDeviceKey: string; + signalType: string; +} + +export interface RoutingSnapshotMessage { + type: "snapshot"; + midpointRoutes: Record; + sinkRoutes: Record; +} + +export interface MidpointRouteChangedMessage { + type: "midpointRouteChanged"; + deviceKey: string; + routes: MidpointRoute[]; +} + +export interface SinkInputChangedMessage { + type: "sinkInputChanged"; + deviceKey: string; + inputPortKey: string; + sourceDeviceKey: string; + signalType: string; +} + +export type RoutingFeedbackMessage = + | RoutingSnapshotMessage + | MidpointRouteChangedMessage + | SinkInputChangedMessage; + export type LogEventLevel = | "Verbose" | "Debug" diff --git a/src/store/routingFeedbackMiddleware.ts b/src/store/routingFeedbackMiddleware.ts new file mode 100644 index 0000000..49380c2 --- /dev/null +++ b/src/store/routingFeedbackMiddleware.ts @@ -0,0 +1,129 @@ +import { Middleware } from "@reduxjs/toolkit"; +import { RoutingFeedbackMessage } from "./apiSlice"; +import { + midpointRouteChanged, + ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, + routingFeedbackReset, + routingSnapshotReceived, + RoutingWsConnectAction, + routingWsConnected, + routingWsConnectionFailed, + RoutingWsDisconnectAction, + routingWsDisconnected, + sinkInputChanged, +} from "./routingFeedbackSlice"; + +const RECONNECT_DELAY_MS = 3000; +const MAX_RECONNECT_ATTEMPTS = 5; + +export const routingFeedbackMiddleware: Middleware = (store) => { + let socket: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempts = 0; + let currentUrl: string | null = null; + let fallbackUrl: string | null = null; + + function cleanup() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.close(); + socket = null; + } + reconnectAttempts = 0; + currentUrl = null; + fallbackUrl = null; + } + + function connectToUrl(url: string, fallback?: string) { + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.close(); + socket = null; + } + + currentUrl = url; + socket = new WebSocket(url); + + socket.onopen = () => { + reconnectAttempts = 0; + store.dispatch(routingWsConnected()); + }; + + socket.onclose = () => { + store.dispatch(routingWsDisconnected()); + socket = null; + attemptReconnect(); + }; + + socket.onerror = (err) => { + console.error("[routing-ws] WebSocket error", err); + if (fallback) { + console.log("[routing-ws] Primary connection failed, falling back to", fallback); + connectToUrl(fallback); + } + }; + + socket.onmessage = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data) as RoutingFeedbackMessage; + switch (msg.type) { + case "snapshot": + store.dispatch(routingSnapshotReceived(msg)); + break; + case "midpointRouteChanged": + store.dispatch(midpointRouteChanged(msg)); + break; + case "sinkInputChanged": + store.dispatch(sinkInputChanged(msg)); + break; + } + } catch (e) { + console.error("[routing-ws] Failed to parse message", e); + } + }; + } + + function attemptReconnect() { + if (!currentUrl) return; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + store.dispatch(routingWsConnectionFailed(currentUrl)); + return; + } + reconnectAttempts++; + reconnectTimer = setTimeout(() => { + if (currentUrl) connectToUrl(currentUrl); + }, RECONNECT_DELAY_MS); + } + + return (next) => (action) => { + const { type } = action as RoutingWsConnectAction | RoutingWsDisconnectAction; + + if (type === ROUTING_WS_CONNECT) { + const { url, fallbackUrl: fb } = (action as RoutingWsConnectAction).payload; + cleanup(); + fallbackUrl = fb ?? null; + connectToUrl(url, fallbackUrl ?? undefined); + return; + } + + if (type === ROUTING_WS_DISCONNECT) { + cleanup(); + store.dispatch(routingWsDisconnected()); + store.dispatch(routingFeedbackReset()); + return; + } + + return next(action); + }; +}; diff --git a/src/store/routingFeedbackSlice.ts b/src/store/routingFeedbackSlice.ts new file mode 100644 index 0000000..74179a4 --- /dev/null +++ b/src/store/routingFeedbackSlice.ts @@ -0,0 +1,87 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { + MidpointRoute, + MidpointRouteChangedMessage, + RoutingSnapshotMessage, + SinkInputChangedMessage, + SinkRoute, +} from "./apiSlice"; + +export interface RoutingFeedbackState { + midpointRoutes: Record; + sinkRoutes: Record; + connected: boolean; + failedUrl: string | null; +} + +const initialState: RoutingFeedbackState = { + midpointRoutes: {}, + sinkRoutes: {}, + connected: false, + failedUrl: null, +}; + +const routingFeedbackSlice = createSlice({ + name: "routingFeedback", + initialState, + reducers: { + routingWsConnected(state) { + state.connected = true; + state.failedUrl = null; + }, + routingWsDisconnected(state) { + state.connected = false; + }, + routingWsConnectionFailed(state, action: PayloadAction) { + state.failedUrl = action.payload; + }, + routingSnapshotReceived( + state, + action: PayloadAction, + ) { + state.midpointRoutes = action.payload.midpointRoutes; + state.sinkRoutes = action.payload.sinkRoutes; + }, + midpointRouteChanged( + state, + action: PayloadAction, + ) { + state.midpointRoutes[action.payload.deviceKey] = action.payload.routes; + }, + sinkInputChanged(state, action: PayloadAction) { + state.sinkRoutes[action.payload.deviceKey] = { + inputPortKey: action.payload.inputPortKey, + sourceDeviceKey: action.payload.sourceDeviceKey, + signalType: action.payload.signalType, + }; + }, + routingFeedbackReset() { + return initialState; + }, + }, +}); + +export const { + routingWsConnected, + routingWsDisconnected, + routingWsConnectionFailed, + routingSnapshotReceived, + midpointRouteChanged, + sinkInputChanged, + routingFeedbackReset, +} = routingFeedbackSlice.actions; + +export default routingFeedbackSlice.reducer; + +// ── Action type constants used by the middleware ───────────────────────────── +export const ROUTING_WS_CONNECT = "routingFeedback/wsConnect"; +export const ROUTING_WS_DISCONNECT = "routingFeedback/wsDisconnect"; + +export interface RoutingWsConnectAction { + type: typeof ROUTING_WS_CONNECT; + payload: { url: string; fallbackUrl?: string }; +} + +export interface RoutingWsDisconnectAction { + type: typeof ROUTING_WS_DISCONNECT; +} diff --git a/src/store/store.ts b/src/store/store.ts index efabe87..33f0bc3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -3,6 +3,8 @@ import { oneSliceToRuleThemAll } from './apiSlice'; import { authReducer } from './auth/authSlice'; import { commonUiReducer } from './commonUi/commonUiSlice'; import { debugConsoleReducer } from './debugConsole/debugConsoleSlice'; +import { routingFeedbackMiddleware } from './routingFeedbackMiddleware'; +import routingFeedbackReducer from './routingFeedbackSlice'; import { websocketMiddleware } from './websocketMiddleware'; import websocketReducer from './websocketSlice'; @@ -13,6 +15,7 @@ const allReducers = combineReducers({ commonUi: commonUiReducer, debugConsole: debugConsoleReducer, websocket: websocketReducer, + routingFeedback: routingFeedbackReducer, }) const rootReducer: Reducer = (state: RootState, action: AnyAction) => { @@ -27,6 +30,7 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware() .concat(oneSliceToRuleThemAll.apiSlice.middleware) .concat(websocketMiddleware) + .concat(routingFeedbackMiddleware) }); export type RootState = ReturnType; From 308bb4a2fccf1ef9007466bbd917c1eade9df79b Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 22 Jun 2026 21:30:44 -0600 Subject: [PATCH 02/15] feat: seed routing feedback state from HTTP response before WebSocket connection --- src/features/Routing.tsx | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 549e5a3..3e76730 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -30,6 +30,7 @@ import { useAppDispatch, useAppSelector } from "../store/hooks"; import { ROUTING_WS_CONNECT, ROUTING_WS_DISCONNECT, + routingSnapshotReceived, } from "../store/routingFeedbackSlice"; import styles from "./Routing.module.scss"; import RoutingDeviceNode, { @@ -357,6 +358,45 @@ const Routing = () => { return essentialsVersion ? meetsMinVersion(essentialsVersion, "3.0.0") : false; }, [versions]); + // Seed routing feedback state from the HTTP response before the WebSocket connects + useEffect(() => { + if (!data?.currentRoutes || !isV3) return; + const midpoints: Record = {}; + const sinks: 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, + }); + } + // The destination device is a sink + if (route.destinationDeviceKey && route.destinationInputPortKey) { + sinks[route.destinationDeviceKey] = { + inputPortKey: route.destinationInputPortKey, + sourceDeviceKey: route.sourceDeviceKey, + signalType: group.signalType, + }; + } + } + } + + dispatch( + routingSnapshotReceived({ + type: "snapshot", + midpointRoutes: midpoints, + sinkRoutes: sinks, + }), + ); + }, [data?.currentRoutes, isV3, dispatch]); + // Connect to routing feedback WebSocket when data is available (v3+ only) useEffect(() => { if (!appId || !isV3) return; From 2804c3087b849a1fd3bb58780ff3b48165cbcdef Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 22 Jun 2026 22:48:07 -0600 Subject: [PATCH 03/15] feat: update routing feedback state to handle multiple failed URLs and display alert for untrusted certificates --- src/features/Routing.tsx | 23 ++++++++++++++++++++++- src/store/routingFeedbackMiddleware.ts | 6 +++++- src/store/routingFeedbackSlice.ts | 10 +++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 3e76730..8315b09 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -15,7 +15,7 @@ import { } from "@xyflow/react"; import dagre from "dagre"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Dropdown } from "react-bootstrap"; +import { Alert, Dropdown } from "react-bootstrap"; import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; import useAppParams from "../shared/hooks/useAppParams"; @@ -338,6 +338,7 @@ const Routing = () => { const dispatch = useAppDispatch(); const midpointRoutes = useAppSelector((s) => s.routingFeedback.midpointRoutes); 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( appId ? { appId } : skipToken, @@ -685,8 +686,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 */}
diff --git a/src/store/routingFeedbackMiddleware.ts b/src/store/routingFeedbackMiddleware.ts index 49380c2..3e737f2 100644 --- a/src/store/routingFeedbackMiddleware.ts +++ b/src/store/routingFeedbackMiddleware.ts @@ -23,6 +23,7 @@ export const routingFeedbackMiddleware: Middleware = (store) => { let reconnectAttempts = 0; let currentUrl: string | null = null; let fallbackUrl: string | null = null; + let attemptedUrls: string[] = []; function cleanup() { if (reconnectTimer) { @@ -40,6 +41,7 @@ export const routingFeedbackMiddleware: Middleware = (store) => { reconnectAttempts = 0; currentUrl = null; fallbackUrl = null; + attemptedUrls = []; } function connectToUrl(url: string, fallback?: string) { @@ -53,10 +55,12 @@ export const routingFeedbackMiddleware: Middleware = (store) => { } currentUrl = url; + if (!attemptedUrls.includes(url)) attemptedUrls.push(url); socket = new WebSocket(url); socket.onopen = () => { reconnectAttempts = 0; + attemptedUrls = []; store.dispatch(routingWsConnected()); }; @@ -97,7 +101,7 @@ export const routingFeedbackMiddleware: Middleware = (store) => { function attemptReconnect() { if (!currentUrl) return; if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - store.dispatch(routingWsConnectionFailed(currentUrl)); + store.dispatch(routingWsConnectionFailed([...attemptedUrls])); return; } reconnectAttempts++; diff --git a/src/store/routingFeedbackSlice.ts b/src/store/routingFeedbackSlice.ts index 74179a4..9c5ca2a 100644 --- a/src/store/routingFeedbackSlice.ts +++ b/src/store/routingFeedbackSlice.ts @@ -11,14 +11,14 @@ export interface RoutingFeedbackState { midpointRoutes: Record; sinkRoutes: Record; connected: boolean; - failedUrl: string | null; + failedUrls: string[]; } const initialState: RoutingFeedbackState = { midpointRoutes: {}, sinkRoutes: {}, connected: false, - failedUrl: null, + failedUrls: [], }; const routingFeedbackSlice = createSlice({ @@ -27,13 +27,13 @@ const routingFeedbackSlice = createSlice({ reducers: { routingWsConnected(state) { state.connected = true; - state.failedUrl = null; + state.failedUrls = []; }, routingWsDisconnected(state) { state.connected = false; }, - routingWsConnectionFailed(state, action: PayloadAction) { - state.failedUrl = action.payload; + routingWsConnectionFailed(state, action: PayloadAction) { + state.failedUrls = action.payload; }, routingSnapshotReceived( state, From 479080df2e7c978ec232ba36b486688912a3d855 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 14 Jul 2026 19:34:10 -0600 Subject: [PATCH 04/15] feat: enhance LoginForm with password visibility toggle and add EyeIcon component feat: update Routing to support synthetic tie lines for live routing feedback feat: modify Versions component layout for better responsiveness feat: extend apiSlice and routingFeedbackSlice to handle multiple sink routes --- src/features/LoginForm.tsx | 32 ++++-- src/features/Routing.tsx | 183 ++++++++++++++++++++---------- src/features/Versions.tsx | 36 +++--- src/shared/components/EyeIcon.tsx | 43 +++++++ src/store/apiSlice.ts | 16 ++- src/store/routingFeedbackSlice.ts | 30 +++-- 6 files changed, 242 insertions(+), 98 deletions(-) create mode 100644 src/shared/components/EyeIcon.tsx 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} + /> + +
); }; diff --git a/src/shared/components/EyeIcon.tsx b/src/shared/components/EyeIcon.tsx new file mode 100644 index 0000000..fb617ea --- /dev/null +++ b/src/shared/components/EyeIcon.tsx @@ -0,0 +1,43 @@ +type EyeIconProps = { + slashed?: boolean; + size?: number; +}; + +/** + * Simple inline eye / eye-slash icon (Bootstrap Icons glyphs), used for password-visibility + * toggles. Avoids pulling in a whole icon font/library for a single icon pair. + */ +const EyeIcon = ({ slashed = false, size = 16 }: EyeIconProps) => { + if (slashed) { + return ( + + ); + } + + return ( + + ); +}; + +export default EyeIcon; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 9e1c98f..e5d9bb8 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -477,10 +477,21 @@ export interface CurrentRouteGroupInfo { routes: ActiveRouteInfo[]; } +export interface SinkCurrentSourceInfo { + deviceKey: string; + inputPortKey: string; + sourceDeviceKey: string; + signalType: string; +} + export interface RoutingDevicesAndTieLines { devices: RoutingDevice[]; tieLines: TieLine[]; currentRoutes: CurrentRouteGroupInfo[]; + // Current source per sink device, read directly from each sink's own current-source + // bookkeeping. Covers routes made via device-specific bulk APIs (e.g. dynamic multiview + // layouts) that currentRoutes does not, since those never create a RouteDescriptor/TieLine. + sinkCurrentSources: SinkCurrentSourceInfo[]; } // ─── Live routing feedback (WebSocket) types ───────────────────────────────── @@ -500,7 +511,10 @@ export interface SinkRoute { export interface RoutingSnapshotMessage { type: "snapshot"; midpointRoutes: Record; - sinkRoutes: Record; + // A device implementing IRoutingSinkWithLayouts (e.g. a multiview decoder) can have multiple + // simultaneous tile routes reported under its one device key, so this is a list per device + // rather than a single route. + sinkRoutes: Record; } export interface MidpointRouteChangedMessage { diff --git a/src/store/routingFeedbackSlice.ts b/src/store/routingFeedbackSlice.ts index 9c5ca2a..e8f01e5 100644 --- a/src/store/routingFeedbackSlice.ts +++ b/src/store/routingFeedbackSlice.ts @@ -1,15 +1,17 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { - MidpointRoute, - MidpointRouteChangedMessage, - RoutingSnapshotMessage, - SinkInputChangedMessage, - SinkRoute, + MidpointRoute, + MidpointRouteChangedMessage, + RoutingSnapshotMessage, + SinkInputChangedMessage, + SinkRoute, } from "./apiSlice"; export interface RoutingFeedbackState { midpointRoutes: Record; - sinkRoutes: Record; + // 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. + sinkRoutes: Record; connected: boolean; failedUrls: string[]; } @@ -49,11 +51,17 @@ const routingFeedbackSlice = createSlice({ state.midpointRoutes[action.payload.deviceKey] = action.payload.routes; }, sinkInputChanged(state, action: PayloadAction) { - state.sinkRoutes[action.payload.deviceKey] = { - inputPortKey: action.payload.inputPortKey, - sourceDeviceKey: action.payload.sourceDeviceKey, - signalType: action.payload.signalType, - }; + const { deviceKey, inputPortKey, sourceDeviceKey, signalType } = + action.payload; + const existing = state.sinkRoutes[deviceKey] ?? []; + const updated: SinkRoute = { inputPortKey, sourceDeviceKey, signalType }; + const idx = existing.findIndex((r) => r.inputPortKey === inputPortKey); + if (idx >= 0) { + existing[idx] = updated; + } else { + existing.push(updated); + } + state.sinkRoutes[deviceKey] = existing; }, routingFeedbackReset() { return initialState; From a66b4514977a43a42895d752bee93aa8b479ca87 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 17 Jul 2026 16:56:24 -0600 Subject: [PATCH 05/15] feat: implement multiview layout panels with draggable functionality and integrate with routing feedback --- .../MultiviewLayoutCanvas.module.scss | 65 +++++ src/features/MultiviewLayoutCanvas.tsx | 78 ++++++ src/features/MultiviewLayoutPanel.module.scss | 65 +++++ src/features/MultiviewLayoutPanel.tsx | 106 ++++++++ src/features/Routing.tsx | 242 ++++++++++++++---- src/features/RoutingDeviceNode.module.scss | 16 ++ src/features/RoutingDeviceNode.tsx | 32 ++- src/store/apiSlice.ts | 45 +++- src/store/routingFeedbackMiddleware.ts | 26 +- src/store/routingFeedbackSlice.ts | 11 + 10 files changed, 623 insertions(+), 63 deletions(-) create mode 100644 src/features/MultiviewLayoutCanvas.module.scss create mode 100644 src/features/MultiviewLayoutCanvas.tsx create mode 100644 src/features/MultiviewLayoutPanel.module.scss create mode 100644 src/features/MultiviewLayoutPanel.tsx diff --git a/src/features/MultiviewLayoutCanvas.module.scss b/src/features/MultiviewLayoutCanvas.module.scss new file mode 100644 index 0000000..ef123d0 --- /dev/null +++ b/src/features/MultiviewLayoutCanvas.module.scss @@ -0,0 +1,65 @@ +.canvas { + position: relative; + width: 100%; + background: #111; + overflow: hidden; +} + +.canvasLight { + background: #ddd; +} + +.tile { + position: absolute; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.35); + display: flex; + align-items: flex-end; + padding: 3px 5px; + font-size: 0.68rem; + color: #fff; + background-color: rgba(13, 110, 253, 0.35); + cursor: pointer; + transition: outline-color 0.1s, background-color 0.1s; + outline: 2px solid transparent; + outline-offset: -2px; + + &:hover { + background-color: rgba(13, 110, 253, 0.5); + } +} + +.tileEmpty { + background-color: rgba(120, 120, 120, 0.15); + + &:hover { + background-color: rgba(120, 120, 120, 0.28); + } +} + +.tileSelected { + outline-color: #ffc107; + background-color: rgba(255, 193, 7, 0.45); +} + +.tileLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +.tileNumberBadge { + position: absolute; + top: 2px; + left: 3px; + font-size: 0.62rem; + opacity: 0.75; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +.canvasMeta { + font-size: 0.68rem; +} + diff --git a/src/features/MultiviewLayoutCanvas.tsx b/src/features/MultiviewLayoutCanvas.tsx new file mode 100644 index 0000000..5cad62d --- /dev/null +++ b/src/features/MultiviewLayoutCanvas.tsx @@ -0,0 +1,78 @@ +import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice"; +import styles from "./MultiviewLayoutCanvas.module.scss"; + +export interface MultiviewLayoutCanvasProps { + /** Current multiview canvas/tile layout to render. */ + layout: MultiviewLayoutState; + /** Resolves a device key (e.g. a tile's sourceDeviceKey) to a display name. */ + resolveSourceName: (deviceKey: string) => string; + darkMode?: boolean; + /** Tile number to render as selected/highlighted, or null/undefined if none. */ + selectedTileNumber?: number | null; + /** Called when a tile is clicked, with the full tile state. */ + onTileClick?: (tile: MultiviewTileState) => void; +} + +/** + * Renders a visual mock-up of what is actually displayed on the monitor fed by a single + * multiview-capable decoder: its canvas at the correct aspect ratio, with every tile + * positioned/sized/stacked to match and labeled with its routed source. Used inside a per-node + * popover in RoutingDeviceNode - purely additive, it does not alter the existing tie-line/route + * graph visualization in Routing.tsx. + */ +const MultiviewLayoutCanvas = ({ + layout, + resolveSourceName, + darkMode, + selectedTileNumber, + onTileClick, +}: MultiviewLayoutCanvasProps) => { + const aspectRatio = layout.canvasWidth / layout.canvasHeight; + + return ( +
+
+ {layout.canvasWidth}×{layout.canvasHeight} +
+
0 ? aspectRatio : 16 / 9 }} + > + {[...layout.tiles] + .sort((a, b) => a.zOrder - b.zOrder) + .map((tile) => { + const isEmpty = !tile.sourceDeviceKey; + const isSelected = selectedTileNumber === tile.tileNumber; + const sourceName = tile.sourceDeviceKey + ? resolveSourceName(tile.sourceDeviceKey) + : "Empty"; + + return ( +
{ + e.stopPropagation(); + onTileClick?.(tile); + }} + > + {tile.tileNumber} + {sourceName} +
+ ); + })} +
+
+ ); +}; + +export default MultiviewLayoutCanvas; + diff --git a/src/features/MultiviewLayoutPanel.module.scss b/src/features/MultiviewLayoutPanel.module.scss new file mode 100644 index 0000000..71d64eb --- /dev/null +++ b/src/features/MultiviewLayoutPanel.module.scss @@ -0,0 +1,65 @@ +.panel { + position: absolute; + width: 280px; + z-index: 20; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + border-radius: 6px; + overflow: hidden; + background: #fff; + border: 1px solid #ccc; +} + +.panelDark { + background: #1e1e1e; + border-color: #444; + color: #e0e0e0; +} + +.titleBar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0.5rem; + font-size: 0.78rem; + font-weight: 600; + cursor: move; + -webkit-user-select: none; + user-select: none; + background: #e9ecef; + border-bottom: 1px solid #ccc; +} + +.titleBarDark { + background: #2c2c2c; + border-bottom-color: #444; + color: #e0e0e0; +} + +.title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-grow: 1; + margin-right: 0.5rem; +} + +.closeBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 0 2px; + line-height: 1; + font-size: 1.1rem; + color: inherit; + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + color: #dc3545; + } +} + +.body { + padding: 0.5rem; +} diff --git a/src/features/MultiviewLayoutPanel.tsx b/src/features/MultiviewLayoutPanel.tsx new file mode 100644 index 0000000..6e95170 --- /dev/null +++ b/src/features/MultiviewLayoutPanel.tsx @@ -0,0 +1,106 @@ +import { useCallback, useRef } from "react"; + +import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice"; +import MultiviewLayoutCanvas from "./MultiviewLayoutCanvas"; +import styles from "./MultiviewLayoutPanel.module.scss"; + +export interface MultiviewLayoutPanelPosition { + x: number; + y: number; +} + +export interface MultiviewLayoutPanelProps { + title: string; + layout: MultiviewLayoutState; + position: MultiviewLayoutPanelPosition; + darkMode?: boolean; + selectedTileNumber?: number | null; + resolveSourceName: (deviceKey: string) => string; + onTileClick?: (tile: MultiviewTileState) => void; + onClose: () => void; + onMove: (position: MultiviewLayoutPanelPosition) => void; +} + +/** + * A floating, freely-draggable window showing a single device's multiview canvas/tile mock-up + * (via MultiviewLayoutCanvas). Rendered as a sibling of the React Flow canvas (not as part of a + * graph node), so its position is independent of node positions - which move whenever the dagre + * layout re-runs in response to routing/filter changes. Stays open until explicitly closed, and + * multiple panels (one per device) can be open and dragged around independently at once. + */ +const MultiviewLayoutPanel = ({ + title, + layout, + position, + darkMode, + selectedTileNumber, + resolveSourceName, + onTileClick, + onClose, + onMove, +}: MultiviewLayoutPanelProps) => { + const dragStateRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(null); + + const handleTitleBarPointerDown = useCallback( + (e: React.PointerEvent) => { + // Only left-click / primary touch should start a drag. + if (e.button !== 0) return; + + dragStateRef.current = { + startX: e.clientX, + startY: e.clientY, + originX: position.x, + originY: position.y, + }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState) return; + onMove({ + x: dragState.originX + (moveEvent.clientX - dragState.startX), + y: dragState.originY + (moveEvent.clientY - dragState.startY), + }); + }; + + const handlePointerUp = () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }, + [position.x, position.y, onMove], + ); + + return ( +
+
+ + {title} + + +
+
+ +
+
+ ); +}; + +export default MultiviewLayoutPanel; diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 33142f1..8805e5d 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -2,16 +2,16 @@ import "@xyflow/react/dist/style.css"; import { skipToken } from "@reduxjs/toolkit/query"; import { - Background, - Controls, - Edge, - EdgeTypes, - MiniMap, - Node, - NodeTypes, - ReactFlow, - useEdgesState, - useNodesState, + Background, + Controls, + Edge, + EdgeTypes, + MiniMap, + Node, + NodeTypes, + ReactFlow, + useEdgesState, + useNodesState, } from "@xyflow/react"; import dagre from "dagre"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -20,24 +20,25 @@ 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, + RoutingDevice, + RoutingDevicesAndTieLines, + SinkRoute, + TieLine, + useGetRoutingDevicesAndTieLinesQuery, + useGetVersionsQuery, } from "../store/apiSlice"; import { useAppDispatch, useAppSelector } from "../store/hooks"; import { - ROUTING_WS_CONNECT, - ROUTING_WS_DISCONNECT, - routingSnapshotReceived, + 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, - PORT_ROW_PX, - RoutingDeviceNodeData, + HEADER_PX, + PORT_ROW_PX, + RoutingDeviceNodeData, } from "./RoutingDeviceNode"; import TieLineEdge from "./TieLineEdge"; @@ -384,10 +385,11 @@ const Routing = () => { 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, ); @@ -399,6 +401,40 @@ const Routing = () => { 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", @@ -450,38 +486,57 @@ const Routing = () => { 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; - // Fetch the routing feedback session URL from the API, then connect - const baseUrl = `/cws/${appId}/api/routingFeedbackSession`; - let cancelled = false; - - fetch(baseUrl) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }) - .then((session: { url: string; fallbackUrl?: string }) => { - if (cancelled) 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); - }); + const cancelledRef = { current: false }; + connectRoutingWebSocket(cancelledRef); return () => { - cancelled = true; + cancelledRef.current = true; dispatch({ type: ROUTING_WS_DISCONNECT }); }; - }, [appId, isV3, dispatch]); + }, [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( () => @@ -508,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. @@ -528,6 +633,8 @@ const Routing = () => { ...n.data, darkMode, currentRoutes: midpointRoutes[n.id], + hasLayout: Boolean(layouts[n.id]), + onToggleLayoutPanel: () => toggleLayoutPanel(n.id), onHide: () => setHiddenDevices((prev) => { const next = new Set(prev); @@ -548,6 +655,9 @@ const Routing = () => { darkMode, midpointRoutes, sinkRoutes, + layouts, + resolveSourceName, + handleTileClick, setNodes, setEdges, ]); @@ -592,14 +702,20 @@ const Routing = () => { 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}`); @@ -624,15 +740,10 @@ const Routing = () => { return { ...n, data: { ...n.data, highlightedRouteKeys: highlighted } }; }), ); + setSelectedTileNumberByDevice(Object.fromEntries(selectedTileNumberByDevice)); } }, [selectedEdgeIds, setEdges, setNodes, midpointRoutes]); - // Keep a ref to current edges for path tracing without triggering re-renders - const edgesRef = useRef([]); - useEffect(() => { - edgesRef.current = edges; - }, [edges]); - // Edge click: highlight only the single clicked edge const onEdgeClick = useCallback( (_: React.MouseEvent, edge: Edge) => @@ -925,6 +1036,16 @@ const Routing = () => { > {routingWsConnected ? "Live" : "Offline"} +
@@ -956,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 9c2ef98..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; } diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 4238679..82a149c 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -1,4 +1,5 @@ import { Handle, NodeProps, Position } from "@xyflow/react"; + import { MidpointRoute, RoutingDevice } from "../store/apiSlice"; import styles from "./RoutingDeviceNode.module.scss"; @@ -20,13 +21,25 @@ export type RoutingDeviceNodeData = { 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, currentRoutes, highlightedRouteKeys } = 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); @@ -50,6 +63,23 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { )} + {hasLayout && ( + + )} {onHide && ( From 74c915fc48de97d17d2ea79e5012163668ce8f13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:37:52 +0000 Subject: [PATCH 08/15] Fix Routing.tsx layout effect re-running dagre on every feedback update --- package-lock.json | 120 +-------------------------------------- src/features/Routing.tsx | 33 +++++++++-- 2 files changed, 31 insertions(+), 122 deletions(-) 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/Routing.tsx b/src/features/Routing.tsx index 8805e5d..96a6c2f 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -569,6 +569,18 @@ const Routing = () => { 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(); @@ -632,8 +644,8 @@ const Routing = () => { data: { ...n.data, darkMode, - currentRoutes: midpointRoutes[n.id], - hasLayout: Boolean(layouts[n.id]), + currentRoutes: midpointRoutesRef.current[n.id], + hasLayout: Boolean(layoutsRef.current[n.id]), onToggleLayoutPanel: () => toggleLayoutPanel(n.id), onHide: () => setHiddenDevices((prev) => { @@ -653,15 +665,28 @@ const Routing = () => { hiddenDevices, hideUnconnectedPorts, darkMode, - midpointRoutes, sinkRoutes, - layouts, resolveSourceName, handleTileClick, setNodes, setEdges, ]); + // 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) => From 599dc93509235f8b210bc04f38b7a1a4da552ab8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 23 Jul 2026 12:41:03 -0600 Subject: [PATCH 09/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/features/Routing.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 96a6c2f..e9dcad3 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -20,6 +20,7 @@ import { Alert, Dropdown } from "react-bootstrap"; import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; import useAppParams from "../shared/hooks/useAppParams"; import { + MidpointRoute, RoutingDevice, RoutingDevicesAndTieLines, SinkRoute, From 30ccdc1eb942856959ccaff93f881f8ac02e8802 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 23 Jul 2026 12:43:32 -0600 Subject: [PATCH 10/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/features/Routing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index e9dcad3..8d9311b 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -101,7 +101,7 @@ function buildGraph( ); const deviceOutputPortKey = new Map( data.devices - .filter((d) => (d.outputPorts ?? []).length > 0) + .filter((d) => (d.outputPorts ?? []).length === 1) .map((d) => [d.key, d.outputPorts![0].key]), ); const syntheticTieLines: TieLine[] = Object.entries(sinkRoutes).flatMap( From 73bcc2bca7193eb333264ce19af3ed0f88f6212d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:44:08 +0000 Subject: [PATCH 11/15] feat: increment minor version From 98dea90b2507be3aaafb66740e9c5a0fd33d9972 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 23 Jul 2026 12:47:35 -0600 Subject: [PATCH 12/15] refactor: remove unused type import for MidpointRoute in Routing.tsx --- src/features/Routing.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 8d9311b..a42e442 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -288,8 +288,6 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { // ─── 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 From 2514c626a3e43fe1d36deefa1dd3d240fe22b5d3 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 23 Jul 2026 12:55:53 -0600 Subject: [PATCH 13/15] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/features/RoutingDeviceNode.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 82a149c..db4326f 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -64,14 +64,15 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { )} {hasLayout && ( -