Viz introspection — external state for debugging & robot testing
nornir-ui (the nornir viz GUI) is an egui app: normally you can only see its state on screen. Introspection makes the entire UI state observable from outside — no display, no screenshot OCR — so it can be debugged from a log and robot-tested headlessly.
Two channels, both always-on:
1. State dump — NORNIR_VIZ_STATE
Every frame (throttled to ~300 ms) the app serialises its full UI state to the JSON file named by $NORNIR_VIZ_STATE (default /tmp/nornir_viz_state.json). It is overwritten in place, so it always reflects what is on screen right now.
Schema:
{
"source": "server http://127.0.0.1:7878",
"connect_status": "connected http://127.0.0.1:7878: 5 workspace(s)",
"tab": "Timeline",
"all_tabs": ["Timeline","DepGraph","CallGraph","Funnel","TimeTravel",
"LiveRun","Release","Knowledge","Warehouse","Mcp","Search",
"Gates","Bench","Test","Leaderboard","Security"],
"workspace_picker": { "options": ["holger","knut","njord","skade","znippy"],
"selected": "skade", "count": 5 },
"repos": ["skade-katalog","skade"],
"knowledge": { "configured_repos": ["skade-katalog","skade"], "scanning": false,
"scanned_repos": 2, "crates": 7,
"rows": [{"repo":"skade","krate":"skade","symbols":612,"calls":1840,
"files":31,"gates":4,"heat_30d":12}, …],
"repo_totals": [{"repo":"skade","ok":true,"symbols":612,"calls":1840,
"features":4,"git_files":31}, …] },
"release": { "source": "local /…/.nornir/warehouse", "error": null, "runs": 1,
"selected_run": "run-b3-fixture",
"rows": [{"component":"znippy","op":"test","phase":"end","status":"ok",
"line":"[znippy] test end ok (1.2s) — 3 passed"}, …],
"component_status": { "znippy":"ok", "holger":"running" } },
"about": { "client_version":"0.4.11", "mode":"local", "endpoint":"",
"workspace":"skade", "warehouse":"/…/.nornir/warehouse",
"server_version":null, "server_repo_count":null, "server_note":null },
"timeline": { "releases": 42, "error": null },
"dep_graph": { "deep": false, "node_count": 3,
"direct_edges": 3, "transitive_edges": 0, "collapsed": [],
"nodes": [{"repo":"holger","col":0,"row":0,"collapsed":false}, …],
"edges": [{"from":"holger","to":"knut","class":"direct","hops":1,
"via":["knut-core"]},
{"from":"A","to":"C","class":"transitive","hops":2,"via":[]}, …] },
"search": { "form": { "query":"falkor", "corpus":"code", "sym_repo":"",
"sym_query":"build_timeline", "vec_repo":"", "vec_query":"",
"call_repo":"nornir", "call_name":"reload", "call_to":"" },
"results": { "fulltext": {"ran":true,"ok":true,"count":12},
"symbols": {"ran":true,"ok":true,"count":3},
"vectors": {"ran":false}, "calls": {"ran":false},
"index_stats": {"ran":true,"ok":true,"total":840,
"by_corpus":[["code","612"],["docs","228"]]} } },
"gates": { "form": { "repo":"skade" },
"report": {"ran":true,"ok":true,"passed":["build","clippy","test"],
"failed":[], "releasable":true},
"trace": {"ran":false} },
"bench": { "form": { "repo":"skade", "metric":"throughput_mb_s" }, "loaded":true,
"ok":true, "metrics":["latency_ms","throughput_mb_s"], "point_count":9,
"series":[{"date":"2026-06-01","version":"0.1.3","value":812.0}, …] },
"warehouse": { "workspace":"skade", "tables":["bench_runs","releases","gates"],
"table_count":3, "selected":"releases", "error":null,
"chart": {"on":false, "value_col":null, "label_col":null},
"preview": {"loaded":true,"ok":true,
"columns":["release_id","sha","gate"], "rows":42} },
"mcp": { "loaded":true, "total_calls":318, "capped":false,
"rows":[{"tool":"repo_overview","calls":120,"errors":0,"avg_ms":4.1}, …] },
"security": { "repos":["skade-katalog","skade"], "scanning":null, "busy":false,
"result": {"ran":true,"ok":true,"repo":"skade","components":214,
"vuln_crates":0,"vulns":[],
"licenses":[["MIT",180],["Apache-2.0",30]],
"cache_hits":214,"cache_misses":0} },
"warehouse_tables": ["bench_runs","releases","gates"],
"placeholders": []
}
-
connect_status—connected <ep>: N workspace(s)·CONNECT FAILED <ep>: <err>·local (no server). This is the first thing to read when the picker is empty: an emptyworkspace_picker.options+ aCONNECT FAILEDstatus means the gRPC client never reached the server (bad token/endpoint), not that there are no workspaces. -
workspace_picker— the options + selection of the workspace select box. Every other select box / populated list (warehouse_tables,repos, the tab set) is dumped the same way so a test can assert what is populated. -
placeholders— empty-state strings currently shown (e.g."no releases recorded yet"). A non-empty list = the UI is showing a "nothing here" message; a test asserts it's empty for a workspace that should have data. -
release— the 🚀 Release tab's view of therelease_eventsop DAG (B3/B4).rowsis the op-log the tab streams: one entry percomponent × op × phaseboundary for the selected run, each carrying a component-prefixedline([znippy] test end ok (1.2s)) plus the structuredcomponent/op/phase/status.component_statusis the latest status per component for that run — the exact map the lit-up dep-graph colours its nodes by (ok=green,fail=red,running=amber pulse, idle=dim).runsis how many release runs the warehouse holds (newest selected by default);erroris non-null only if the read failed. Remote mode (noRelease.EventsRPC yet) carriessourceflagged(Release.Events RPC TODO)and empty rows. -
about— the ℹ About popup's identity block (C1). Always carries SOMETHING real:client_version(the viz'sCARGO_PKG_VERSION),mode(local/remote),endpoint, the selectedworkspace, and thewarehousepath (local mode). In remote mode it additionally carriesserver_version+server_repo_countfrom a cachedHealth.Ping(queried the first time the popup opens); until queried,server_versionis null andserver_noteflags the pending ping. -
dep_graph— the 🔗 Dep Graph tab's laid-out structure (C5/C7/I3), the exact graph the rectangular-node renderer paints (LAW #6).deepis the C5 transitive-closure explode toggle (off → only the snapshot's direct edges; on → also the synthesised transitive edges).nodescarries every visible repo with its grid position (col/row— deps sit at higher columns, i.e. to the right of their consumers) and itscollapsedflag.edgescarries every drawn edge classifieddirectvstransitive(C7 — direct deps are drawn in violet, transitive in a dimmer blue with a dashed line + hop count), plushops(1 for direct, ≥2 for transitive) andvia(the cross-repo crates justifying a direct edge; empty for a synthesised transitive one).direct_edges/transitive_edgesare the counts;collapsedis the set of repos the user clicked to collapse (I3 — their exclusive subtree is hidden, but a node reachable by an alternate open path survives). The same direct-vs-transitive colour scheme should apply to the other dep views (the Timeline-tab cross-repo weave and thedepgraph_svgexport) so a transitive reach reads the same everywhere. The pure layout core issrc/viz/depgraph_layout.rs(unit-tested closure + classification); the renderer issrc/viz/graph.rs(pan = drag/scroll, zoom = Ctrl+scroll or ±, click a node = collapse/expand, deep toggle + legend in the toolbar).tests/viz_snapshots.rs::viz_depgraph_deep_transitive_inject_assertis the inject-and-assert proof: a known A→B→C chain (no direct A→C) drives the deep toggle through the app and asserts A's closure = {B,C}, A→C synthesised as a 2-hop transitive edge, node positions exposed, plus a committed exploded+coloured PNG;viz_depgraph_collapse_inject_assertproves the click-collapse hides B's exclusive child while the alternate A→C path survives. -
Form / control state (LAW #6 — "every form/input value"). The interactive tabs each emit a
formblock carrying exactly what the user has typed or picked, alongside the rendered results:search— the 🔍 Search tab'sform(the BM25query+corpusdropdown, the symbol/vector*_repo/*_queryboxes, the call-graphcall_repo/call_name/call_to) andresults(per-sub-panel{ran, ok, count}or error, the call-path rows, the index-stats totals).gates— the 🚦 Gates tab's selected/typedrepoand the rendered gatereport(passed/failedgate names +releasable) + whether atraceloaded.bench— the 📈 Bench tab'srepo+metricdropdowns and the loadedseries(per-rundate/version/value) for the chosen metric.warehouse— the 🗄 Warehouse browser'stables, theselectedtable, thecharttoggle + chosen columns, and the loadedpreview(columns+ rows).mcp— the 📞 MCP tab's per-tool usagerows(calls/errors/avg_ms).security— the 🛡 Security tab's scan-buttonrepos, which isscanning, and the renderedresult(components + per-cratevulns+licenses).
So state_json mirrors not just a tab's data but its control surface — what's in every box and dropdown right now — which is what a robot driver reads before it decides what to click.
2. Click trail — stderr
On every tab switch or workspace change the app logs one line to stderr:
nornir-viz CLICK: tab=Warehouse workspace=skade | connected http://127.0.0.1:7878: 5 workspace(s)
When the viz runs under the server's journal (or you launch it with stderr redirected to a file), this is the "what did the operator just click" trail.
3. Action trail — NORNIR_VIZ_ACTIONLOG + the 🐞 overlay
A third, richer channel records every interaction — tab switch, button click, query, RPC, error — with a millisecond stamp (src/viz/action_log.rs). It has three sinks: an in-memory ring (last 256), a per-entry nornir-viz ACTION … stderr line, and an appended file at $NORNIR_VIZ_ACTIONLOG (default /tmp/nornir_viz_actions.log), truncated once at launch. The toolbar's 🐞 log toggle opens a bottom panel that tails the file (not just this viz's ring), so it is the single shared source of truth, colour-coded by [TAG] ([ERROR] red · [BENCH] amber · [RPC] blue · [TAB] green · …).
Live bench-in-viz. Because the overlay tails the file, any other nornir process appending to the same $NORNIR_VIZ_ACTIONLOG shows up live — most usefully a running nornir bench, whose [BENCH] progress lines (see bencher.md) stream amber into the 🐞 panel as the suite runs:
# terminal A — viz on the shared action-log file, then toggle 🐞 log
NORNIR_VIZ_ACTIONLOG=/tmp/nornir_viz_actions.log nornir viz
# terminal B — same file; each bench: … line streams into the viz overlay
NORNIR_VIZ_ACTIONLOG=/tmp/nornir_viz_actions.log nornir bench run <repo>
Debugging (operator)
# terminal A — launch the viz pointed at the live server
export NORNIR_SERVER=http://127.0.0.1:7878
export NORNIR_SERVER_TOKEN=$(sudo sed -n 's/^NORNIR_SERVER_TOKEN=//p' /etc/nornir/nornir.env)
export NORNIR_VIZ_STATE=/tmp/nornir_viz_state.json
nornir viz 2> /tmp/nornir_viz.log
# terminal B — watch state + clicks as you click around
watch -n0.3 -- jq . /tmp/nornir_viz_state.json # live UI state
tail -f /tmp/nornir_viz.log | grep CLICK # the click trail
Robot / automated testing
The introspection is the assertion surface — drive the app (or just construct it headlessly) and assert on the JSON. Three levels:
-
Headless data-layer matrix (no display). Construct the app state for a workspace (the same
build()path the GUI uses —list_workspaces+load_timeline, noeframe::run) and assert onstate_json()directly: for everyworkspace_*, assertworkspace_picker.count > 0,placeholdersexcludes anyrelease_demo*/demo text,all_tabs.len() == 16, andtimeline.error == null(or a clean expected error). Parametrise acrossworkspaces × tabs × {local,remote}→ the test matrix. -
Headless button crash-hunt (no display, no GPU).
tests/viz_buttons.rsdrives the real egui UI through anegui_kittest::Harness— it walks the AccessKit tree, so no window and no GPU are needed — and clicks every clickable widget on every tab of every workspace (empty warehouses included; empty-state buttons must be safe too). Each click is wrapped incatch_unwind, so a panic is collected as{workspace, tab, button}and the hunt continues to the end before failing with a full listing. Where the data-layer matrix (1) exercises shape, this surfaces the bug that only fires when an operator actually clicks a live control — an out-of-bounds index, a non-char-boundary slice, a poisoned mutex, anunwrap()on missing data. Run:cargo test --features viz --test viz_buttons -- --nocapture. (The viz was bumped egui 0.28 → 0.33 alongside this;egui_kittestis the test harness that the new version makes available.) -
Live GUI smoke (with a display / Xvfb). Launch
nornir vizwithNORNIR_VIZ_STATEset, poll the JSON untilconnect_statusisconnected, then assert the picker + tables are populated. To exercise clicks, drive the window (fantoccini/nornir robot) and re-read the JSON after each click — the dump is the post-condition oracle.
Rule: any new select box / populated list / form field in the viz MUST be added to state_json() (in src/viz/app.rs, via each tab's own state_json()) so the matrix — and a robot driver — can see it. Introspection coverage is part of the feature, not an afterthought.
4. Control channel — NORNIR_VIZ_CMD (the drive-half)
The three channels above are read-only — they let an agent see the UI. The control channel lets it drive the UI. It's a tiny command file (the same shape as the state/action-log files): the driver writes one JSON command to $NORNIR_VIZ_CMD (default /tmp/nornir_viz_cmd.json); the running viz polls it once per frame (top of draw_ui → poll_control_channel), applies it, and deletes the file (consume-once, so a command fires exactly one time). The result then shows up in the next $NORNIR_VIZ_STATE dump. Implementation: src/viz/control.rs (the VizCommand type + cmd_path/take_pending/ write_command), applied by UrdrThreadsApp::apply_command.
Command schema (both fields optional):
{ "tab": "Test", "workspace": "korp" }
tab— make this tab active, by itsstate_json()["tab"]debug name (Timeline,DepGraph,CallGraph,Funnel,TimeTravel,LiveRun,Release,Knowledge,Warehouse,Mcp,Search,Gates,Bench,Test,Leaderboard,Security). An unknown name is logged as an[ERROR]in the action trail; a known one switches the tab and is logged as a[CLICK].workspace— select this workspace in the picker (re-scopes every server-backed tab, same as a human picking it).
Why a file (not a socket): it matches the rest of the viz's external contract (state dump file, action-log file, trace file) — observable, greppable, no port to bind, no extra runtime, and it works the same headless or live. One command at a time is plenty for a click loop: the driver writes, the viz applies it next frame, the result lands in the state dump.
5. The robot-UI-tester loop — viz.state + viz.click MCP tools
nornir-mcp (src/bin/nornir-mcp.rs) wraps both halves as MCP tools, so a Claude-Code agent can see and drive the running viz with no display:
viz.state(read) — returns the running viz's fullstate_jsonby reading$NORNIR_VIZ_STATE. "What's on screen right now?" Errors clearly if the file is absent (i.e. no viz is running). Takes no args.viz.click(drive) — writes a{tab?, workspace?}command to$NORNIR_VIZ_CMD, waits up towait_ms(default 1200) for the running viz to consume it (the file vanishes when applied), then returns{command, consumed, state}— the post-condition state so you see the effect in one call.consumed:falsemeans no live viz picked the command up within the window (the file still waits for the next viz to poll; nothing is lost).
The two tools share the env contract with the viz — point both at the same $NORNIR_VIZ_STATE / $NORNIR_VIZ_CMD (the defaults /tmp/nornir_viz_state.json and /tmp/nornir_viz_cmd.json work out of the box).
Claude-Code example
# Terminal: launch the viz so it writes state + polls commands on the defaults
nornir viz # nornir-ui; writes /tmp/nornir_viz_state.json,
# polls /tmp/nornir_viz_cmd.json each frame
# In Claude Code (nornir-mcp registered — see mcp-from-claude-code.md):
You: What's the viz showing?
→ tool viz.state → { "tab":"Timeline", "workspace_picker":{"selected":"skade"}, … }
You: Open the Test tab.
→ tool viz.click { "tab":"Test" }
→ { "command":{"tab":"Test"}, "consumed":true,
"state": { "tab":"Test", "test":{ "runs":[…], "grid":{"repos":[…]} } } }
⇒ the agent SEES the Test pane's rendered rows it just navigated to.
You: Now switch to the korp workspace and show me its warehouse tables.
→ tool viz.click { "tab":"Warehouse", "workspace":"korp" }
→ state.warehouse.tables = ["release_events","test_results", …]
This is the robot-UI-tester loop: viz.state (observe) → decide → viz.click (act) → viz.state (observe the result). Because every form field is in the state (channel 1) and every tab/workspace is drivable (channel 4), an agent can navigate and assert the viz end-to-end with no pixels.
Inject-and-assert proof (LAW #1): tests/viz_robot_loop.rs drives the loop through the real file channel — control::write_command (what viz.click writes) → one draw_ui frame (poll_control_channel consumes + applies) → state_json() (what viz.state returns) — and asserts the Test tab became active, its seeded run + grid rows came back, the command file was consumed once, and the drive landed in the action trail (viz.click tab → Test). A second test proves a workspace command re-selects the picker. The channel's own round-trip + consume-once + malformed-payload handling is unit-tested in src/viz/control.rs.
Live-window note. This pass drives a real running window via the file channel — nornir viz polls $NORNIR_VIZ_CMD every frame and the loop also keeps the egui frame ticking (request_repaint_after(250ms)) so a command lands within a frame or two even when the window is otherwise idle. The headless apply_command path is the same code the live poll calls, so the test exercises the exact production logic. A richer live-drive (setting an arbitrary text field, clicking a specific button by id, dragging a node) would need either (a) a widget-addressing scheme in the command (e.g. {set:{field:"search.query", value:"foo"}, button:"Search"}) wired into each tab's draw, or (b) injecting synthetic egui::Events into the RawInput against the AccessKit tree (the egui_kittest approach) from inside the running app — both larger than this tab/workspace pass and tracked as the next step.
Data-only diagram renderers (src/viz/diagram.rs)
Each diagram-bearing viz tab also has a headless, no-egui renderer that turns the same [Timeline] data model into exportable static SVG / Markdown — the data-only counterpart of the on-screen painting. They are compiled wherever viz::model is (the server build too), so docs, the Viz.Timeline RPC, and the CLI can render the viz diagrams without a display (CLI parity with the GUI). House style matches introspect::Graph::to_svg: graph diagrams render to static SVG via Typst (no Mermaid, no JS — they show verbatim in any web UI with no build step), tabular diagrams as plain GitHub-flavoured Markdown tables. Empty inputs return an italic placeholder rather than an empty table.
| renderer | viz tab | shape |
timeline_svg |
Timeline | static SVG timeline, one lane per repo |
lane_summary_table |
Timeline | per-repo lane summary (releases, latest sha/gate/version) |
depgraph_svg |
DepGraph | static SVG graph LR, edges labelled by the via crates |
snapshot_edge_list |
DepGraph | plain cross-repo edge / adjacency list |
gate_matrix_table |
Gates | release × repo gate-verdict matrix (✓ / ✗) |
release_versions_table |
Funnel | published crate@version per release |
bench_history_table |
Bench | per-repo primary-metric trend, newest first (limit=N) |
bench_compare_table |
TimeTravel | latest bench metric across repos |
Renderers must never open the warehouse — they take an already-built [Timeline] (the server holds the single warehouse lock), exactly like the bench_history doc-section renderer.
The Knowledge tab scans in the background (never on the UI thread)
The 🗺 Knowledge tab maps each workspace repo (one bubble per crate: symbols vs calls, sized by files, coloured by 30-day git heat). The scan is a real syn parse of every .rs under each repo plus a gix HEAD-history walk per repo — seconds of CPU on a multi-crate workspace.
Root cause of the historical ~5 s click stall: that scan ran synchronously on the UI thread inside KnowledgeState::draw() on the first frame after the tab was selected (and on every "↻ rescan"), so the whole egui frame — the entire window — froze for the wall-time of the slowest repo scan.
Fix (src/viz/knowledge.rs): the scan now runs on a dedicated std::thread::spawn (the LiveRun / Security-tab pattern), fanning out across repos with rayon inside the worker. draw() only:
- drains a finished scan from an
Arc<Mutex<Option<…>>>handoff slot (lock poisoning is recovered, never panics the UI), - arms a fresh scan (
rescan_requested) on first entry, on the ↻ rescan button, and on a workspace switch, kicking offstart_scan()only when no scan is already in flight, and - paints "⏳ scanning N repo(s) …" +
request_repaint()while the worker runs.
The UI thread never blocks. A repeat visit to the tab repaints from the cached scans instantly. Workspace switch (UrdrThreadsApp::switch_workspace) re-fetches the new workspace's member repos from the server and calls KnowledgeState::set_workspace(root, repos), which drops the stale scans, resets the status, and arms the background rescan — so the map re-scopes for the new workspace without a stall.
The tab's rendered rows live in state_json()["knowledge"] (above): rows are the exact per-crate bubbles painted (LAW #6), repo_totals the per-repo symbol/call/feature/git-file counts, and scanning is true while a background scan is in flight. tests/viz_knowledge.rs is the inject-and-assert proof: it writes two tiny known crates to a temp workspace, runs the scan, and asserts the exact symbol/call counts in state_json, then asserts a workspace switch resets
- rescopes to the new repo set.