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 diff --git a/src/App.tsx b/src/App.tsx index a111d96..6032b06 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 { getRouteAppId } from './shared/functions/routeAppId'; import { AppDispatch, RootState } from './store/store'; import { @@ -81,6 +82,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/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 642e7eb..912d548 100644 --- a/src/features/Help/Help.test.tsx +++ b/src/features/Help/Help.test.tsx @@ -1,13 +1,26 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import Help from './Help'; +const BackButton = () => { + const navigate = useNavigate(); + return ; +}; + const renderHelp = (initialPath: string) => render( - } /> + + + + + } + /> ); @@ -113,4 +126,51 @@ describe('Help', () => { '/help/reference/api-endpoints#get-api-paths' ); }); + + 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' + ); + }); + + it('keeps Contents closed after Back to the article it was opened on', () => { + renderHelp('/help/reference/api-endpoints'); + const sidebar = () => document.getElementById('help-contents'); + + fireEvent.click(screen.getByRole('button', { name: 'Contents' })); + fireEvent.click( + within(getSidebar()).getByRole('link', { + name: 'How to Manage Stored Secrets', + }) + ); + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + + expect( + screen.getByRole('heading', { name: /API Endpoints Reference/i }) + ).toBeInTheDocument(); + 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..f59e0f0 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,37 @@ const Help = () => { const params = useParams(); const slug = params['*'] ?? ''; const doc = getDocBySlug(slug); + const { pathname } = useLocation(); + + // Below md the sidebar folds behind a Contents button. Close it on every navigation, including + // Back to the article it was opened on, by resetting the state during render when the path + // changes (React's alternative to an effect for this). + const [contents, setContents] = useState({ open: false, path: pathname }); + if (contents.path !== pathname) { + setContents({ open: false, path: pathname }); + } + const showContents = contents.open && contents.path === 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 ( -