Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/viz-remote-rpcs.md

nornir viz — the thin/remote read path (tab → RPC → table)

How a thin (remote) nornir-ui viz fills every tab from a running nornir-server instead of opening a local Iceberg warehouse. This is the companion to viz-modes.md (how the app chooses fat/thin) — this doc is the data plane: which tab reads which RPC reads which warehouse table.

Design — why a remote read path exists at all

The viz is source-agnostic: every tab renders from a plain data model (Timeline, Vec<ReleaseEventRow>, a KnowledgeSummary, …). In embedded (fat) mode the tab opens the workspace's Iceberg warehouse read-only and queries it directly. But a thin client (nornir viz --server …, or bare nornir viz with no local config) has no warehouse and no source checkout — those live on the server, which holds the redb single-writer lock. So a thin tab must read the same data over gRPC.

The contract every remote-backed tab follows:

The server produces exactly the data the embedded path would, serializes it (serde → JSON), and the client deserializes it into the identical model type. The tab's render + state_json code is then byte-identical across modes.

This is why the server handlers literally call the same query_* functions the embedded tab calls, and the row structs derive serde::{Serialize, Deserialize}.

The tab → RPC → table map

Tab Embedded read Thin RPC Server reads Client decoder
🕰 Timeline / TimeTravel build_timeline(wh, ws) Viz.Timeline release_lineage (+ git tags fallback) remote::fetch_timeline
🔗 Dep Graph (derived from the Timeline's lanes) (none — uses Timeline) — —
🗃 Warehouse browser wh.table_names / scan_preview Warehouse.Tables / Warehouse.Scan every table remote::fetch_tables / scan_table
🚀 Release query_release_events(wh, All) Viz.ReleaseEvents release_events remote::fetch_release_events
📡 Live Run (durable hydrate) query_release_events(wh, All) Viz.ReleaseEvents + Release.Progress stream release_events remote::fetch_release_events
🏆 Leaderboard query_agent_model_runs(wh, All) Viz.BakeoffResults agent_model_runs remote::fetch_bakeoff_results
🗺 Knowledge knowledge::scan_all(repo_dir) per repo Viz.Knowledge scans <ws_root>/git/<member> remote::knowledge_summary
🗂 Funnel funnel::Store::open Funnel.Show funnel_events remote::funnel_show
🔎 Call Graph knowledge::query::load_latest Knowledge.* / Warehouse.Scan symbol_facts etc. remote::*

Bold rows are the RPCs added in feat/viz-remote-rpcs — before them, Release, Leaderboard, Knowledge, and the Live durable hydrate were empty against a live server (the embedded read had no remote twin).

The three new RPCs (all on service Viz, all return JsonResponse)

All three reuse the existing VizTimelineRequest message (a workspace field); the served workspace is actually selected by the nornir-workspace gRPC header (set by the top-bar picker), so the request body's workspace is unused for these and left empty by the client wrappers.

Guide — how the remote read path works, end to end

  1. Mode resolution (viz-modes.md) builds Source::Remote { endpoint, token }. app.rs constructs each tab's state with its remote constructor, threading (endpoint, token, workspace_name) in:

    • ReleaseTabState::remote(endpoint, token, ws)
    • LeaderboardTabState::remote(endpoint, token, ws)
    • LiveRunState::new_remote(endpoint, token, ws)
    • KnowledgeState::new_remote(root, repos, endpoint, token, ws)
  2. First draw calls the tab's load() (or ensure_started for Live), whose Src::Remote arm calls the matching remote::* wrapper. Those run a private current-thread tokio runtime (remote::call) so the synchronous egui loop never blocks on async. Knowledge fetches on a background thread (the scan is heavy) and drains the result on a later frame, exactly like its local scan.

  3. The interceptor (remote::Auth) attaches authorization: Bearer <token> and, when set, nornir-workspace: <ws> to every call — so the server routes to the right served workspace (AppState::ws).

  4. The server handler resolves the workspace, hops to a spawn_blocking thread (warehouse scans + syn parses are blocking), runs the same query_* / scan_all the embedded path runs, serializes, and replies.

  5. Workspace switch: the top picker calls app.switch_workspace(ws), which calls set_workspace/set_workspace_name on each remote tab (re-pointing the gRPC header) and reload()s — so the next draw refetches for the new server workspace. No restart.

How to add a NEW remote-backed tab

  1. Give the row/summary struct #[derive(serde::Serialize, serde::Deserialize)] so it crosses the wire losslessly.
  2. Add an rpc Foo (VizTimelineRequest) returns (JsonResponse); to service Viz in proto/nornir.proto (the crate regenerates via protox on cargo build --features server,viz — no protoc).
  3. Implement it on VizSvc in src/bin/nornir-server.rs: resolve wctx, spawn_blocking, call the embedded query_*, serde_json::to_string.
  4. Add a remote::fetch_foo(endpoint, token, workspace) wrapper that calls the generated client and serde_json::from_strs the response.
  5. In the tab, add an Src::Remote { endpoint, token, workspace } variant + a set_workspace method; call remote::fetch_foo in its Src::Remote load arm.
  6. Wire app.rs (the match &source constructor + switch_workspace).
  7. Test: a serde wire round-trip unit test in remote.rs (to_string → from_str → assert-equal, real values), and a row in tests/viz_remote.rs's viz_remote_matrix (ephemeral server, empty-Ok) + live_full_matrix (asserts non-empty against the real fleet).

Idiot guide — "why is tab X empty when I point the viz at a server?"

A tab is empty in thin mode for exactly one of these reasons:

  1. No remote read path — the embedded tab opens the warehouse directly, but no RPC ships that data, so the remote arm just showed a "… RPC TODO" note. This was the Release / Leaderboard / Knowledge / Live bug. Fix: add the Viz.* RPC + the remote::fetch_* wrapper + wire the tab's Src::Remote arm (the recipe above). The tab's state_json["source"] now reads remote … (Viz.ReleaseEvents) instead of (… RPC TODO).
  2. No data on the server — the RPC works but the served workspace genuinely has nothing (no nornir release run yet → empty release_events; no nornir bakeoff → empty Leaderboard; an empty funnel). That's correct emptiness, not a bug — run the producing verb on the server host.
  3. Wrong workspace — the top-bar picker selects which served workspace the nornir-workspace header targets. A historical workspace (e.g. holger) has data; a fresh one doesn't. Switch the picker.

The Dep Graph tab is special: it has no RPC of its own — it lays its nodes out from the Timeline's lanes. If Timeline renders (it does, via Viz.Timeline), Dep Graph builds. A dep_graph: null only because you never opened the tab is lazy-load, not breakage.

The Knowledge tab is the odd one: it's a producer (it parses source), not a warehouse read. The thin client has no checkout, so the scan runs on the server (Viz.Knowledge) over the repos the poll loop already cloned to <ws_root>/git/<member>. If it's empty, check the server actually has those checkouts (a path-member or SSH-only workspace may have none).

Tests