From 8a5a0b54a77da2de40726777b0fabe25506bd7fe Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 22 Oct 2026 14:37:01 -0600 Subject: [PATCH 1/6] feat: read the debug console and routing feedback over HTTP polling on VC-4 On Virtual Control the debug and routing WebSockets listen on ports the server's firewall blocks. There, both streams now long-poll the new debugSession/messages and routingFeedbackSession/messages routes over the same HTTPS port as every other API call, so no firewall rules or per-port certificate steps are needed. Appliances keep using the WebSockets. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 12 ++ src/features/Routing.tsx | 14 +- src/shared/functions/hostPaths.test.ts | 11 ++ src/shared/functions/hostPaths.ts | 11 ++ src/store/messagePoller.test.ts | 166 ++++++++++++++++++++ src/store/messagePoller.ts | 159 +++++++++++++++++++ src/store/routingFeedbackMiddleware.test.ts | 55 +++++++ src/store/routingFeedbackMiddleware.ts | 83 +++++++--- src/store/routingFeedbackSlice.ts | 6 +- src/store/websocketMiddleware.test.ts | 52 ++++++ src/store/websocketMiddleware.ts | 58 ++++++- src/store/websocketSlice.ts | 6 +- 12 files changed, 601 insertions(+), 32 deletions(-) create mode 100644 src/store/messagePoller.test.ts create mode 100644 src/store/messagePoller.ts diff --git a/src/App.tsx b/src/App.tsx index cb2747d..14328df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { useGetDebugSessionMutation, useStopDebugSessionMutation, } from './store/apiSlice'; +import { apiUrl, streamsUseHttpPolling } from './shared/functions/hostPaths'; import { AppDispatch, RootState } from './store/store'; import { connectionAttemptStarted, @@ -90,6 +91,17 @@ function App() { const { isConnecting, isConnected } = store.getState().websocket; if (!appId || isConnecting || isConnected) return; const requestId = ++joinRequestIdRef.current; + + if (streamsUseHttpPolling()) { + // The first poll starts the server-side session, so there's no WebSocket session to request + activeSessionAppIdRef.current = appId; + dispatch({ + type: WS_CONNECT, + payload: { pollUrl: apiUrl(appId, 'debugSession/messages') }, + }); + return; + } + dispatch(connectionAttemptStarted()); try { const res = await startSession({ appId }).unwrap(); diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 99b5318..e630828 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -75,7 +75,7 @@ import RoutingDeviceNode, { RoutingDeviceNodeData, } from './RoutingDeviceNode'; import TieLineEdge from './TieLineEdge'; -import { apiUrl } from '../shared/functions/hostPaths'; +import { apiUrl, streamsUseHttpPolling } from '../shared/functions/hostPaths'; // ─── Constants ────────────────────────────────────────────────────────────── @@ -605,6 +605,18 @@ const Routing = () => { const connectRoutingWebSocket = useCallback( (onCancelledRef?: { current: boolean }) => { if (!appId || !isV3) return; + + if (streamsUseHttpPolling()) { + // The first poll starts the session and returns the snapshot + dispatch({ + type: ROUTING_WS_CONNECT, + payload: { + pollUrl: apiUrl(appId, 'routingFeedbackSession/messages'), + }, + }); + return; + } + const baseUrl = apiUrl(appId, 'routingFeedbackSession'); fetch(baseUrl) diff --git a/src/shared/functions/hostPaths.test.ts b/src/shared/functions/hostPaths.test.ts index 2e7306a..fe62d3c 100644 --- a/src/shared/functions/hostPaths.test.ts +++ b/src/shared/functions/hostPaths.test.ts @@ -5,6 +5,7 @@ import { getCwsRoot, getHostLayout, getRouterBasename, + streamsUseHttpPolling, toCwsPath, } from './hostPaths'; @@ -105,3 +106,13 @@ describe('apiUrl', () => { ); }); }); + +describe('streamsUseHttpPolling', () => { + it('polls on Virtual Control, where the WebSocket ports are firewalled', () => { + expect(streamsUseHttpPolling(vc4)).toBe(true); + }); + + it('keeps the WebSockets on an appliance', () => { + expect(streamsUseHttpPolling(appliance)).toBe(false); + }); +}); diff --git a/src/shared/functions/hostPaths.ts b/src/shared/functions/hostPaths.ts index ed717c7..29cde2f 100644 --- a/src/shared/functions/hostPaths.ts +++ b/src/shared/functions/hostPaths.ts @@ -83,6 +83,17 @@ export function toCwsPath( return url; } +/** + * Whether the live streams (debug console, routing feedback) should be read over HTTP polling + * instead of their WebSockets. On VC-4 those WebSockets listen on ports the server's firewall blocks + * unless someone opens them, while polling goes through the same HTTPS port as every API call. + */ +export function streamsUseHttpPolling( + layout: HostLayout = getHostLayout() +): boolean { + return layout.isVirtualControl; +} + /** The full URL for an API path on a program, e.g. `apiUrl('app01', 'routingFeedbackSession')`. */ export function apiUrl( appId: string, diff --git a/src/store/messagePoller.test.ts b/src/store/messagePoller.test.ts new file mode 100644 index 0000000..057ad47 --- /dev/null +++ b/src/store/messagePoller.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MessagePollerOptions, + PollResponse, + startMessagePoller, +} from './messagePoller'; + +/** + * A fetch stand-in that answers requests from a script, one entry per request. Once the script + * runs out, requests stay pending until aborted, like a long poll with nothing new. + */ +function scriptedFetch(script: (PollResponse | Error | number)[]) { + const urls: string[] = []; + const fetchImpl = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + urls.push(String(input)); + const step = script.shift(); + + if (step === undefined) { + return new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }); + } + if (step instanceof Error) return Promise.reject(step); + if (typeof step === 'number') { + return Promise.resolve(new Response('', { status: step })); + } + return Promise.resolve(new Response(JSON.stringify(step), { status: 200 })); + }); + + return { fetchImpl: fetchImpl as unknown as typeof fetch, urls }; +} + +const start = ( + fetchImpl: typeof fetch, + overrides: Partial = {} +) => { + const calls = { + messages: [] as string[], + connected: 0, + disconnected: 0, + failed: 0, + }; + const poller = startMessagePoller({ + url: '/cws/api/debugSession/messages', + fetchImpl, + minIntervalMs: 0, + retryDelayMs: 0, + onMessages: (m) => calls.messages.push(...m), + onConnected: () => calls.connected++, + onDisconnected: () => calls.disconnected++, + onFailed: () => calls.failed++, + ...overrides, + }); + return { poller, calls }; +}; + +describe('startMessagePoller', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts a session, then polls from the cursor it returned', async () => { + const { fetchImpl, urls } = scriptedFetch([ + { next: 7, messages: ['snapshot'] }, + { next: 9, missed: false, messages: ['a', 'b'] }, + ]); + + const { poller, calls } = start(fetchImpl, { waitMs: 4000 }); + + await vi.waitFor(() => expect(urls).toHaveLength(3)); + poller.stop(); + + expect(urls).toEqual([ + '/cws/api/debugSession/messages', + '/cws/api/debugSession/messages?since=7&wait=4000', + '/cws/api/debugSession/messages?since=9&wait=4000', + ]); + expect(calls.messages).toEqual(['snapshot', 'a', 'b']); + expect(calls.connected).toBe(1); + }); + + it('starts over after missed messages when asked to', async () => { + const { fetchImpl, urls } = scriptedFetch([ + { next: 3, messages: ['snapshot-1'] }, + { next: 20, missed: true, messages: [] }, + { next: 20, messages: ['snapshot-2'] }, + ]); + + const { poller, calls } = start(fetchImpl, { restartOnMissed: true }); + + await vi.waitFor(() => expect(urls).toHaveLength(4)); + poller.stop(); + + expect(urls[2]).toBe('/cws/api/debugSession/messages'); + expect(urls[3]).toContain('since=20'); + expect(calls.messages).toEqual(['snapshot-1', 'snapshot-2']); + }); + + it('carries on past missed messages by default', async () => { + const { fetchImpl, urls } = scriptedFetch([ + { next: 3, messages: [] }, + { next: 20, missed: true, messages: ['x'] }, + ]); + + const { poller, calls } = start(fetchImpl); + + await vi.waitFor(() => expect(urls).toHaveLength(3)); + poller.stop(); + + expect(urls[2]).toContain('since=20'); + expect(calls.messages).toEqual(['x']); + }); + + it('reports an outage and recovers from the same cursor', async () => { + const { fetchImpl, urls } = scriptedFetch([ + { next: 5, messages: [] }, + new Error('network'), + 503, + { next: 6, messages: ['back'] }, + ]); + + const { poller, calls } = start(fetchImpl); + + await vi.waitFor(() => expect(urls).toHaveLength(5)); + poller.stop(); + + expect(urls[2]).toContain('since=5'); + expect(urls[3]).toContain('since=5'); + expect(calls.disconnected).toBe(1); + expect(calls.connected).toBe(2); + expect(calls.messages).toEqual(['back']); + expect(calls.failed).toBe(0); + }); + + it('gives up after too many failures in a row', async () => { + const { fetchImpl, urls } = scriptedFetch([500, 500, 500]); + + const { calls } = start(fetchImpl, { maxFailures: 3 }); + + await vi.waitFor(() => expect(calls.failed).toBe(1)); + await new Promise((r) => setTimeout(r, 20)); + + expect(urls).toHaveLength(3); + expect(calls.connected).toBe(0); + }); + + it('aborts the waiting request on stop and reports nothing after', async () => { + const { fetchImpl, urls } = scriptedFetch([{ next: 1, messages: [] }]); + + const { poller, calls } = start(fetchImpl); + + await vi.waitFor(() => expect(urls).toHaveLength(2)); + poller.stop(); + await new Promise((r) => setTimeout(r, 20)); + + expect(urls).toHaveLength(2); + expect(calls.failed).toBe(0); + expect(calls.disconnected).toBe(0); + }); +}); diff --git a/src/store/messagePoller.ts b/src/store/messagePoller.ts new file mode 100644 index 0000000..2e3253e --- /dev/null +++ b/src/store/messagePoller.ts @@ -0,0 +1,159 @@ +/** + * Reads an Essentials message stream over plain HTTP instead of a WebSocket. + * + * Used where the WebSocket port can't be reached, such as a Virtual Control server whose firewall + * only opens 443. The server holds each request open until a message arrives or `waitMs` passes + * (long polling), so updates still show up almost immediately. + * + * Protocol (`debugSession/messages`, `routingFeedbackSession/messages`): + * - `GET ` starts a session and returns `{ next, messages }`; for routing, `messages` holds the + * snapshot. + * - `GET ?since=&wait=` returns `{ next, missed, messages }` with what arrived after + * `since`. Each message is the same text the WebSocket would have sent. + */ + +export interface PollResponse { + next: number; + missed?: boolean; + messages: string[]; +} + +export interface MessagePollerOptions { + /** The stream's `.../messages` URL. */ + url: string; + /** Called with each batch, oldest first, including the first response's messages. */ + onMessages: (messages: string[]) => void; + /** Called once the session has started, and again after each recovery from failures. */ + onConnected: () => void; + /** Called when a request fails after the session had connected; polling keeps retrying. */ + onDisconnected?: () => void; + /** Called when polling gives up after `maxFailures` failures in a row. */ + onFailed: (error: unknown) => void; + /** + * Start a new session when the server reports missed messages, rather than carrying on. Set it + * for streams such as routing whose first response carries full state. + */ + restartOnMissed?: boolean; + /** How long the server may hold each request. Default 5000. */ + waitMs?: number; + /** Pause after a response that carried messages, so a busy stream isn't fetched nonstop. Default 250. */ + minIntervalMs?: number; + /** Pause before retrying a failed request. Default 2000. */ + retryDelayMs?: number; + /** Failures in a row before giving up. Default 5. */ + maxFailures?: number; + fetchImpl?: typeof fetch; +} + +export interface MessagePoller { + stop: () => void; +} + +export function startMessagePoller( + options: MessagePollerOptions +): MessagePoller { + const { + url, + onMessages, + onConnected, + onDisconnected, + onFailed, + restartOnMissed = false, + waitMs = 5000, + minIntervalMs = 250, + retryDelayMs = 2000, + maxFailures = 5, + fetchImpl = (...args) => fetch(...args), + } = options; + + let stopped = false; + let controller: AbortController | null = null; + let timer: ReturnType | null = null; + + const delay = (ms: number) => + new Promise((resolve) => { + timer = setTimeout(() => { + timer = null; + resolve(); + }, ms); + }); + + const get = async (since: number | null): Promise => { + const query = + since === null ? '' : `since=${since}&wait=${Math.max(0, waitMs)}`; + const target = query + ? `${url}${url.includes('?') ? '&' : '?'}${query}` + : url; + + controller = new AbortController(); + const res = await fetchImpl(target, { + signal: controller.signal, + cache: 'no-store', + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as PollResponse; + }; + + const run = async () => { + let cursor: number | null = null; + let failures = 0; + let connected = false; + + while (!stopped) { + try { + const response = await get(cursor); + if (stopped) return; + + if (cursor !== null && response.missed && restartOnMissed) { + // Start over to get fresh full state + cursor = null; + continue; + } + + cursor = response.next; + failures = 0; + + if (!connected) { + connected = true; + onConnected(); + } + + if (response.messages.length > 0) { + onMessages(response.messages); + await delay(minIntervalMs); + } + } catch (err) { + if (stopped) return; + + failures++; + console.warn('[poll] Request failed', url, err); + + if (failures >= maxFailures) { + stopped = true; + onFailed(err); + return; + } + + // Report the outage so the UI doesn't show a live connection while retrying + if (connected) { + connected = false; + onDisconnected?.(); + } + await delay(retryDelayMs); + } + } + }; + + void run(); + + return { + stop: () => { + stopped = true; + controller?.abort(); + if (timer) { + clearTimeout(timer); + timer = null; + } + }, + }; +} diff --git a/src/store/routingFeedbackMiddleware.test.ts b/src/store/routingFeedbackMiddleware.test.ts index 1c40370..6384e15 100644 --- a/src/store/routingFeedbackMiddleware.test.ts +++ b/src/store/routingFeedbackMiddleware.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { routingFeedbackMiddleware } from './routingFeedbackMiddleware'; import routingFeedbackReducer, { ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, } from './routingFeedbackSlice'; // Minimal stand-in for the browser WebSocket; tests drive its events directly @@ -162,4 +163,58 @@ describe('routingFeedbackMiddleware', () => { FakeWebSocket.instances[2].onopen?.(); expect(store.getState().routingFeedback.connected).toBe(true); }); + + // A fetch stand-in for the polling transport: the first request starts the session, later ones + // stay pending until aborted, like a long poll with nothing new + const stubPollingFetch = (first: object) => { + const requests: { url: string; signal?: AbortSignal | null }[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), signal: init?.signal }); + if (requests.length === 1) { + return Promise.resolve( + new Response(JSON.stringify(first), { status: 200 }) + ); + } + return new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }); + }) + ); + return requests; + }; + + it('reads routing feedback over HTTP when given a poll URL', async () => { + vi.useRealTimers(); + const snapshot = { + type: 'snapshot', + midpointRoutes: { 'switcher-1': [] }, + sinkRoutes: {}, + layouts: {}, + }; + const requests = stubPollingFetch({ + next: 9, + messages: [JSON.stringify(snapshot)], + }); + const store = makeStore(); + + store.dispatch({ + type: ROUTING_WS_CONNECT, + payload: { pollUrl: '/cws/api/routingFeedbackSession/messages' }, + }); + + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(store.getState().routingFeedback.connected).toBe(true); + expect(store.getState().routingFeedback.midpointRoutes).toEqual({ + 'switcher-1': [], + }); + expect(requests[1].url).toContain('since=9'); + expect(FakeWebSocket.instances).toHaveLength(0); + + store.dispatch({ type: ROUTING_WS_DISCONNECT }); + expect(requests[1].signal?.aborted).toBe(true); + }); }); diff --git a/src/store/routingFeedbackMiddleware.ts b/src/store/routingFeedbackMiddleware.ts index b40fda8..9ffa17a 100644 --- a/src/store/routingFeedbackMiddleware.ts +++ b/src/store/routingFeedbackMiddleware.ts @@ -1,5 +1,6 @@ import { Middleware } from '@reduxjs/toolkit'; import { RoutingFeedbackMessage } from './apiSlice'; +import { MessagePoller, startMessagePoller } from './messagePoller'; import { layoutChanged, midpointRouteChanged, @@ -20,6 +21,7 @@ const MAX_RECONNECT_ATTEMPTS = 5; export const routingFeedbackMiddleware: Middleware = (store) => { let socket: WebSocket | null = null; + let poller: MessagePoller | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; let currentUrl: string | null = null; @@ -29,7 +31,60 @@ export const routingFeedbackMiddleware: Middleware = (store) => { let fallbackUrl: string | null = null; let attemptedUrls: string[] = []; + function handleMessage(data: string) { + try { + const msg = JSON.parse(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; + case 'layoutChanged': + store.dispatch(layoutChanged(msg)); + break; + } + } catch (e) { + console.error('[routing-ws] Failed to parse message', e); + } + } + + function startPolling(pollUrl: string) { + console.log('[routing-ws] Polling', pollUrl); + + // Every callback checks it still owns `poller`, as the socket handlers do. The first + // response of each session carries the snapshot, and a gap restarts the session. + const current: MessagePoller = startMessagePoller({ + url: pollUrl, + restartOnMissed: true, + onConnected: () => { + if (poller === current) store.dispatch(routingWsConnected()); + }, + onDisconnected: () => { + if (poller === current) store.dispatch(routingWsDisconnected()); + }, + onFailed: (err) => { + if (poller !== current) return; + console.error('[routing-ws] Polling routing feedback failed', err); + poller = null; + store.dispatch(routingWsDisconnected()); + }, + onMessages: (messages) => { + if (poller === current) messages.forEach(handleMessage); + }, + }); + poller = current; + } + function cleanup() { + if (poller) { + poller.stop(); + poller = null; + } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -112,25 +167,7 @@ export const routingFeedbackMiddleware: Middleware = (store) => { ws.onmessage = (event: MessageEvent) => { if (socket !== ws) return; - 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; - case 'layoutChanged': - store.dispatch(layoutChanged(msg)); - break; - } - } catch (e) { - console.error('[routing-ws] Failed to parse message', e); - } + handleMessage(event.data); }; } @@ -161,9 +198,13 @@ export const routingFeedbackMiddleware: Middleware = (store) => { RoutingWsConnectAction | RoutingWsDisconnectAction; if (type === ROUTING_WS_CONNECT) { - const { url, fallbackUrl: fb } = (action as RoutingWsConnectAction) - .payload; + const payload = (action as RoutingWsConnectAction).payload; cleanup(); + if ('pollUrl' in payload) { + startPolling(payload.pollUrl); + return; + } + const { url, fallbackUrl: fb } = payload; primaryUrl = url; fallbackUrl = fb ?? null; connectToUrl(url, fallbackUrl ?? undefined); diff --git a/src/store/routingFeedbackSlice.ts b/src/store/routingFeedbackSlice.ts index 46fcdcb..62b51ec 100644 --- a/src/store/routingFeedbackSlice.ts +++ b/src/store/routingFeedbackSlice.ts @@ -110,7 +110,11 @@ export const ROUTING_WS_DISCONNECT = 'routingFeedback/wsDisconnect'; export interface RoutingWsConnectAction { type: typeof ROUTING_WS_CONNECT; - payload: { url: string; fallbackUrl?: string }; + /** + * Either the WebSocket URL(s) from the feedback session, or `pollUrl` to read feedback over HTTP + * where the WebSocket port can't be reached (see messagePoller). + */ + payload: { url: string; fallbackUrl?: string } | { pollUrl: string }; } export interface RoutingWsDisconnectAction { diff --git a/src/store/websocketMiddleware.test.ts b/src/store/websocketMiddleware.test.ts index df5419a..6faf0ab 100644 --- a/src/store/websocketMiddleware.test.ts +++ b/src/store/websocketMiddleware.test.ts @@ -173,4 +173,56 @@ describe('websocketMiddleware', () => { expect(store.getState().websocket.isConnecting).toBe(false); }); + + // A fetch stand-in for the polling transport: the first request starts the session, later ones + // stay pending until aborted, like a long poll with nothing new + const stubPollingFetch = (first: object) => { + const requests: { url: string; signal?: AbortSignal | null }[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), signal: init?.signal }); + if (requests.length === 1) { + return Promise.resolve( + new Response(JSON.stringify(first), { status: 200 }) + ); + } + return new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }); + }) + ); + return requests; + }; + + it('reads the log over HTTP when given a poll URL, and stops on disconnect', async () => { + const message = { + Timestamp: '2026-10-22T20:00:00Z', + Level: 'Information', + RenderedMessage: 'hello', + }; + const requests = stubPollingFetch({ + next: 4, + messages: [JSON.stringify(message)], + }); + const store = makeStore(); + + store.dispatch({ + type: WS_CONNECT, + payload: { pollUrl: '/cws/api/debugSession/messages' }, + }); + + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(store.getState().websocket.isConnected).toBe(true); + expect(store.getState().websocket.messages).toEqual([message]); + expect(requests[1].url).toContain('since=4'); + expect(FakeWebSocket.instances).toHaveLength(0); + + store.dispatch({ type: WS_DISCONNECT }); + + expect(requests[1].signal?.aborted).toBe(true); + expect(store.getState().websocket.isConnected).toBe(false); + }); }); diff --git a/src/store/websocketMiddleware.ts b/src/store/websocketMiddleware.ts index 0b896ad..603bca9 100644 --- a/src/store/websocketMiddleware.ts +++ b/src/store/websocketMiddleware.ts @@ -1,4 +1,5 @@ import { Middleware } from '@reduxjs/toolkit'; +import { MessagePoller, startMessagePoller } from './messagePoller'; import { connected, connectionAttemptStarted, @@ -13,10 +14,25 @@ import { export const websocketMiddleware: Middleware = (store) => { let socket: WebSocket | null = null; + let poller: MessagePoller | null = null; + + const dispatchMessages = (messages: string[]) => { + for (const data of messages) { + try { + store.dispatch(messageReceived(JSON.parse(data))); + } catch (e) { + console.error('Failed to parse debug message', e); + } + } + }; // Detach handlers before closing so a stale socket can't clobber the // current one, trigger a fallback connection, or keep dispatching messages const closeSocket = () => { + if (poller) { + poller.stop(); + poller = null; + } if (socket) { socket.onopen = null; socket.onclose = null; @@ -31,9 +47,7 @@ export const websocketMiddleware: Middleware = (store) => { const { type } = action as WsConnectAction | WsDisconnectAction; if (type === WS_CONNECT) { - const { url, fallbackUrl } = (action as WsConnectAction).payload; - - console.log('[ws] Connecting to', url); + const payload = (action as WsConnectAction).payload; // Close any existing connection before opening a new one. Its handlers // are detached, so report the disconnect here before the new attempt @@ -41,6 +55,38 @@ export const websocketMiddleware: Middleware = (store) => { store.dispatch(disconnected()); store.dispatch(connectionAttemptStarted()); + if ('pollUrl' in payload) { + console.log('[ws] Polling', payload.pollUrl); + + // Every callback checks it still owns `poller`, as the socket handlers do + const current: MessagePoller = startMessagePoller({ + url: payload.pollUrl, + onConnected: () => { + if (poller === current) store.dispatch(connected()); + }, + onDisconnected: () => { + if (poller !== current) return; + store.dispatch(disconnected()); + store.dispatch(connectionAttemptStarted()); + }, + onFailed: (err) => { + if (poller !== current) return; + console.error('[ws] Polling the debug log failed', err); + poller = null; + store.dispatch(disconnected()); + }, + onMessages: (messages) => { + if (poller === current) dispatchMessages(messages); + }, + }); + poller = current; + return; + } + + const { url, fallbackUrl } = payload; + + console.log('[ws] Connecting to', url); + const connectToUrl = (targetUrl: string, fallback?: string) => { let ws: WebSocket; try { @@ -96,11 +142,7 @@ export const websocketMiddleware: Middleware = (store) => { }; ws.onmessage = (event: MessageEvent) => { if (socket !== ws) return; - try { - store.dispatch(messageReceived(JSON.parse(event.data))); - } catch (e) { - console.error('Failed to parse WebSocket message', e); - } + dispatchMessages([event.data]); }; }; diff --git a/src/store/websocketSlice.ts b/src/store/websocketSlice.ts index 71f4831..8a71bef 100644 --- a/src/store/websocketSlice.ts +++ b/src/store/websocketSlice.ts @@ -71,7 +71,11 @@ export const WS_DISCONNECT = 'websocket/disconnect'; export interface WsConnectAction { type: typeof WS_CONNECT; - payload: { url: string; fallbackUrl?: string }; + /** + * Either the WebSocket URL(s) from the debug session, or `pollUrl` to read the log over HTTP + * where the WebSocket port can't be reached (see messagePoller). + */ + payload: { url: string; fallbackUrl?: string } | { pollUrl: string }; } export interface WsDisconnectAction { From 668c8f4d67659da8fb633c2fd538ece2851190c2 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 22 Oct 2026 15:04:02 -0600 Subject: [PATCH 2/6] docs: VC-4 Dev Tools need only the Mobile Control port opened The Debug Console and routing poll over HTTPS on VC-4, so the WebSocket port range and the per-port certificate step are gone from the guide. Document the two polling routes in the API reference. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 2 +- docs/how-to/README.md | 2 +- docs/how-to/troubleshoot-connection.md | 2 +- docs/how-to/use-dev-tools-on-vc4.md | 49 ++++++++++---------------- docs/reference/api-endpoints.md | 47 ++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 34 deletions(-) diff --git a/docs/README.md b/docs/README.md index 61abc5f..83962f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,7 +80,7 @@ The PepperDash Essentials Web Config App provides several key features: The app supports up to 10 simultaneous PepperDash Essentials program slots (`app01` through `app10`). After logging in, available program slots are automatically discovered and populated in the application selector dropdown in the top navigation bar. Navigating between app slots does not require re-authentication. -On Crestron Virtual Control (VC-4), each room has its own Dev Tools at `https://[vc4-server]/VirtualControl/Rooms/[RoomId]/cws/debug/`, and the selector shows the room ID. The VC-4 server's firewall needs a few ports opened for the Debug Console, live routing and the Mobile Control UI; see [Use Dev Tools on Virtual Control (VC-4)](./how-to/use-dev-tools-on-vc4.md). +On Crestron Virtual Control (VC-4), each room has its own Dev Tools at `https://[vc4-server]/VirtualControl/Rooms/[RoomId]/cws/debug/`, and the selector shows the room ID. The Debug Console and live routing work over HTTPS with no extra setup, and the Mobile Control UI needs one firewall port opened; see [Use Dev Tools on Virtual Control (VC-4)](./how-to/use-dev-tools-on-vc4.md). ## System Requirements diff --git a/docs/how-to/README.md b/docs/how-to/README.md index 6565f94..65d523b 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -37,7 +37,7 @@ These guides provide step-by-step solutions to specific problems you might encou **You'll learn to:** - Open Dev Tools for a VC-4 room and sign in -- Open the firewall ports the Debug Console, routing and Mobile Control need +- Open the firewall port the Mobile Control UI needs - Accept the WebSocket certificate --- diff --git a/docs/how-to/troubleshoot-connection.md b/docs/how-to/troubleshoot-connection.md index b2bb8f1..a436630 100644 --- a/docs/how-to/troubleshoot-connection.md +++ b/docs/how-to/troubleshoot-connection.md @@ -212,7 +212,7 @@ Should return JSON data if the API is working. - Corporate firewalls may block HTTPS to internal devices - Some networks block self-signed certificates - Guest networks may restrict device-to-device communication -- On a VC-4 server, the host firewall blocks the Debug Console and routing WebSocket ports (65335–65534/tcp) and the Mobile Control direct server port until they're opened; see [Open the Firewall Ports](./use-dev-tools-on-vc4.md#open-the-firewall-ports) +- On a VC-4 server, the host firewall blocks the Mobile Control direct server port until it's opened; see [Open the Mobile Control Port](./use-dev-tools-on-vc4.md#open-the-mobile-control-port). The Debug Console and routing use HTTPS polling there, so they need no extra ports ### DNS Resolution diff --git a/docs/how-to/use-dev-tools-on-vc4.md b/docs/how-to/use-dev-tools-on-vc4.md index b90aae3..1e783b7 100644 --- a/docs/how-to/use-dev-tools-on-vc4.md +++ b/docs/how-to/use-dev-tools-on-vc4.md @@ -6,8 +6,8 @@ ## Requirements -- An Essentials 3.0 build that places Dev Tools in the room's own `Html` folder ([PepperDash/Essentials#1532](https://github.com/PepperDash/Essentials/pull/1532)) and can start its secure WebSockets on VC-4 ([PepperDash/Essentials#1533](https://github.com/PepperDash/Essentials/pull/1533)). Earlier builds show a blank page, or fail to start Debug Console and routing sessions. -- `sudo` access on the VC-4 server, to open firewall ports. +- An Essentials 3.0 build that places Dev Tools in the room's own `Html` folder ([PepperDash/Essentials#1532](https://github.com/PepperDash/Essentials/pull/1532)) and serves the Debug Console and routing feedback over HTTP polling ([PepperDash/Essentials#1536](https://github.com/PepperDash/Essentials/pull/1536)). Earlier builds show a blank page, or the Debug Console and routing never connect. +- `sudo` access on the VC-4 server, to open the Mobile Control port. ## Open Dev Tools @@ -23,41 +23,34 @@ The browser shows a security warning for the server's certificate the first time **Signing in**: VC-4 already controls access to the room, so a room usually has Crestron authentication turned off. Dev Tools then accepts any username and password. The application selector in the top bar shows the room ID where a 4-series shows `app01` to `app10`. -## Open the Firewall Ports +## How the Debug Console and Routing Connect -The pages load over HTTPS on port 443, which VC-4 already allows. The Debug Console, live routing and the Mobile Control UI each connect to Essentials on a port of their own, and the VC-4 server's firewall blocks those until you open them: +On a 4-series processor, the Debug Console and live routing each use a WebSocket on a port of its own. A VC-4 server's firewall only opens 443, so on VC-4 Dev Tools reads both over HTTPS instead. It long-polls the `debugSession/messages` and `routingFeedbackSession/messages` routes on the same port as every other page. Nothing needs opening in the firewall, and there's no separate certificate to accept. -| Port | Used by | Needed for | -| ------------------------------------------------------- | -------------------------- | ----------------------------------------------- | -| 65435–65534/tcp | Debug console WebSocket | **Debug Console** page | -| 65335–65433/tcp | Routing feedback WebSocket | Live updates on the **Routing** page | -| The Mobile Control direct server port, 50002 by default | Mobile Control UI | Opening the UI from the **Mobile Control** page | +Log lines and route changes still show up within about a second. The difference from a processor is a request every few seconds while a page is open. -The two WebSocket servers pick a random port in their range the first time a session starts, so open the whole range rather than a single port. +## Open the Mobile Control Port + +The Mobile Control UI is served by the room's Mobile Control direct server, on a port of its own: 50002 by default. Open it in the VC-4 server's firewall before using the UI from the **Mobile Control** page. On a server that uses firewalld: ```bash -sudo firewall-cmd --permanent --add-port=65335-65534/tcp sudo firewall-cmd --permanent --add-port=50002/tcp sudo firewall-cmd --reload sudo firewall-cmd --list-ports ``` -The last command should list `65335-65534/tcp` and `50002/tcp`. - -**More than one room on the server**: every room shares the WebSocket ranges, so the rule above covers all of them. Each room's Mobile Control direct server needs its own port, set in that room's `appServer` device under `directServer.port`, and its own firewall rule. - -## Accept the WebSocket Certificate +The last command should list `50002/tcp`. -The Debug Console and routing WebSockets use the room's self-signed certificate. Browsers ask you to trust a self-signed certificate separately for each port, so the first connection to a new port can fail even after you accepted the certificate for the page. +**More than one room on the server**: each room's Mobile Control direct server needs its own port, set in that room's `appServer` device under `directServer.port`, and its own firewall rule. -When that happens, Dev Tools shows a link to the WebSocket's address: +**Earlier setups**: an older version of this guide opened `65335-65534/tcp` for the WebSockets. Builds that poll don't use those ports, so you can remove the rule: -1. Open the link and accept the certificate warning. -2. Return to Dev Tools and start the session again. - -The ports are chosen again when the program restarts, so you may need to do this again after a restart. +```bash +sudo firewall-cmd --permanent --remove-port=65335-65534/tcp +sudo firewall-cmd --reload +``` ## Troubleshooting @@ -65,21 +58,15 @@ The ports are chosen again when the program restarts, so you may need to do this The browser loaded the page but not its scripts. The room is running an Essentials build, or a Dev Tools build, from before VC-4 support. Load a build that meets the [requirements](#requirements). -### "Failed to start WebSocket debug server" or "Failed to start routing feedback WebSocket server" - -The room's Essentials build looks for its certificate in `/user`, which doesn't exist on VC-4. Load a build that meets the [requirements](#requirements). - ### The Debug Console or routing never connects -Check the firewall first. On the VC-4 server: +Check that the room's Essentials build has the polling routes. This should return JSON with a `next` field: ```bash -sudo firewall-cmd --list-ports -sudo ss -ltnp | grep -E ':(653[3-9][0-9]|654[0-9][0-9]|655[0-3][0-9])\b' +curl -sk https://[vc4-server]/VirtualControl/Rooms/[RoomId]/cws/api/debugSession/messages ``` -- If the port range is missing from the firewall list, add it as shown [above](#open-the-firewall-ports). -- If `ss` shows the room listening and the range is open, [accept the WebSocket certificate](#accept-the-websocket-certificate). +A `404` means the build is from before HTTP polling. Load a build that meets the [requirements](#requirements). The browser's developer console logs `[poll]` warnings for any request that fails. ### The Mobile Control UI link won't open diff --git a/docs/reference/api-endpoints.md b/docs/reference/api-endpoints.md index e43ad82..796e780 100644 --- a/docs/reference/api-endpoints.md +++ b/docs/reference/api-endpoints.md @@ -472,6 +472,40 @@ GET /debugSession --- +### Poll Debug Messages + +**Purpose**: Read the debug log over HTTP instead of the WebSocket. Dev Tools uses this on Virtual Control (VC-4), where the WebSocket ports are firewalled + +```http +GET /debugSession/messages +GET /debugSession/messages?since=42&wait=5000 +``` + +**Query Parameters**: + +- `since` (number, optional): The `next` value from the previous response. Leave it out to start a session and get the starting cursor +- `wait` (number, optional): Milliseconds to hold the request when nothing is new, up to 10000. Default 0 + +**Response**: + +```json +{ + "next": 44, + "missed": false, + "messages": ["{\"Timestamp\":\"...\",\"Level\":\"Information\",...}", "..."] +} +``` + +**Response Fields**: + +- `next` (number): Pass as `since` on the next request +- `missed` (boolean): Some messages after `since` were dropped before they were fetched: the buffer overflowed, the session went idle for over a minute, it was stopped, or the program restarted +- `messages` (string[]): Each one is the same JSON text the WebSocket sends, in the WebSocket message format above + +**Usage**: The server returns as soon as a message arrives, so polling again right after each response gives near-live updates. Messages are only collected while a client has polled in the last minute. `POST /debugSession` ends the session + +--- + ### Stop Debug Session **Purpose**: Stop an active debug session and close WebSocket connections @@ -747,6 +781,19 @@ GET /routingFeedbackSession --- +### Poll Routing Feedback + +**Purpose**: Read routing feedback over HTTP instead of the WebSocket. Dev Tools uses this on Virtual Control (VC-4), where the WebSocket ports are firewalled + +```http +GET /routingFeedbackSession/messages +GET /routingFeedbackSession/messages?since=12&wait=5000 +``` + +**Usage**: Works like [Poll Debug Messages](#poll-debug-messages). The first request, without `since`, returns the `snapshot` message as its only message. Later responses carry the same `midpointRouteChanged`, `sinkInputChanged` and `layoutChanged` messages the WebSocket sends. When a response has `missed: true`, start again without `since` for a fresh snapshot. `POST /routingFeedbackSession` ends the session + +--- + ### Execute Routing Command **Purpose**: Make or clear a route, addressed entirely by device and port keys From 36aeeb3c86ff03db098479577efcf3929ce2292d Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 22 Oct 2026 16:26:07 -0600 Subject: [PATCH 3/6] feat: make Dev Tools usable on a phone in portrait - Top nav folds behind a toggle below 1200px, keeping the app selector and connection light in view, and closes after a page is picked. Brand shortens to "Dev Tools" on phones. - Devices: on a phone, the list takes the whole width; picking a device shows its detail with a way back. Can Read/Can Write columns hide on phones and long values wrap. - Debug Console: toolbar wraps by control, device filters show on phones (they were hidden below lg), and each log row stacks timestamp/key/level over the message. - Help: sidebar folds behind a Contents button on phones; page gets padding (it sits outside the main layout). - API Paths: the table now scrolls (it couldn't on any screen) and long names wrap. - Plugin Load Issues and Secrets scroll as a whole page on phones; category folds under the message, date columns hide, row actions wrap. - Routing: toolbar wraps by control; the minimap hides on phones. - Config viewer: no minimap and word wrap on phones, via a small useIsNarrowScreen hook. - Drawers take the full width on phones; the root uses 100dvh so phone browser toolbars don't hide the bottom of the page. Co-Authored-By: Claude Opus 5.5 --- src/features/ApiPaths.tsx | 46 +++--- src/features/ConfigFile.tsx | 6 + src/features/DebugConsole/ConsoleWindow.tsx | 17 +- src/features/DebugConsole/DebugConsole.tsx | 24 +-- src/features/DebugConsole/DebugFilters.tsx | 4 +- src/features/DeviceDetail.tsx | 24 +-- src/features/DeviceList.tsx | 27 ++- src/features/Help/Help.test.tsx | 25 +++ src/features/Help/Help.tsx | 34 +++- src/features/Help/HelpSidebar.tsx | 7 +- src/features/PluginLoadIssues.test.tsx | 4 +- src/features/PluginLoadIssues.tsx | 17 +- src/features/Routing.tsx | 14 +- src/features/Secrets.tsx | 14 +- src/features/TopNav.tsx | 173 +++++++++++--------- src/shared/ListFiltersHeader.tsx | 8 +- src/shared/hooks/useIsNarrowScreen.test.ts | 52 ++++++ src/shared/hooks/useIsNarrowScreen.ts | 29 ++++ src/styles.scss | 67 +++++++- 19 files changed, 425 insertions(+), 167 deletions(-) create mode 100644 src/shared/hooks/useIsNarrowScreen.test.ts create mode 100644 src/shared/hooks/useIsNarrowScreen.ts diff --git a/src/features/ApiPaths.tsx b/src/features/ApiPaths.tsx index 99156d3..00abce5 100644 --- a/src/features/ApiPaths.tsx +++ b/src/features/ApiPaths.tsx @@ -33,29 +33,31 @@ export const ApiPaths = () => { return (

Available API Paths

- - - - - - - - - {sorted.map((path) => ( - clickRow(path)} - className={ - 'cursor-pointer hover' + - (selectedRoute === path ? ' table-primary' : '') - } - > - - +
+
NameURL
{path.Name}{path.Url}
+ + + + - ))} - -
NameURL
+ + + {sorted.map((path) => ( + clickRow(path)} + className={ + 'cursor-pointer hover' + + (selectedRoute === path ? ' table-primary' : '') + } + > + {path.Name} + {path.Url} + + ))} + + +
{ const monaco = useMonaco(); const editorRef = useRef(null); + const isNarrow = useIsNarrowScreen(); useEffect(() => { if (!monaco) return; @@ -85,6 +87,10 @@ const ConfigFileRender = ({ config }: { config: unknown }) => { language="json" options={{ readOnly: true, + // On a phone the minimap takes a fifth of the width, and long lines need wrapping + minimap: { enabled: !isNarrow }, + wordWrap: isNarrow ? 'on' : 'off', + lineNumbersMinChars: isNarrow ? 3 : 5, }} value={JSON.stringify(config, null, 2)} onMount={handleEditorDidMount} diff --git a/src/features/DebugConsole/ConsoleWindow.tsx b/src/features/DebugConsole/ConsoleWindow.tsx index 5bb2c73..280b3fc 100644 --- a/src/features/DebugConsole/ConsoleWindow.tsx +++ b/src/features/DebugConsole/ConsoleWindow.tsx @@ -34,10 +34,17 @@ const Content = ({ filteredItems }: ConsoleWindowProps) => { : 'bg-white') } > - {message.Timestamp} - {message.Properties?.Key || 'global'} - {message.Level} - + {/* On a phone: timestamp, key and level on one line, the message on the next */} + + {message.Timestamp} + + + {message.Properties?.Key || 'global'} + + + {message.Level} + + {message.RenderedMessage} @@ -57,7 +64,7 @@ const ConsoleWindow = ({ filteredItems }: ConsoleWindowProps) => { return ( <> - + Timestamp Key Level diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index f1f89ae..546b277 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -86,10 +86,9 @@ const DebugConsole = ({

Debug Console

-
+
{!isConnected ? ( )} {doNotLoadConfigOnNextBoot?.doNotLoadConfigOnNextBoot && ( - - Message Count: {messages.length} + Message Count: {messages.length}
{certUrls && certUrls.length > 0 && ( { return (
-
+
+ + + )}
); diff --git a/src/features/Help/Help.test.tsx b/src/features/Help/Help.test.tsx index 9f8ffe7..a29bb84 100644 --- a/src/features/Help/Help.test.tsx +++ b/src/features/Help/Help.test.tsx @@ -100,4 +100,29 @@ describe('Help', () => { expect(scrolled).toContain('routing-diagram'); }); }); + + it('folds the sidebar behind a Contents button on phones, closing it once a page is picked', () => { + renderHelp('/help/reference/api-endpoints'); + + // Closed: CSS hides the sidebar below md and leaves the article showing + const sidebar = () => document.getElementById('help-contents'); + expect(sidebar()?.className).toContain('d-none d-md-block'); + + fireEvent.click(screen.getByRole('button', { name: 'Contents' })); + expect(sidebar()?.className).not.toContain('d-none'); + expect( + screen.getByRole('button', { name: 'Close contents' }) + ).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click( + within(getSidebar()).getByRole('link', { + name: 'How to Manage Stored Secrets', + }) + ); + expect(sidebar()?.className).toContain('d-none d-md-block'); + expect(screen.getByRole('button', { name: 'Contents' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); }); diff --git a/src/features/Help/Help.tsx b/src/features/Help/Help.tsx index 76f7d07..836e100 100644 --- a/src/features/Help/Help.tsx +++ b/src/features/Help/Help.tsx @@ -1,4 +1,6 @@ -import { Link, useParams } from 'react-router-dom'; +import { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { Link, useLocation, useParams } from 'react-router-dom'; import HelpArticle from './HelpArticle'; import HelpSidebar from './HelpSidebar'; import { getDocBySlug } from './docsContent'; @@ -7,13 +9,33 @@ const Help = () => { const params = useParams(); const slug = params['*'] ?? ''; const doc = getDocBySlug(slug); + const { pathname } = useLocation(); + + // Below md the sidebar folds behind a Contents button. Remember the page it was opened on, so it + // reads as closed once another page is picked. + const [contentsOpenedOn, setContentsOpenedOn] = useState(null); + const showContents = contentsOpenedOn === pathname; return ( -
-

Help

-
- -
+
+
+

Help

+ +
+
+ +
{doc ? ( ) : ( diff --git a/src/features/Help/HelpSidebar.tsx b/src/features/Help/HelpSidebar.tsx index f8d3956..1a5aed5 100644 --- a/src/features/Help/HelpSidebar.tsx +++ b/src/features/Help/HelpSidebar.tsx @@ -1,9 +1,12 @@ import { NavLink } from 'react-router-dom'; import { docsNavTree } from './docsContent'; -const HelpSidebar = () => { +const HelpSidebar = ({ open = false }: { open?: boolean }) => { return ( -