Repository navigation
Conversation
Fix: Relay Topology Collapse and Neutral Stream Layout
Settings classes are free to hold non-finite floats (clip_max: float = np.inf is perfectly reasonable) and JSON has no literal for them. Neither ezmsg's settings metadata pass nor the dashboard's _json_safe adapter treated them specially, so they reached the transport layer intact, where the two paths disagreed: - REST: JSONResponse renders with allow_nan=False, so /api/settings and /api/snapshot returned 500 with "Out of range float values are not JSON compliant" -- one inf anywhere took the whole dashboard down. - WebSocket: envelopes go through pydantic model_dump(mode="json"), whose default ser_json_inf_nan="null" silently replaced the values with null. Non-finite floats now travel as the string tokens "Infinity", "-Infinity" and "NaN" (spelled so the browser recovers them with Number(token)): - backend/json_encoding.py holds the encode/decode/sanitize helpers. - adapters._json_safe encodes on the way out, which covers both transports since it runs before pydantic sees the payload. - DashboardJSONResponse re-renders with the sanitizer if a payload built outside the adapters trips allow_nan=False. - update_setting_field decodes tokens back to floats before handing them to ezmsg, which writes the value into the dataclass as-is. Only fields that currently hold a number are decoded; anything else is rejected with a 422 so a string can never land in a float field. - The settings panel displays the tokens, accepts inf/-inf/nan (and the Python spellings) in float fields, and encodes them back on apply. Float fields use a text input because a number input cannot hold "Infinity"; integer fields keep the number input and require a finite value. examples/nonfinite_float_settings.py runs a real system with inf/-inf/nan settings and probes both transports end to end. Fixes #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs `uv tool run ruff check src` with whatever ruff is current, and the repo had no [tool.ruff] section, so ruff 0.16's wider default rule set turned the Lint step red on dev independently of any code change. Pin the rule set the way ezmsg-sigproc does (select = E, F, I, W with an isort first-party/third-party split), pin ruff itself in the lint dependency group, and add the matching ruff-check/ruff-format pre-commit hooks. The workflow already matched sigproc's, so it needs no change. The only code effect is import ordering, applied with `ruff check --fix`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff format` is one of the pre-commit hooks adopted from ezmsg-sigproc, whose tree is already format-clean. Run it once here so the hook starts clean instead of reformatting unrelated files on the next contributor's first commit. Formatting only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard serves `src/ezmsg/dashboard/_web`, which is a committed copy of `frontend/dist` produced by `python -m ezmsg.dashboard.build_frontend`. It had not been refreshed since the relay rendering work, so the settings panel changes in this branch -- non-finite floats rendered as Infinity/-Infinity/NaN -- were invisible to anyone who installed the package rather than running the dev server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run build` (tsc -b && vite build) has been failing on dev: the `streamOwnerByAddress` map's value type and `registerStreamOwner`'s parameter type were two hand-maintained copies of the same union, and only the parameter gained "scope_collection_stream", so every call to `.set()` was a type error. The map already held those values at runtime -- only the type was wrong. Declare the union once as `StreamOwnerKind`/`StreamOwner` and use it in both places, which fixes the errors and removes the drift that caused them. Nothing in CI ran `tsc`: vitest strips types without checking them, and the e2e suite serves the app with `vite dev`, which does the same. Build the frontend in the unit-tests job so the next type error is caught here rather than by whoever next runs a release build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard serves `src/ezmsg/dashboard/_web`, a committed copy of `frontend/dist`, and nothing verified the two agreed. They had been out of sync since the relay rendering work, so the packaged dashboard served a bundle months older than the source -- `npm run build` alone does not update it. `build_frontend --check` already existed for exactly this; run it right after the build step added in this branch, with the fix spelled out in the failure message. Requires `uv sync` because importing the package pulls in FastAPI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a unit in the topology already snaps the Settings panel to that component. Do the same in Publishers: reveal every row whose owning component is the selected unit, and scroll the first one into view. The panel already had this machinery for publisher and subscriber stream selections, so the three focus branches now share one `revealRows` helper rather than repeating the expand/scroll block a third time. Two deliberate choices: - The Publishers section's collapsed state is left alone. Collapsing a section is a deliberate "maximize the other one" action, so a unit click should not undo it -- the panel is unmounted while collapsed and picks the request up when it is expanded again. - A focus request that matches no rows is not marked handled, so a unit whose publishers only show up in a later snapshot still gets focused then. Collections are untouched: their streams belong to their child units, and selecting one continues to focus Settings only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split between the topology and the Publishers/Settings inspector was only adjustable through the `Inspector Width` number field in global settings. Make the border between them a real divider: drag it to trade width between the two, double-click to return to the default, or focus it and use the arrow keys (`Home`/`End` jump to the extremes). The width still lives in `globalSettings.inspectorWidthPx`, so it persists and stays in sync with the existing number field, and the 360-900px clamp -- until now spelled out separately in three places -- is a single `clampInspectorWidth`. While dragging, the live width is held in component state and only committed to global settings on release, so a drag is one localStorage write rather than one per frame. The handle overlays the existing border rather than taking a grid column, so nothing shifts when it appears. Resizing deliberately does not re-fit the topology view, matching what the number field has always done -- a re-fit would throw away the user's pan/zoom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the publishers focus and draggable divider changes so the packaged dashboard serves them, and keeps the new CI staleness check green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+C ended in a KeyboardInterrupt traceback, and with a browser attached it took ten seconds to get there. Two causes: uvicorn shuts the application down gracefully on SIGINT and then re-raises the signal so that callers see conventional interrupt semantics. For a foreground server that only amounts to a traceback printed after a clean shutdown, so serve_dashboard now swallows it and logs "Dashboard stopped." instead. Real failures from `server.run()` still propagate. The delay was the event stream. The `/ws/events` handler only woke when the graph service yielded, so a quiet stream held the connection -- and with it the server shutdown -- until the next heartbeat, ten seconds away. Long enough that Ctrl+C looks hung and gets pressed again, and the second press tears the loop down mid-flight, which is where the messiest output came from. The handler now watches for the disconnect concurrently and stops as soon as one arrives. Measured with a live websocket: 10.05s to shut down, before; 0.16s, after. A second Ctrl+C no longer has time to land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: repair the frontend type build and check it in CI
feat: inspector UX — publishers follow unit selection, draggable divider
fix: shut the dashboard down quietly on Ctrl+C
A unit with dynamic settings gets a publisher on `<unit>/INPUT_SETTINGS`, created lazily the first time one of its settings is patched. It is a control-plane endpoint: it carries settings updates and publishes nothing the rest of the time, so it sits in the publishers list at 0.0 Hz looking exactly like a data publisher -- including offering a profiling trace that can never produce a sample, which reads as a broken trace rather than an idle topic. Hide them by default, with `Show settings channels in Publishers (debug)` in global settings to bring them back. Found on a live pipeline where one of 88 publishers was a settings channel: `HUB1/SDA/INPUT_SETTINGS`, created twenty minutes after the process it belongs to, at the moment SDA was first patched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilt so `src/ezmsg/dashboard/_web` carries the settings-channel filter. The staleness check added in #6 is now on dev and fails without this, which is exactly the drift it was added to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`index.html` went out with no `Cache-Control`, so browsers applied heuristic freshness and reused the cached shell without revalidating. Because Vite emits content-hashed filenames, that shell keeps naming the bundle it was built against: a rebuilt, restarted dashboard serves the old app indefinitely and nothing short of a hard reload discovers the new one. A live session showed only `/api/*` traffic -- the shell had last been fetched the day before. Send the no-store headers that the JSON routes already use with the shell, and mark `/assets/*` `immutable`: those names are content-hashed, so they can never go stale, which is what makes an uncacheable shell cheap. Fixing this also required repairing the SPA fallback it shares a code path with. StaticFiles signals a miss by *raising* Starlette's HTTPException rather than returning a 404, so the `if response.status_code != 404` branch was unreachable and client-side routes 404ed instead of serving the shell. Catch the exception instead -- Starlette's class, not FastAPI's subclass, which is what is raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StaticFiles` hands `get_response` a path normalized for the host OS, so on Windows it arrives as `assets\index-abc123.js` and the `assets/` prefix check never matched: the immutable header was silently dropped and the Windows CI jobs failed on it. Normalize separators before deciding. Going through `Path.as_posix()` looks tidier but only reads a backslash as a separator when running on Windows, which would have left the behaviour untestable anywhere else -- the parametrized test covers both separator styles on every platform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: stop the browser serving a stale app shell
Three findings from the dev->main review, plus a docs gap.
Non-finite profiling metrics displayed as 0. `toNumber` mapped anything that
was not a finite number to zero, and non-finite floats now arrive as tokens, so
a rate of NaN rendered as `0.0 Hz` -- indistinguishable from an idle publisher,
which is precisely the confusion that makes an empty trace look broken. Decode
tokens instead and let the value stay non-finite; `toFixed` spells it out
("Infinity Hz") and the consumers that do arithmetic on rates already guard.
Selecting a unit collapsed a publisher row that was capturing, hiding its stop
control while the capture kept running. Keep active trace rows expanded when
the list refocuses.
The inspector's 900px ceiling was arbitrary, and on a narrow window it still
allowed the topology to be squeezed to a sliver. Bound the inspector by what
the topology needs instead: at least 480px, enforced in the grid template too,
so a width stored on a wide monitor cannot squash the graph after a resize.
The global settings list in the user guide did not mention the settings-channel
toggle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: review follow-ups on metrics, trace controls and inspector width
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
30 commits, 42 files, +2812/−487. Closes #4.
Everything here has been through its own PR (#3, #6, #8, #10, #11, #12, #13) plus a holistic review of this diff, whose findings were folded back in as #13.
Things to know before upgrading
1. Everyone needs one hard reload, once
index.htmlwas served with noCache-Control, so browsers reused the cached shell indefinitely — and because Vite emits content-hashed filenames, a stale shell keeps naming the bundle it was built against. That is fixed (#12), but the fix can only take effect once the browser fetches the new shell. Anyone with an existing tab should hard reload (Cmd/Ctrl+Shift+R) after upgrading. After that it never needs doing again: the shell isno-storeand/assets/*isimmutable.This is also why the previous release's features could look missing: the packaged bundle under
src/ezmsg/dashboard/_webhad drifted ~2 months behind the frontend source. It is current here, and CI now fails when it drifts.2. Non-finite floats change shape on the wire
inf,-infandnanin settings and profiling payloads now travel as the strings"Infinity","-Infinity","NaN"on/api/settings,/api/snapshotand/ws/events. Previously the REST routes returned 500 and the websocket silently replaced the values withnull, so any pipeline withclip_max: float = np.inftook the dashboard's settings view down entirely (#4).The bundled UI displays and accepts these (typing
inf,-inf,nanor the JavaScript spellings into a float field works, and patches are decoded back to floats server-side). If you have your own client parsing these endpoints, it will now see strings where it previously saw numbers or a failed request.3. The Publishers pane hides settings channels
A unit with dynamic settings gets a publisher on
<unit>/INPUT_SETTINGSthe first time one of its settings is patched. It is a control-plane endpoint that publishes nothing afterwards, so it sat in the list at0.0 Hzoffering a profiling trace that could never produce a sample. Those rows are hidden by default; re-enable with Show settings channels in Publishers (debug) in global settings.4.
Ctrl+CbehavesIt used to end in a
KeyboardInterrupttraceback, and with a browser attached it took ten seconds to get there — long enough that people pressed it twice, which tore the loop down mid-shutdown and made the output worse. Shutdown is now prompt and quiet: measured 10.05s → 0.16s with a live websocket, exit code 0.What else is new
Inspector
Home/Endjump to the extremes. The width persists and still appears asInspector Widthin global settings.Correctness
StaticFilessignals a miss by raising rather than returning a 404.Infinity/NaNrather than0.0 Hz, which previously looked identical to an idle publisher.dev).Repo health (no user impact, but it is why this diff is trustworthy)
ezmsg-sigproc(E,F,I,W, line-length 120) with the matching pre-commit hooks, plus a one-timeruff formatpass. CI's lint step had silently gone red ondevwhen ruff widened its defaults.npm run buildwas failing ondevwith two type errors; fixed, and CI now builds the frontend, because neithervitestnor the e2e dev server typechecks.src/ezmsg/dashboard/_webdoes not match a fresh build. It caught real drift on the first PR after it landed.Verification
pytest57 ·npm test40 ·npm run test:e2e32 (incl. visual baselines) · production build clean · ruff and format clean · packaged assets in sync with a fresh build.Beyond the suites, the significant paths were exercised against a live 90-component pipeline (3 processes, 192 topics, 572 KiB snapshots): REST and websocket payloads parse under a browser-strict JSON parse, settings render and patch, profiling traces stream and plot, and
Ctrl+Cexits cleanly with seven connections open.🤖 Generated with Claude Code