nordisk/nornir
src/viz/app.rs
//! egui app — paints the lanes + edges and the "time machine" panel.
use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Result;
use eframe::egui::{
self, Align2, Color32, ColorImage, CornerRadius, FontId, Pos2, Rect, Sense, Stroke,
TextureHandle, TextureOptions, Vec2,
};
use uuid::Uuid;
use super::graph::{DepGraphView, draw_dep_graph};
use super::model::{Timeline, load_timeline};
use super::timetravel::{TimeTravelState, draw_timetravel};
// nornir's semantic ramps over the SHARED facett-core palette (L5) — the palette
// itself is `facett_core::theme::Theme`, re-exported by `viz::facett_theme`.
use super::facett_theme::ThemeExt;
const SPLASH_DURATION_SECS: f32 = 2.5;
const SPLASH_FADE_SECS: f32 = 1.0;
const WATERMARK_ALPHA: u8 = 22;
/// Human build identifier for the viz client, e.g.
/// `v0.4.11 (a1b2c3d, built 2026-06-12 22:50 UTC)`. The SHA + build time come
/// from `build.rs` (`NORNIR_BUILD_SHA`/`NORNIR_BUILD_EPOCH`); they change every
/// build, so this is how you tell a fresh client from a stale one. Shown in the
/// window title and the ℹ About popup, and emitted in `state_json["about"]`.
pub fn client_build() -> String {
let ver = env!("CARGO_PKG_VERSION");
let sha = option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown");
let epoch: i64 = option_env!("NORNIR_BUILD_EPOCH")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let when = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default();
if when.is_empty() {
format!("v{ver} ({sha})")
} else {
format!("v{ver} ({sha}, built {when})")
}
}
// Media moved to `.nornir/assets/` in 1e8d062; the old `assets/` path 404'd and
// broke the `viz` build.
static NORNIR_WEBP_BYTES: &[u8] = include_bytes!("../../.nornir/assets/nornir.webp");
fn decode_nornir() -> Option<ColorImage> {
static CACHE: OnceLock<Option<ColorImage>> = OnceLock::new();
CACHE
.get_or_init(|| {
let img = image::load_from_memory(NORNIR_WEBP_BYTES).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
Some(ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
})
.clone()
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Tab {
/// 🧬 The root/server pane — workspace lifecycle (add/kill/populate/refresh)
/// + server status. Not a workspace-scoped data view; the home of root ops.
Nornir,
Timeline,
DepGraph,
CallGraph,
Architecture,
/// 🚇 The Metro tab — nornir's button → gRPC → warehouse
/// coverage CHAINS drawn as transit lines via facett-graphview::MetroView,
/// lit by the latest surface_coverage run (S5b).
Metro,
/// 🧬 The Time-Helix tab — each repo's release timeline wound as a glowing
/// helix advancing along a time axis, cross-repo deps bridging the coils, via
/// facett-helix::HelixView. Falls back to the demo helix on an empty workspace.
Helix,
/// 🩸 The Bloodstream tab — the whole workspace as an organic circulatory
/// system (crates=organs seated anatomically, dep edges=arteries, the
/// call-chain=streaming blood), with recursive drill-down into a crate's
/// non-workspace transitive crates.io closure. Built natively over
/// `bloodstream::Organism`; never blank (defaults to nornir's own body).
Bloodstream,
Funnel,
TimeTravel,
LiveRun,
Release,
Knowledge,
Warehouse,
/// 🏢 The Warehouse Deck — the whole warehouse as a facett `WarehouseDeck`:
/// a wall of N panes (several 3D graphs + grids + charts at once).
WarehouseDeck,
Mcp,
Search,
Gates,
/// 💬 The Chat tab — a "local Claude" / agent-agnostic chat panel wired to the
/// ACTIVE local model (an in-process candle GGUF loaded from the /mnt/work4t
/// cache), plus the MCP-to-nornir client config an agent uses. EPIC H / #45.
Chat,
Security,
/// ⚒ The Dwarves tab — nornir's own **build runner** (`src/viz/dwarves_tab.rs`):
/// reflects a repo's `BuildSurface` into choosable rows (binary × features ×
/// profile × target), runs `nornir build --attest --seal`, and test-boots the
/// result as a job. Renders without a workspace (demo content: holger's
/// build-space).
///
/// It used to carry a second ⚒ Forge pane that ran dwarves' brokkr pipeline
/// engine IN-PROCESS off a hard-coded demo `brokkr.toml`. dwarves-demo's own
/// Pipeline tab is that pane, on the shared facett viewer and driven by the real
/// engine — so this was a twin, and it was one of the call sites forcing nornir's
/// `dwarves` path-dep (the cargo cycle that blocked the fat forge). The forge UI
/// lives with the forge; a running forge is reachable at `dwarves-server` :7900.
Dwarves,
/// 📦 The built-in (married) holger registry pane — the in-process dev pair
/// (/cache crates.io-mirror + /sparring rehearsal registry), read via
/// holger's functional API. Always a tab; its pane content is gated behind
/// `embed-holger` (degrades to a note when holger isn't embedded).
Holger,
Manual,
/// 🤖 The Claude tab — a facett-Elm conversation pane over the Anthropic
/// Messages API (`src/viz/claude_tab.rs`). Renders without a workspace (it
/// talks to the API, not the warehouse); the HTTP call is behind the
/// `ClaudeTransport` seam so a key-absent host shows "set ANTHROPIC_API_KEY"
/// and CI drives a fake transport.
Claude,
/// 🧠 The Models tab — browse known LLM / embedding models, pick a device
/// (CPU/CUDA/ROCm), and DOWNLOAD one as a nornir job (`src/viz/models_tab.rs`).
/// The download runs behind the `ModelDownloader` seam (real = `ollama pull`;
/// CI drives a fake), each as a job with a progress row + cancel.
Models,
/// 🎛 **Settings** — the preferences pane, and the ONE home of the viz's LOOK &
/// FEEL. The app-wide facett palette picker used to sit on the root top bar,
/// wedged between the 🐞 action-log toggle and ℹ About; appearance is a
/// preference, not navigation, so it moved here. This mirrors dwarves-demo's
/// `settings` tab (same 🎛 glyph, same split: root = navigation + actions,
/// Settings = appearance + preferences), so an operator moving between the
/// suite's apps finds the palette control in the same place in each.
///
/// Renders WITHOUT a workspace or warehouse — it reads nothing but the app's own
/// palette — so it sits with Claude/Models ahead of the onboarding guard.
Settings,
}
/// Index the top-bar tab strip wraps onto its second row. SINGLE source of truth
/// (bug #23): both the rendered bar (`draw_top_bar`) and the readable-data dump
/// (`top_bar_state`) pass THIS so the dump can never drift from the render again.
pub(crate) const TOP_BAR_SPLIT: usize = 10;
impl Tab {
/// Every tab, in header order — for the external state dump.
pub const ALL: [Tab; 26] = [
Tab::Nornir,
Tab::Timeline,
Tab::DepGraph,
Tab::CallGraph,
Tab::Architecture,
Tab::Metro,
Tab::Helix,
Tab::Bloodstream,
Tab::Funnel,
Tab::TimeTravel,
Tab::LiveRun,
Tab::Release,
Tab::Knowledge,
Tab::Warehouse,
Tab::WarehouseDeck,
Tab::Mcp,
Tab::Search,
Tab::Gates,
Tab::Chat,
Tab::Security,
Tab::Dwarves,
Tab::Holger,
Tab::Manual,
Tab::Claude,
Tab::Models,
// 🎛 Settings LAST — appended, so every tab above keeps the header index the
// persisted tab, deep-links and robot `active_tab` assertions already use.
Tab::Settings,
];
/// Resolve a tab from its `state_json()["tab"]` debug name (the names in
/// [`Tab::ALL`]). `None` for an unknown name. Used by the robot-UI-tester
/// control channel (`viz.click`) and the headless test injectors.
pub fn from_name(name: &str) -> Option<Tab> {
Tab::ALL.into_iter().find(|t| format!("{t:?}") == name)
}
/// The STABLE pane id (= the `Tab` debug name) used as an AccessKit `id_salt`
/// at this pane's render root and as the registry key in
/// [`super::surfaces::viz_surfaces`]. Distinct from [`Tab::label`] (the
/// human-readable header glyph+words): the id is the machine-addressable salt
/// the atom-walk drift gate matches on, so it must never collide across panes.
pub fn id(self) -> &'static str {
// The Debug impl is the canonical stable name (state_json / click contract).
match self {
Tab::Nornir => "Nornir",
Tab::Timeline => "Timeline",
Tab::DepGraph => "DepGraph",
Tab::CallGraph => "CallGraph",
Tab::Architecture => "Architecture",
Tab::Metro => "Metro",
Tab::Helix => "Helix",
Tab::Bloodstream => "Bloodstream",
Tab::Funnel => "Funnel",
Tab::TimeTravel => "TimeTravel",
Tab::LiveRun => "LiveRun",
Tab::Release => "Release",
Tab::Knowledge => "Knowledge",
Tab::Warehouse => "Warehouse",
Tab::WarehouseDeck => "WarehouseDeck",
Tab::Mcp => "Mcp",
Tab::Search => "Search",
Tab::Gates => "Gates",
Tab::Chat => "Chat",
Tab::Security => "Security",
Tab::Dwarves => "Dwarves",
Tab::Holger => "Holger",
Tab::Manual => "Manual",
Tab::Claude => "Claude",
Tab::Models => "Models",
Tab::Settings => "Settings",
}
}
/// The full header text (icon glyph + words) the top-bar paints for this tab
/// — the exact button label a user reads and a robot/AccessKit click matches
/// by string. Drives the [`TabItem`](super::facett_ui::TabItem) labels.
pub fn label(self) -> &'static str {
match self {
Tab::Nornir => "🧬 nornir",
Tab::Timeline => "🧵 Timeline",
Tab::DepGraph => "🔗 Dep Graph",
Tab::CallGraph => "🕸 Call Graph",
Tab::Architecture => "🏛 Architecture",
Tab::Metro => "🚇 Metro",
Tab::Helix => "🧬 Time Helix",
Tab::Bloodstream => "🩸 Bloodstream",
Tab::Funnel => "🗂 Funnel",
Tab::TimeTravel => "⏳ Time Travel",
Tab::LiveRun => "📡 Live Run",
Tab::Release => "🚀 Release",
Tab::Knowledge => "🗺 Knowledge",
Tab::Warehouse => "🗄 Warehouse",
Tab::WarehouseDeck => "🏢 Wh Deck",
Tab::Mcp => "📞 MCP",
Tab::Search => "🔍 Search",
Tab::Gates => "🚦 Gates",
Tab::Chat => "💬 Chat",
Tab::Security => "🛡 Security",
Tab::Dwarves => "⚒ Dwarves",
Tab::Holger => "📦 Holger",
Tab::Manual => "📖 Manual",
Tab::Claude => "🤖 Claude",
Tab::Models => "🧠 Models",
// 🎛 — the SAME glyph dwarves-demo's `tab_icon` gives its `settings` tab,
// so the preferences pane wears one face across the suite.
Tab::Settings => "🎛 Settings",
}
}
/// The [`TabItem`](super::facett_ui::TabItem) list for the top-bar
/// [`TabBar`](super::facett_ui::TabBar), in header order — id = the debug
/// name (`state_json`/click contract), label = [`Tab::label`].
fn tab_items() -> Vec<super::facett_ui::TabItem> {
Tab::ALL
.iter()
// 🔗 Dep Graph + 🕸 Call Graph are CONSOLIDATED under 🏛 Architecture
// (its `Deps` and `Metro` sub-views), so they are no longer top-level
// header buttons — that removed the duplicate where Call Graph rendered
// the same surface as Architecture. The `Tab::{DepGraph,CallGraph}`
// variants live on for the control channel / back-compat links.
.filter(|t| !matches!(t, Tab::DepGraph | Tab::CallGraph))
.map(|t| super::facett_ui::TabItem::new(format!("{t:?}"), t.label()))
.collect()
}
}
impl std::fmt::Debug for Tab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Tab::Nornir => "Nornir",
Tab::Timeline => "Timeline",
Tab::DepGraph => "DepGraph",
Tab::CallGraph => "CallGraph",
Tab::Architecture => "Architecture",
Tab::Metro => "Metro",
Tab::Helix => "Helix",
Tab::Bloodstream => "Bloodstream",
Tab::Funnel => "Funnel",
Tab::TimeTravel => "TimeTravel",
Tab::LiveRun => "LiveRun",
Tab::Release => "Release",
Tab::Knowledge => "Knowledge",
Tab::Warehouse => "Warehouse",
Tab::WarehouseDeck => "WarehouseDeck",
Tab::Mcp => "Mcp",
Tab::Search => "Search",
Tab::Gates => "Gates",
Tab::Chat => "Chat",
Tab::Security => "Security",
Tab::Dwarves => "Dwarves",
Tab::Holger => "Holger",
Tab::Manual => "Manual",
Tab::Claude => "Claude",
Tab::Models => "Models",
Tab::Settings => "Settings",
};
f.write_str(s)
}
}
/// Where the timeline comes from: a local Iceberg warehouse (embedded, opens
/// it directly) or a running `nornir-server` (thin client over the
/// `Viz.Timeline` gRPC). Both yield the same [`Timeline`], so the rest of the
/// app is source-agnostic.
pub enum Source {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
impl Source {
fn load(&self, workspace: &str) -> Result<Timeline> {
match self {
Source::Local(root) => load_timeline(root, workspace),
Source::Remote { endpoint, token } => {
super::remote::fetch_timeline(endpoint, token, workspace)
}
}
}
}
pub struct UrdrThreadsApp {
source: Source,
source_label: String,
workspace_name: String,
/// Local checkout root the Knowledge/Security tabs scan `workspace_root/<repo>`
/// under. Kept so a workspace switch can re-scope those local-disk scanners.
workspace_root: PathBuf,
/// Workspaces offered in the picker (server-registered for remote; just the
/// current one for local). Switching re-scopes every server-backed tab.
workspaces: Vec<String>,
timeline: Result<Timeline>,
selected: Option<Uuid>,
/// Repo selected in the Dep Graph tab (for the cargo-deps drill-down panel).
dep_selected_repo: Option<String>,
/// Dep Graph tab view state — deep-closure toggle (C5), direct/transitive
/// edge classification (C7), pan/zoom + click-collapse navigation (I3).
dep_view: DepGraphView,
last_reload: std::time::Instant,
tab: Tab,
/// 🎬 The declarative master-shell runtime state (fold / draggable-split ratios
/// / active route) for [`super::shell_scene::nornir_shell_scene`]. The scene
/// itself is rebuilt cheaply each frame (28-pane tree); `nav` is the live state
/// `scene::render` reads and any interaction mutates. Observable in
/// `state_json()["scene"]`.
nav: facett_app::scene::NavState,
/// The last `scene::render` observable facts (device class, per-pane rects,
/// live split ratios, folded map, slot states) — folded into `state_json`.
last_scene_render: serde_json::Value,
started_at: std::time::Instant,
nornir_tex: Option<TextureHandle>,
timetravel: TimeTravelState,
live: super::live::LiveRunState,
/// 🚀 Release tab — the `release_events` op DAG (op-log + lit-up dep-graph).
release: super::release_tab::ReleaseTabState,
/// 💬 Chat tab (EPIC H / #45) — a local-model chat panel wired to the ACTIVE
/// in-process Generator (candle GGUF from /mnt/work4t) + the MCP-to-nornir
/// client config an agent uses.
chat: super::chat_tab::ChatTabState,
knowledge: super::knowledge::KnowledgeState,
warehouse: super::warehouse_tab::WarehouseBrowser,
/// 🏢 Warehouse Deck — the whole warehouse as a facett `WarehouseDeck` wall.
warehouse_deck: super::warehouse_deck_tab::WarehouseDeckPane,
callgraph: super::callgraph::CallGraphState,
/// 🏛 Architecture tab — the EPIC ARCH wiring board (UI-component ↔ gRPC ↔
/// core-fn ↔ warehouse-table), merged from each member's persisted board
/// (local) or fetched over `Viz.Architecture` (remote).
arch: super::arch_tab::ArchTabState,
/// 🚇 Metro tab — hosts the facett `MetroView`, fed the
/// `metro_feed::build_metro_map` producer over the arch tab's loaded facts (S5b).
metro: super::metro_tab::MetroTabState,
/// 🧬 Time-Helix tab — hosts the facett `HelixView`, fed a `Scene` built from
/// the selected workspace's release timelines (falls back to the demo helix).
helix: super::helix_tab::HelixTabState,
/// 🩸 Bloodstream tab — the workspace as an organic circulatory system, painted
/// natively over `bloodstream::Organism` with recursive external-dep drill-down.
bloodstream: super::bloodstream_tab::BloodstreamView,
funnel: super::funnel_tab::FunnelTabState,
mcp: super::mcp_tab::McpTab,
/// Server-backed clickable tabs (remote mode only).
search: super::ops_tabs::SearchState,
gates: super::ops_tabs::GatesState,
/// 📡 Build-info LIVE-TAIL (slice-5 capstone): subscribes the §f3 build-info
/// Flight stream per sub-job `step_id` (`build::flight_sink::subscribe`) and
/// folds it into per-step status transitions + the log/metric tail. Rendered
/// in the 📡 Live Run tab beside the release-run feed; folded into
/// `state_json["build_info_live"]`.
build_info_tail: super::build_info_live::BuildInfoTail,
security: super::security_tab::SecurityTab,
/// ⚒ Dwarves tab — the 🔨 build runner (repo `BuildSurface` → choosable rows →
/// `nornir build --attest --seal`). Seeded with holger's build-space as demo
/// content.
dwarves: super::dwarves_tab::DwarvesTabState,
/// 📦 Holger registry pane — the built-in (married) in-process holger dev
/// pair (/cache + /sparring), read via holger's functional API. Only present
/// when holger is actually embedded; the 📦 tab degrades to a note otherwise.
#[cfg(feature = "embed-holger")]
holger: super::holger_tab::HolgerTab,
/// 🚦 Pre-flight capability checks, surfaced as a popup after the splash.
preflight: super::preflight::Preflight,
ws_panel: super::ops_tabs::WorkspacePanel,
/// 🧬 The `nornir` root pane — server + workspace-lifecycle operations
/// (add/kill/populate/refresh + server status). Server-backed.
nornir_root: super::nornir_tab::NornirRootView,
/// 🧬 The RELEASE DASHBOARD shown at the top of the nornir tab: a Release
/// button + a gate-overlaid dependency graph (shared `graph_render`), so
/// go/no-go release state is one glance.
nornir_dashboard: super::nornir_dashboard::NornirDashboard,
/// 📖 The Manual tab — nornir's own `docs/book-svg/` rendered via the
/// facett-docview vector viewer (lazy-loaded on first show).
manual: super::manual_tab::ManualTab,
/// 🤖 The Claude tab — a facett-Elm conversation pane over the Anthropic
/// Messages API, behind the `ClaudeTransport` seam (fake-driven in CI).
claude: super::claude_tab::ClaudeTab,
/// 🧠 The Models tab — browse/download LLM+embedding models as jobs behind
/// the `ModelDownloader` seam (fake-driven in CI).
models: super::models_tab::ModelsTab,
/// Configured repo names (for the Gates/Bench repo pickers).
repos: Vec<String>,
/// Auto-reload the timeline (and refresh the warehouse table list) on a
/// timer so a server-monitored workspace animates as it republishes.
auto_refresh: bool,
refresh_secs: u64,
/// Human-readable connection status (set at build: "connected: N
/// workspace(s)" / "CONNECT FAILED: …" / "local (no server)"). Surfaced in
/// the UI header AND the external state dump so the test matrix sees it.
connect_status: String,
/// Throttle for the external state dump (`NORNIR_VIZ_STATE`).
last_dump: std::time::Instant,
/// Last (tab, workspace) we logged a click for — change detection.
last_click: (Tab, String),
/// TOTAL user-action debug log (ring buffer + stderr + file sink). Always-on;
/// every tab switch / button / query / RPC pushes here. See `action_log.rs`.
action_log: super::action_log::ActionLog,
/// Whether the bottom action-trail overlay panel is shown (toggle in the bar).
show_action_log: bool,
/// Whether the ℹ About popup window is open (toggle in the top bar).
show_about: bool,
/// Cached `Health.Ping` server identity (version + repo count), fetched lazily
/// the first time the About popup opens in remote mode. `None` until queried;
/// `Some(Err)` if the ping failed.
server_info: Option<Result<super::remote::ServerInfo, String>>,
/// The **active facett palette** (C8) — the single app-wide theme every pane
/// paints with. The top-bar picker mutates it; on change we re-apply egui
/// `Visuals` and broadcast it to every tab (`broadcast_palette`) so the whole
/// viz re-skins together. Emitted in `state_json["palette"]` (LAW #6) so a
/// switch is observable headlessly.
palette: super::facett_theme::Theme,
/// Whether `palette`'s visuals have been pushed to the egui `Context` for the
/// current value (so we only call `set_visuals` on change, not every frame).
palette_applied: bool,
/// **LAW4b** — `true` when a `ViewportCommand::Screenshot` has been fired but
/// the corresponding `Event::Screenshot` has not yet been consumed. Reflected
/// in `state_json["screenshot"]["pending"]` so a robot-driver can poll for
/// completion without a screen.
screenshot_pending: bool,
/// **LAW4b** — path written by the most recent screenshot (or `None` before
/// the first one). Reflected in `state_json["screenshot"]["last_path"]`.
screenshot_last_path: Option<String>,
/// **LAW4b** — `out_path` carried over from the pending screenshot command,
/// consumed when `Event::Screenshot` arrives.
screenshot_pending_path: Option<String>,
/// "Ask at close" guard (VIZ-TEST-JOBS): a window-close while jobs are running
/// is cancelled and this confirm modal opens instead. The running count comes
/// from the 🧬 nornir tab's job list (last poll).
close_confirm_open: bool,
/// Set once the user confirms "close anyway" — lets the next close request
/// through unguarded.
allow_close: bool,
/// 🎬 **Demo mode** (hands-free guided tour). `Some` when launched with
/// `nornir viz --demo`: each frame the running viz emits the next due
/// [`DemoStep`](super::demo::DemoStep) as a real `viz.click` on the control
/// channel, so the tour ROBOT-DRIVES the same poll+apply path a human/MCP
/// click takes — and the motion plays. `None` for a normal window.
demo: Option<DemoRun>,
/// 🎬 Stable saga session id for this app instance — stamped onto every
/// `SagaEvent` the nav/click choke records (feature `saga`), so a whole
/// window's journey groups into one replayable session. Unique per app build
/// (pid + a process-global counter) so parallel in-process test apps don't
/// collide.
saga_session_id: String,
}
/// A process-global, monotonic per-app-instance saga session id.
fn next_saga_session_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
format!("viz-{}-{n}", std::process::id())
}
/// Live wrapper around a [`DemoDriver`](super::demo::DemoDriver): the script plus
/// the wall-clock start it measures `elapsed_ms` from. FC-7: the driver itself is
/// time-injected; only this struct touches a real clock, and a looped tour rebases
/// `started` so the schedule stays stable across passes.
struct DemoRun {
driver: super::demo::DemoDriver,
started: std::time::Instant,
}
impl UrdrThreadsApp {
pub fn new(warehouse_root: PathBuf, workspace_name: String) -> Self {
Self::with_repos(warehouse_root, workspace_name, PathBuf::new(), Vec::new())
}
/// Extended constructor: also seed the Knowledge tab with a workspace
/// root (the parent containing repo dirs) and the configured repo names.
pub fn with_repos(
warehouse_root: PathBuf,
workspace_name: String,
workspace_root: PathBuf,
repos: Vec<String>,
) -> Self {
let log_dir = warehouse_root
.parent()
.map(|p| p.join("logs"))
.unwrap_or_else(|| warehouse_root.join("logs"));
let label = warehouse_root.display().to_string();
// Local mode has a single workspace — no server set to auto-select from.
Self::build(
Source::Local(warehouse_root),
label,
workspace_name,
None,
workspace_root,
repos,
log_dir,
)
}
/// Thin-client constructor: load the timeline from a running `nornir-server`
/// over gRPC instead of opening a local warehouse. The live-run tab streams
/// the active release run from the server over `Release.Progress` (no local
/// log dir needed) — so a viz pointed at a remote server (e.g. over Tailscale)
/// animates the run in real time, the same as a local one.
pub fn with_remote(
endpoint: String,
token: String,
workspace_name: String,
workspace_root: PathBuf,
repos: Vec<String>,
) -> Self {
Self::with_remote_preferring(endpoint, token, workspace_name, None, workspace_root, repos)
}
/// As [`with_remote`](Self::with_remote), but with a `preferred_workspace`
/// hint (derived from `--config`'s `workspace_<name>` path): when no explicit
/// `--workspace`/`NORNIR_WORKSPACE` is given, the picker selects this
/// workspace **if the server registers it**, instead of the alphabetically
/// first one. Stops a config-scoped viz (e.g. `workspace_njord`) from
/// silently opening an unrelated workspace (`holger`) just because it sorts
/// first. Falls back to first when the hint is absent or unregistered.
pub fn with_remote_preferring(
endpoint: String,
token: String,
workspace_name: String,
preferred_workspace: Option<String>,
workspace_root: PathBuf,
repos: Vec<String>,
) -> Self {
let label = format!("server {endpoint}");
Self::build(
Source::Remote { endpoint, token },
label,
workspace_name,
preferred_workspace,
workspace_root,
repos,
PathBuf::new(),
)
}
fn build(
source: Source,
source_label: String,
mut workspace_name: String,
preferred_workspace: Option<String>,
workspace_root: PathBuf,
repos: Vec<String>,
log_dir: PathBuf,
) -> Self {
// Picker contents come FIRST so the picker offers real workspaces:
// remote mode lists the server's registered set. We do NOT auto-select
// the first one — the server has no implicit default route, so the user
// must pick a workspace from the top-bar (the empty-selection state shows
// a "select a workspace" prompt). The ONLY startup auto-selection is an
// explicit config-derived workspace (`--config workspace_njord` → `njord`)
// that the server actually registers. An explicit --workspace name stays
// selectable even if unregistered.
let mut connect_status = String::from("local (no server)");
let workspaces = match &source {
Source::Remote { endpoint, token } => {
let mut ws = match super::remote::list_workspaces(endpoint, token) {
Ok(ws) => {
eprintln!(
"nornir-viz: Workspaces.List → {} workspace(s): {ws:?}",
ws.len()
);
connect_status = format!("connected {endpoint}: {} workspace(s)", ws.len());
ws
}
Err(e) => {
eprintln!("nornir-viz: Workspaces.List FAILED (picker empty): {e:#}");
connect_status = format!("CONNECT FAILED {endpoint}: {e:#}");
Vec::new()
}
};
ws.sort();
ws.dedup();
if workspace_name.is_empty() {
// Auto-select ONLY a config-derived workspace (e.g.
// `workspace_njord` → `njord`) when the server actually
// registers it — that's an explicit `--config` selection, not
// a default route. With no such match `workspace_name` stays
// empty and the user picks from the top-bar (NO "first
// registered" fallback — the server has no default workspace).
let preferred = preferred_workspace
.as_deref()
.filter(|p| ws.iter().any(|w| w == p))
.map(str::to_string);
if let Some(p) = preferred {
eprintln!("nornir-viz: auto-selected workspace `{p}` (matched --config)");
workspace_name = p;
} else {
eprintln!(
"nornir-viz: no workspace selected — pick one from the top-bar \
(server has no default; {} registered)",
ws.len()
);
}
} else if !ws.iter().any(|w| w == &workspace_name) {
ws.push(workspace_name.clone());
}
ws
}
Source::Local(_) => vec![workspace_name.clone()],
};
// Remote without a local config → get the repo list (for the repo-driven
// tabs: Security / Knowledge / Gates / Bench) from the server's workspace
// record, so the buttons populate without passing --config.
let mut repos = repos;
if repos.is_empty() {
if let Source::Remote { endpoint, token } = &source {
if let Ok(info) = super::remote::get_workspace(endpoint, token, &workspace_name) {
repos = info.members.into_iter().map(|(m, _)| m).collect();
eprintln!("nornir-viz: repos from server `{workspace_name}` → {repos:?}");
}
}
}
// No workspace selected (a fresh server with an EMPTY registry): skip the
// build-time warehouse load — `Viz.Timeline` with an empty workspace returns
// `NotFound: workspace `` is not served`, which would otherwise leak into
// `timeline.error` and render red. The onboarding panel renders instead;
// sub-views re-hydrate once a workspace is registered + selected. (Mirrors
// the same guard in `reload()`.)
let timeline = if workspace_name.is_empty() {
Ok(super::model::Timeline::default())
} else {
source.load(&workspace_name)
};
// Durable source = the warehouse `release_events` table (LAW
// persist-to-warehouse-stream-not-tmp): local mode hydrates the live pane
// from the warehouse on load/reload and tails the workspace log dir as a
// fast live accelerator; remote mode streams the run from the server over
// Release.Progress (the server's warehouse is the durable source).
let live = match &source {
Source::Remote { endpoint, token } => super::live::LiveRunState::new_remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
Source::Local(p) => super::live::LiveRunState::new(log_dir, p.clone()),
};
// The warehouse browser opens the warehouse directly when local, else
// browses it over the server's Warehouse.Tables/Scan gRPC.
let warehouse = match &source {
Source::Local(p) => super::warehouse_tab::WarehouseBrowser::local(p.clone()),
Source::Remote { endpoint, token } => super::warehouse_tab::WarehouseBrowser::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// 🏢 Warehouse Deck: local opens the warehouse for raw Arrow frames;
// remote builds the SAME deck over the server's Warehouse.Tables/Scan
// gRPC (stringified rows re-materialised as Utf8 batches), like the
// 🗄 Warehouse browser — so the 3D deck works in thin mode too.
let warehouse_deck = match &source {
Source::Local(p) => super::warehouse_deck_tab::WarehouseDeckPane::local(p.clone()),
Source::Remote { endpoint, token } => {
super::warehouse_deck_tab::WarehouseDeckPane::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
)
}
};
let callgraph = match &source {
Source::Local(p) => {
// Local mode still has a NAMED workspace — thread it through so the
// pane's LAW-2 empty-state / populate-job matching
// (`repo_pane::classify_empty` → `populate_active_for`) is scoped to
// THIS workspace instead of the empty-string catch-all, and
// `state_json` reports the real workspace, exactly like the remote
// arm. Without it a local callgraph reported `"workspace":""`.
let mut cg = super::callgraph::CallGraphState::local(p.clone());
cg.set_workspace(workspace_name.clone());
cg
}
Source::Remote { endpoint, token } => super::callgraph::CallGraphState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// 🏛 Architecture tab: local opens the warehouse + merges every member's
// latest board; remote reads the server's merge over Viz.Architecture.
let mut arch = match &source {
Source::Local(p) => super::arch_tab::ArchTabState::local(p.clone()),
Source::Remote { endpoint, token } => super::arch_tab::ArchTabState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// Seed the configured members so the placeholder can name who still needs
// `nornir arch generate` (parity with the Test matrix's repo seeding).
arch.set_members(repos.clone());
// N4 — seed the workspace GIT root so the source viewer can READ the real
// on-disk clones (`repo_dir_resolved(source_root, <repo>)`) in BOTH modes:
// fat mode's checkout root AND, via the arch tab's registry fallback, the
// thin-mode server-clone dir `~/.nornir/workspaces/<ws>/git`.
arch.set_source_root(workspace_root.clone());
// Seed the click-to-code source: the local checkout root + the first
// member, so a clicked 🏛 node resolves to `file:line` via a fresh
// `symbol_facts` scan + the inline source preview. Local mode only —
// remote has no checkout, so resolution returns nothing (the graph still
// works; the inline preview is the part that needs the source).
if let (Source::Local(_), Some(first)) = (&source, repos.first()) {
let repo_root = crate::config::repo_dir_resolved(&workspace_root, first);
arch.set_resolve_source(repo_root, first.clone());
}
let funnel = match &source {
Source::Local(p) => super::funnel_tab::FunnelTabState::local(p.clone()),
Source::Remote { endpoint, token } => super::funnel_tab::FunnelTabState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
let mcp = match &source {
Source::Local(p) => super::mcp_tab::McpTab::local(p.clone()),
Source::Remote { endpoint, token } => super::mcp_tab::McpTab::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// 🚀 Release tab reads `release_events` from the local warehouse directly;
// remote mode reads the same rows over Viz.ReleaseEvents.
let mut release = match &source {
Source::Local(p) => super::release_tab::ReleaseTabState::local(p.clone()),
Source::Remote { endpoint, token } => super::release_tab::ReleaseTabState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// Wire the Gate / Doctor section's inputs (local mode only — remote has no
// checkout to scan). Repo checkouts are `workspace_root/<repo>`; the doctor
// skips repos without a Cargo.toml. Default (empty) forbidden-version policy.
if matches!(&source, Source::Local(_)) {
release.set_gate_inputs(
&workspace_root,
&repos,
crate::release::doctor::DepPolicy::default(),
);
}
// 🗺 Knowledge map: local mode scans `workspace_root/<repo>` here; remote
// mode has no checkout, so the server runs the scan over Viz.Knowledge.
let knowledge = match &source {
Source::Local(p) => {
// the map is the workspace's code room (`<warehouse>/mimir_v2/`)
super::knowledge::KnowledgeState::new(workspace_root.clone(), repos.clone())
.with_warehouse(p.clone(), workspace_name.clone())
}
Source::Remote { endpoint, token } => super::knowledge::KnowledgeState::new_remote(
workspace_root.clone(),
repos.clone(),
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// 📦 Holger registry pane: read the in-process dev pair under
// `<warehouse>/holger-cache` (the `nornir cache up` data path) via
// holger's functional API. Local mode points at that on-disk dir; remote
// mode has no local holger to read, so it fetches the SERVER's registry
// over `Viz.HolgerRegistry` (the server runs `read_registry` on its own
// data dir) — so the thin viz lists the same crates-io / cache / sparring
// rows the server serves instead of an empty stub. The grpc/http here
// match the `cache up` defaults so the bring-up button binds the same
// addresses.
#[cfg(feature = "embed-holger")]
let holger = match &source {
Source::Local(p) => super::holger_tab::HolgerTab::disk(
p.join("holger-cache"),
"127.0.0.1:18443".to_string(),
"127.0.0.1:18444".to_string(),
),
// Remote/thin: there is no local holger-cache to read, so fetch the
// server's registry over Viz.HolgerRegistry (the server runs
// `read_registry` on its own data dir). This is the fix for the pane
// showing "data dir: <injected>" + "no repositories configured" in
// server mode — it now lists crates-io / cache / sparring.
Source::Remote { endpoint, token } => super::holger_tab::HolgerTab::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
// TOTAL action log (N5). The durable warehouse `viz_actions` sink is
// attached in LOCAL mode (the viz owns the warehouse there); remote mode
// keeps file/stderr only — the server is the durable side. The `/tmp`
// file stays the fast live-channel regardless (write both, per the law).
let mut action_log = super::action_log::ActionLog::new();
// `NORNIR_VIZ_NO_DURABLE_ACTIONLOG` skips the durable warehouse sink so the
// action-log drain thread does NOT hold the catalog's exclusive lock for
// the app's lifetime. Useful when another in-process consumer (e.g. the
// Funnel tab's `Store::open`, or the R6 robot test) needs to open the same
// warehouse; the file/stderr action-log channels stay live regardless.
let durable_actionlog = std::env::var_os("NORNIR_VIZ_NO_DURABLE_ACTIONLOG").is_none();
if let Source::Local(p) = &source {
if durable_actionlog {
action_log.attach_warehouse(p.clone());
}
}
action_log.set_context(&workspace_name, "Timeline");
// Thin/remote client? (borrow before `source` is moved into the struct)
let preflight_thin = matches!(&source, Source::Remote { .. });
// 🧬 root pane — built local()/remote() per source, before `source` moves.
let nornir_root = if preflight_thin {
super::nornir_tab::NornirRootView::remote()
} else {
super::nornir_tab::NornirRootView::local()
};
// 🧬 RELEASE DASHBOARD — wire its gate inputs in local mode (the same
// checkout root + repos the 🚀 Release pane's gate uses); remote has no
// checkout to scan, so the dashboard's gate-graph is local-only.
let mut nornir_dashboard = super::nornir_dashboard::NornirDashboard::default();
if preflight_thin {
nornir_dashboard.set_remote();
} else {
nornir_dashboard.set_gate_inputs(
&workspace_root,
&repos,
crate::release::doctor::DepPolicy::default(),
);
}
let ws_for_models = workspace_name.clone();
let mut app = Self {
source,
source_label,
workspace_name,
workspace_root: workspace_root.clone(),
workspaces,
timeline,
selected: None,
dep_selected_repo: None,
dep_view: DepGraphView::new(),
last_reload: std::time::Instant::now(),
tab: Tab::Timeline,
nav: facett_app::scene::NavState::new(&super::shell_scene::nornir_shell_scene()),
last_scene_render: serde_json::Value::Null,
started_at: std::time::Instant::now(),
nornir_tex: None,
timetravel: TimeTravelState::default(),
live,
release,
chat: super::chat_tab::ChatTabState::new(),
knowledge,
warehouse,
warehouse_deck,
callgraph,
arch,
metro: super::metro_tab::MetroTabState::local(),
helix: super::helix_tab::HelixTabState::local(),
bloodstream: super::bloodstream_tab::BloodstreamView::local(),
funnel,
mcp,
search: super::ops_tabs::SearchState::default(),
gates: super::ops_tabs::GatesState::default(),
build_info_tail: super::build_info_live::BuildInfoTail::new(),
dwarves: super::dwarves_tab::DwarvesTabState::new(workspace_root.clone()),
security: super::security_tab::SecurityTab::new(workspace_root, repos.clone()),
#[cfg(feature = "embed-holger")]
holger,
preflight: super::preflight::Preflight::run(preflight_thin),
ws_panel: super::ops_tabs::WorkspacePanel::default(),
nornir_root,
nornir_dashboard,
manual: super::manual_tab::ManualTab::new(),
claude: super::claude_tab::ClaudeTab::new(),
models: super::models_tab::ModelsTab::new(crate::jobs::JobSink::noop(), ws_for_models),
repos,
auto_refresh: true,
refresh_secs: 5,
connect_status,
last_dump: std::time::Instant::now(),
last_click: (Tab::Timeline, String::new()),
action_log,
show_action_log: false,
show_about: false,
server_info: None,
palette: super::facett_theme::Theme::default(),
palette_applied: false,
screenshot_pending: false,
screenshot_last_path: None,
screenshot_pending_path: None,
close_confirm_open: false,
allow_close: false,
demo: None,
saga_session_id: next_saga_session_id(),
};
// Seed every pane with the default palette so they're palette-driven from
// frame one (later switches go through `set_palette`).
app.broadcast_palette();
// 📡 Build-info auto-refresh: in remote mode, start the background poller so
// the live-tail pane re-`pull`s each tracked sub-job ticket on a timer while
// it is open (mirrors the other live panes' warehouse re-read). Gated
// `build-info-flight` (the feature that links the Flight consumer seam); the
// default viz build keeps the injectable `ingest` path and pays no Flight tax.
#[cfg(feature = "build-info-flight")]
{
let remote = if let Source::Remote { endpoint, token } = &app.source {
Some((endpoint.clone(), token.clone()))
} else {
None
};
if let Some((endpoint, token)) = remote {
app.build_info_tail.start_poller(
endpoint,
Some(token),
super::build_info_live::POLL_EVERY,
);
}
}
// 💬 Emit the MCP-to-nornir client config to a discoverable file next to the
// warehouse (`<root>/nornir-mcp.json`) so an agent operator can point their
// MCP client at nornir without hunting. Best-effort — never fatal.
if let Source::Local(root) = &app.source {
match super::chat_tab::write_mcp_config_file(root) {
Ok(p) => app.action_log.push(
super::action_log::Kind::Life,
format!("MCP-to-nornir config written → {}", p.display()),
),
Err(e) => app.action_log.push(
super::action_log::Kind::Error,
format!("MCP config write failed: {e}"),
),
}
}
app
}
/// Set the app-wide facett palette (C8) and broadcast it to every pane so the
/// whole viz re-skins together. Marks the egui `Visuals` stale so the next
/// `draw_ui` re-applies them. Idempotent — a no-op when the palette is
/// unchanged. `pub` so the headless test matrix can switch the palette and
/// then assert `state_json["palette"]` + each pane's `palette` changed.
pub fn set_palette(&mut self, palette: super::facett_theme::Theme) {
// The shared `facett_core::theme::Theme` carries no `PartialEq`; its
// `name` is unique across `Theme::ALL`, so the name IS the identity.
if super::facett_theme::same_palette(&palette, &self.palette) {
return;
}
self.palette = palette;
self.palette_applied = false;
self.broadcast_palette();
super::trace::emit_event(
"ui.palette",
&serde_json::json!({ "palette": palette.name }),
);
}
/// Push the active palette into every pane's `theme` field, so each pane's
/// custom painting (status fills, accents, zebra/hover/selection, gridlines)
/// uses the same facett palette (C8). Called on construction + every switch.
fn broadcast_palette(&mut self) {
let p = self.palette;
self.dwarves.set_palette(p);
self.release.set_palette(p);
self.chat.set_palette(p);
self.live.set_palette(p);
self.knowledge.set_palette(p);
self.warehouse.set_palette(p);
self.mcp.set_palette(p);
self.claude.set_palette(p);
self.models.set_palette(p);
self.callgraph.set_palette(p);
self.arch.set_palette(p);
self.metro.set_palette(p);
self.helix.set_palette(p);
self.bloodstream.set_palette(p);
self.funnel.set_palette(p);
self.security.set_palette(p);
#[cfg(feature = "embed-holger")]
self.holger.set_palette(p);
self.preflight.set_palette(p);
self.search.set_palette(p);
self.gates.set_palette(p);
self.build_info_tail.set_palette(p);
self.timetravel.set_palette(p);
self.dep_view.set_palette(p);
self.nornir_root.set_palette(p);
self.nornir_dashboard.set_palette(p);
}
/// Build the external state snapshot — every picker's options + selection,
/// the tab set, timeline/warehouse population, connection status, and any
/// empty-state placeholders. Written to `NORNIR_VIZ_STATE` so the test
/// matrix (and the operator) can see exactly what the UI is showing without
/// a screen. See `dump_state`.
///
/// `pub` so the headless test matrix in `tests/viz_matrix.rs` can call
/// this without starting a GUI — construct the app with `with_repos` /
/// `with_remote`, then call `state_json()` directly to get the full UI
/// state as a JSON value (same JSON the operator sees via
/// `NORNIR_VIZ_STATE`). No display, no `eframe::run` needed.
/// The reusable [`TabBar`](super::facett_ui::TabBar) component's readable-data
/// dump (D3b, LAW #6), describing the top-bar exactly as it is rendered: the
/// tab strip + active id + row split, plus the pickers/toggles the bar hosts
/// (workspace, live, about, action_log, palette) with their current values.
/// Built from the SAME `TabBar` the top-bar paints, so the dump can't drift
/// from the render. Folded into `state_json["top_bar"]`.
pub fn top_bar_state(&self) -> serde_json::Value {
let tab_items = Tab::tab_items();
let active_id = format!("{:?}", self.tab);
super::facett_ui::TabBar::new("top", &tab_items, &active_id)
.split_at(TOP_BAR_SPLIT)
.with_control(super::facett_ui::BarControl::picker(
"workspace",
&self.workspace_name,
))
.with_control(super::facett_ui::BarControl::toggle(
"live",
self.auto_refresh,
))
.with_control(super::facett_ui::BarControl::toggle(
"about",
self.show_about,
))
.with_control(super::facett_ui::BarControl::toggle(
"action_log",
self.show_action_log,
))
// No `palette` control: the 🎨 picker moved off the root top bar into the
// 🎛 Settings tab. The dump mirrors what the bar RENDERS (that is the whole
// point of `top_bar_state`), so it must not advertise a control that is no
// longer there. The active palette is still reported in `state_json`.
.state_json()
}
pub fn state_json(&self) -> serde_json::Value {
let (releases, tl_err) = match &self.timeline {
Ok(tl) => (tl.lanes.iter().map(|l| l.nodes.len()).sum::<usize>(), None),
Err(e) => (0, Some(format!("{e:#}"))),
};
let mut placeholders = Vec::new();
if releases == 0 {
placeholders.push("no releases recorded yet".to_string());
}
// Pre-compute the larger pane blocks so the top-level `json!` macro stays
// under its recursion limit (it expands every key inline).
let nornir_root_json = self.nornir_root.state_json();
let nornir_dashboard_json = self.nornir_dashboard.state_json();
// ONBOARDING slice (pre-computed to keep the top `json!` under its recursion
// limit). `active` ⇒ a fresh server with an empty registry / no workspace
// selected; the data tabs then show the add-a-workspace panel and DON'T load
// the warehouse, so `timeline_error` is null — proving the
// `workspace `` is not served` NotFound never reached the user.
// `unpopulated` ⇒ a NAMED workspace is selected + registered, but its
// warehouse isn't served yet (`Viz.Timeline` → `is not served`). That's the
// onboarding "click ⬇ Populate" state, NOT an error — so we mark the
// onboarding slice active AND suppress `timeline_error` (it would otherwise
// surface as a red "Failed to load warehouse"). `active` is true for either
// onboarding shape: empty registry (no selection) OR registered-unpopulated.
let unpopulated = self.timeline_is_unpopulated_onboarding();
let onboarding_active = self.workspace_name.is_empty() || unpopulated;
let onboarding_json = serde_json::json!({
"active": onboarding_active,
"unpopulated": unpopulated,
"workspace": self.workspace_name,
"no_workspaces": self.workspaces.is_empty(),
// The not-served NotFound is the *cause* of an onboarding state, not a
// user-facing error — so it rides in the onboarding slice, never in
// `timeline.error` (asserted by the matrix: onboarding, not red).
"timeline_error": if unpopulated { None } else { tl_err.clone() },
});
// Don't leak the not-served NotFound into the top-level `timeline.error`
// when it's really an onboarding state (it must not render red).
let tl_err = if unpopulated { None } else { tl_err };
let mut top = serde_json::json!({
"source": self.source_label,
"connect_status": self.connect_status,
"tab": format!("{:?}", self.tab),
"all_tabs": Tab::ALL.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
// The reusable facett TabBar component's own readable-data dump (D3b,
// LAW #6): the tab strip (id/label/active), the row split, and the
// pickers/toggles it hosts (workspace, live, about, action_log,
// palette) with their current values — "see what the user sees".
"top_bar": self.top_bar_state(),
// 🎨 The app-wide facett palette (C8) — the single active theme every
// pane paints with. Switching it re-skins the whole viz; the change is
// observable headlessly here + in each pane's own `palette` key (LAW #6).
"palette": self.palette.name,
"palettes": super::facett_theme::Theme::names(),
"workspace_picker": {
"options": self.workspaces,
"selected": self.workspace_name,
"count": self.workspaces.len(),
},
// 🌿 Working-tree freshness (AUT6): per-member dirty + content digest
// of the server's clone, plus `any_dirty`. A commit SHA can't see
// uncommitted edits; this is the staleness signal the ℹ info panel
// renders (⚠ uncommitted) and the headless matrix asserts. LAW #6.
"workspace_freshness": self.ws_panel.state_json(),
// 🧬 The `nornir` root pane (this feature): the server + workspace
// lifecycle operation buttons it hosts (add/kill/populate/refresh +
// status), the Add-workspace form values, the kill-confirm state, and
// the last result of each op — so the headless matrix mechanically
// discovers every root operation and reads back what it rendered.
"nornir": nornir_root_json,
// "Ask at close" guard (VIZ-TEST-JOBS): whether the confirm modal is
// open + the running-job count that armed it, so the headless matrix
// can assert the guard fires while jobs run.
"close_guard": {
"confirm_open": self.close_confirm_open,
"running": self.nornir_root.running_jobs(),
},
// 📖 Manual (facett-docview): resolved dir + load state + the viewer's
// own state_json (page count, current page, zoom) when loaded.
"manual": self.manual.state_json(),
"repos": self.repos,
"knowledge": self.knowledge.state_json(),
// 🗂 Funnel tab (LAW 6): selected plan + per-plan node/edge/ready
// counts + the E1 demo block, so the headless matrix sees the
// funnel DAG's data (it contributed nothing before E1).
"funnel": self.funnel.state_json(),
// 🏛 Architecture (LAW 6): the EPIC ARCH wiring board the tab paints —
// node/edge counts + per-kind counts (component/grpc/corefn/table), the
// node + edge lists with kinds, which members contributed a board, the
// selected node + its downstream-traced labels, and the active palette.
"architecture": self.arch.state_json(),
// 🕸 Call Graph (RESTORED as the Architecture `CallGraph` lens): the
// REAL function/symbol call graph (`callgraph.rs`) as readable data —
// selected repo/crate, laid-out node/edge counts, top fns by degree,
// the 3D layout + truncation flag, and `built` (a graph was actually
// built). This lens had become unreachable when the top-level button
// was dropped; it is now a distinct Arch sub-view again. The 🚇 Metro
// call-PATH map (button→gRPC) is a SEPARATE lens, observable under
// `architecture.metro` (LAW #6).
"callgraph": self.callgraph.state_json(),
// 📡 Live Run (C3): the live release-run pane — the durable warehouse
// `release_events` baseline it hydrated from (survives a reboot, visible
// to other clients), plus the run header + rendered event lines, so the
// headless matrix proves the pane reads from the warehouse, not `/tmp`.
"live": self.live.state_json(),
// 📡 Build-info live-tail (slice-5 capstone): the §f3 build-info feed
// folded per sub-job `step_id` — each step's status transitions + its
// log/metric tail, subscribed via `build::flight_sink::subscribe`. So
// the headless matrix proves the pane tails the feed, not pixels.
"build_info_live": self.build_info_tail.state_json(),
// 🚀 Release op DAG (B3/B4): the release-op rows + per-component status
// the tab renders, so the headless matrix sees the lit-up graph's data.
"release": self.release.state_json(),
// 🔨 Build (Tier-2 #5/#26): the build-thing surface reflected into
// choosable rows (binary × features × profile × target) + each card's
// status + any completed build's provenance/warehouse row, so the
// headless matrix sees the tab's data without a draw.
"build": self.dwarves.build.state_json(),
// ⚒ Dwarves: the build-runner tab's own emitter block.
"dwarves": self.dwarves.state_json(),
// 💬 Chat (EPIC H / #45): the active local model + the transcript +
// `last_response` (what the robot chat test asserts) + the discoverable
// MCP-to-nornir client config, so the whole local-model surface reads
// back as DATA (LAW #6).
"chat": self.chat.state_json(),
// ℹ About popup (C1): client version + endpoint + workspace + warehouse
// + (remote) server version — the real identity fields the popup shows.
"about": self.about_info(),
"timeline": { "releases": releases, "error": tl_err },
// 🔗 Dep Graph (C5/C7/I3): the laid-out graph the tab paints — deep
// toggle, node positions, edges classified direct vs transitive, and
// the collapse set (LAW #6). Computed fresh from the current timeline
// + view settings so the headless matrix sees it even without a draw.
"dep_graph": self.dep_graph_state(),
// ⏳ Time Travel (C4): the cursor (release idx + pinned release), the
// text⇄graph view mode, and — in graph mode — the dependency graph as
// it was at that release (reusing depgraph_layout's nodes/positions +
// direct/transitive edge classification). So the headless matrix sees
// the time-pinned, coloured graph without a draw.
"timetravel": self.timetravel_state(),
// 🔍 Search (LAW 6): the BM25 query + corpus + symbol/vector/call-graph
// form fields AND the rendered result counts/rows, so an agent reads
// back exactly what's typed and what's shown.
"search": self.search.state_json(),
// 🚦 Gates (LAW 6): the selected/typed repo + the rendered gate report
// (passed/failed gate names) + whether a regression trace is loaded.
"gates": self.gates.state_json(),
// 🗄 Warehouse (LAW 6): the table list + selected table + chart toggle
// and chosen columns + the loaded preview shape (columns + row count).
"warehouse": self.warehouse.state_json(),
// 🏢 Warehouse Deck (LAW 6): the composed facett WarehouseDeck state —
// pane_count + per-kind counts (graph3d/grid/chart) + each pane's
// nodes/edges/rows + the per-pane widget state + the trace ledger.
"warehouse_deck": self.warehouse_deck.state_json(),
// 📞 MCP (LAW 6): the per-tool usage rows (calls/errors/avg-ms) + total.
"mcp": self.mcp.state_json(),
// 🤖 Claude (LAW 6): the conversation turns + last-assistant text + the
// `sending` flag + selected model + whether ANTHROPIC_API_KEY is present.
"claude": self.claude.state_json(),
// 🧠 Models (LAW 6): the catalog + selected device + which backends/GPU
// libs are present + every download job with its live progress/status.
"models": self.models.state_json(),
// 🛡 Security (LAW 6): the scan-button repos + which is scanning + the
// rendered SBOM/vuln/license result.
"security": self.security.state_json(),
// 🎛 Settings (LAW 6): the preferences pane's own emitter slice — the
// ACTIVE palette and the roster it offers. This is the pane that owns
// the viz's look & feel now that the picker has moved off the root top
// bar, so it reports its surface as data like every other pane.
"settings": {
"palette": self.palette.name,
"palettes": super::facett_theme::Theme::names(),
},
"preflight": self.preflight.state_json(),
// **LAW4b** — live-app self-screenshot state: whether a screenshot was
// fired but not yet delivered, and the last file path written. Robot
// drivers poll `screenshot.pending == false` to know the PNG is ready.
"screenshot": {
"pending": self.screenshot_pending,
"last_path": self.screenshot_last_path,
},
// Kept for back-compat with existing consumers; the full table list +
// selection now also lives under `warehouse`.
"warehouse_tables": self.warehouse.table_names(),
"placeholders": placeholders,
// The action-log trail: total count + the last few entries, so the
// headless test matrix + the operator see "what was clicked" in the
// same dump as the rest of the UI state.
"action_log": {
"file": self.action_log.file_path(),
"count": self.action_log.count(),
"recent": self.action_log.recent(12).iter()
.map(|e| format!("{} [{}] {}", e.stamp, e.kind.tag(), e.detail))
.collect::<Vec<_>>(),
// N5 — the durable side: this session's id + its recent actions
// read BACK from the warehouse `viz_actions` table (proves the
// trail is durable + queryable, not just a /tmp file). Empty
// array in remote mode (no local warehouse attached).
"session_id": self.action_log.session_id(),
"warehouse_recent": serde_json::from_str::<serde_json::Value>(
&self.action_log.warehouse_recent_json(12),
).unwrap_or(serde_json::Value::Array(Vec::new())),
},
});
// 🧬 RELEASE DASHBOARD (this feature, LAW #6): the gate-overlaid graph the
// nornir tab paints — present flag, the Release button's enabled state (the
// gate verdict), the scoped repo + lit blast radius, the SHARED gate block,
// the per-repo node badges, and each cycle's suggested cut edge. Inserted
// here (not inline in the `json!` above) to keep the macro under its
// recursion limit. So the robot test asserts go/no-go WITHOUT pixels.
if let serde_json::Value::Object(map) = &mut top {
map.insert("nornir_dashboard".into(), nornir_dashboard_json);
// 📦 Holger (LAW 6): the built-in (married) holger dev pair, read via
// holger's IN-PROCESS functional API — whether it's embedded + the
// data dir, the repositories (/cache + /sparring) with per-repo
// crate/version counts + recent ids, and the bring-up state. Inserted
// here (not inline in the `json!` above) to keep the macro under its
// recursion limit. Degrades to `embedded: false` when holger is off.
map.insert("holger".into(), self.holger_state());
}
// ONBOARDING slice — inserted here (not inline in the `json!` above) to keep
// the macro under its recursion limit. See `onboarding_json` for the meaning.
top["onboarding"] = onboarding_json;
// 🚇 METRO slice (S5b) — likewise inserted post-macro (the `json!` above is
// already at its recursion ceiling). The facett MetroView's drawn map as
// DATA: lines, per-station {label, kind, lit}, per-line + overall green.
top["metro"] = self.metro.state_json();
// 🧬 HELIX slice — the facett HelixView's drawn scene as DATA (nodes/coils/
// links + the traverse/wind state), plus `from_data` (real vs demo scene).
top["helix"] = self.helix.state_json();
// 🩸 BLOODSTREAM slice — the drawn circulatory system as DATA: organs (crate
// + anatomical slot + in-degree + expand state), vessels, the blood flow
// phase, and the recursive external closure loaded under each drilled organ
// (name/version/depth) — so a robot drive asserts the deep drill-down.
top["bloodstream"] = facett_jobview::Facet::state_json(&self.bloodstream);
// 🎬 SCENE slice — the DECLARATIVE master shell as observable DATA: the pane
// tree + which panes are foldable/drawers/draggable-splits, the host-filled
// tab slots, the live folded + split-ratio maps, menu/route counts, and the
// last render facts. Inserted post-macro (the `json!` above is at its
// recursion ceiling). Proves the shell layout is data a robot can assert.
top["scene"] = self.scene_state();
top
}
/// The About-popup identity block (C1): always carries SOMETHING real — the
/// **client version** (`CARGO_PKG_VERSION`), the **endpoint**, the **selected
/// workspace**, and the **warehouse path / mode**. In remote mode it also
/// carries the **server version** from a cached `Health.Ping` (queried the
/// first time the popup opens); until queried, `server_version` is null and a
/// note flags it as a pending RPC.
pub fn about_info(&self) -> serde_json::Value {
let (endpoint, warehouse, mode) = match &self.source {
Source::Local(p) => (String::new(), p.display().to_string(), "local"),
Source::Remote { endpoint, .. } => (endpoint.clone(), String::new(), "remote"),
};
let (server_version, server_repo_count, server_note) = match &self.server_info {
Some(Ok(info)) => (Some(info.version.clone()), Some(info.repo_count), None),
Some(Err(e)) => (None, None, Some(format!("Health.Ping failed: {e}"))),
None if mode == "remote" => (
None,
None,
Some("server version: open About to query Health.Ping".to_string()),
),
None => (None, None, None),
};
serde_json::json!({
"client_version": env!("CARGO_PKG_VERSION"),
"client_build": client_build(),
"build_sha": option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown"),
"mode": mode,
"endpoint": endpoint,
"workspace": self.workspace_name,
"warehouse": warehouse,
"source_label": self.source_label,
"connect_status": self.connect_status,
"server_version": server_version,
"server_repo_count": server_repo_count,
"server_note": server_note,
})
}
/// The 📦 Holger pane's slice of `state_json` (LAW #6). When holger is
/// embedded (`embed-holger`), this is the live pane's own dump (repos +
/// counts + bring-up). When it isn't, the pane degrades to a note so the tab
/// is still observable headlessly: `embedded: false`.
#[cfg(feature = "embed-holger")]
fn holger_state(&self) -> serde_json::Value {
self.holger.state_json()
}
#[cfg(not(feature = "embed-holger"))]
fn holger_state(&self) -> serde_json::Value {
serde_json::json!({
"embedded": false,
"note": "holger is not embedded in this build (embed-holger off) — \
the registry pane is unavailable.",
})
}
/// ONBOARDING vs ERROR (bug: registered-but-unpopulated named workspace).
///
/// A workspace can be **registered** (it shows in the picker, `Workspaces.List`
/// returns it) but **not yet served** — no warehouse built (a fresh `nornir
/// workspace add` before the first populate, a `lazy` register, or a monitored
/// workspace whose eager build hasn't finished). `Viz.Timeline` then answers
/// `NotFound: workspace `<name>` is not served`. That is NOT a failure — it is
/// the **onboarding / not-populated** resting state: the user just needs to
/// click ⬇ Populate. Returns `true` for exactly that case (a `NotFound` carrying
/// the not-served / no-workspace marker), so the data tabs render a graceful
/// "not populated — click Populate" panel instead of a red "Failed to load
/// warehouse" error. The EMPTY-REGISTRY (no workspace selected) onboarding is a
/// separate, earlier guard (`workspace_name.is_empty()`); this one fires when a
/// NAMED workspace is selected but its warehouse isn't served yet.
fn timeline_is_unpopulated_onboarding(&self) -> bool {
// Only a *named* (selected) workspace can be registered-but-unpopulated;
// the empty-registry / no-selection case is handled by its own guard.
if self.workspace_name.is_empty() {
return false;
}
match &self.timeline {
// The server serves every registered workspace (8fc1d7b), so a
// registered one nobody populated answers an EMPTY timeline — no
// lanes, no snapshot — rather than the NotFound below. Same state.
Ok(tl) => tl.lanes.is_empty() && tl.latest_snapshot.is_none(),
Err(e) => {
let msg = format!("{e:#}");
// The server's not-served / no-workspace NotFound markers. Match the
// text (the error arrives as an anyhow-wrapped `tonic::Status`, so
// the code isn't structurally typed through the viz client).
msg.contains("is not served")
|| msg.contains("not served")
|| msg.contains("no workspace selected")
// fat mode: the workspace's warehouse was never built
|| msg.contains("no `catalog.redb` at")
}
}
}
/// The 🔗 Dep Graph tab's laid-out structure for `state_json` (LAW #6):
/// deep toggle, classified edges, node positions, collapse set. Computed
/// from the current timeline + view settings; `Null` when no timeline /
/// snapshot is loaded.
fn dep_graph_state(&self) -> serde_json::Value {
match &self.timeline {
Ok(tl) => self.dep_view.state_json_for(tl, self.selected),
// No timeline (empty/locked warehouse) — still EMIT a readable slice
// (LAW #6 / coverage-guard): a structured "empty, here's why" block, not
// a bare null the headless matrix can't distinguish from "no emitter".
Err(e) => serde_json::json!({
"empty": true,
"node_count": 0,
"edge_count": 0,
"error": format!("{e:#}"),
}),
}
}
/// The ⏳ Time Travel tab's cursor + (graph-mode) time-pinned dependency
/// graph for `state_json` (LAW #6). `Null` when no timeline is loaded.
fn timetravel_state(&self) -> serde_json::Value {
match &self.timeline {
Ok(tl) => self.timetravel.state_json(tl),
// No timeline — emit a readable "empty" slice (LAW #6 / coverage-guard)
// rather than a bare null, so the headless matrix sees the surface.
Err(e) => serde_json::json!({
"empty": true,
"releases": 0,
"error": format!("{e:#}"),
}),
}
}
/// Lazily fetch the server's `Health.Ping` identity (remote mode only),
/// caching the result so the About popup doesn't ping every frame.
fn ensure_server_info(&mut self) {
if self.server_info.is_some() {
return;
}
if let Source::Remote { endpoint, token } = &self.source {
self.server_info =
Some(super::remote::ping(endpoint, token).map_err(|e| format!("{e:#}")));
}
}
/// Headless test driver — render **every tab** once through a bare egui
/// context, exercising each tab's data-load + draw so a per-tab panic
/// (index-out-of-bounds, bad data) is caught by a test instead of crashing
/// the live GUI. No window/GPU needed (egui produces shapes, not pixels).
///
/// The auto-refresh wall-clock timer is suspended for the duration of the
/// sweep: a single headless pass is meant to leave EVERY tab loaded for the
/// caller's `state_json()` inspection, but the live timer's `reload()` clears
/// a pane's cached data *without* reloading it (the reload happens on that
/// pane's next `draw`). If the timer fired mid-sweep on a tab drawn AFTER a
/// data tab (e.g. while we're on Mcp, past Release), it would wipe the
/// Release pane we already loaded → the caller sees an empty surface even
/// though the warehouse read succeeded. Suspending it keeps the sweep a
/// deterministic single-pass; the original setting is restored afterwards.
pub fn render_all_tabs_headless(&mut self) {
let prev_auto_refresh = self.auto_refresh;
self.auto_refresh = false;
for t in Tab::ALL {
self.tab = t;
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
}
self.auto_refresh = prev_auto_refresh;
}
/// Write [`state_json`](Self::state_json) to `$NORNIR_VIZ_STATE` (default
/// `/tmp/nornir_viz_state.json`). Throttled; called every frame.
fn dump_state(&mut self) {
if self.last_dump.elapsed().as_millis() < 300 {
return;
}
self.last_dump = std::time::Instant::now();
let path = std::env::var("NORNIR_VIZ_STATE")
.unwrap_or_else(|_| "/tmp/nornir_viz_state.json".to_string());
if let Ok(s) = serde_json::to_string_pretty(&self.state_json()) {
let _ = std::fs::write(&path, s);
}
}
/// 🎬 Arm **demo mode**: install the hands-free guided-tour driver. Called
/// from the `nornir-ui --demo` launch path (and the headless test). `beat_ms`
/// is the pause between beats; the live update loop then emits one due beat per
/// frame on the real clock. A demo window keeps repainting (the tour requests
/// it) so the animations between clicks play.
pub fn enable_demo(&mut self, beat_ms: u64) {
// Keep the frame loop alive so spins/scroll-animations advance between
// beats (a demo window is never "idle").
self.auto_refresh = true;
self.action_log.push(
super::action_log::Kind::Life,
format!("🎬 demo mode armed — guided tour, {beat_ms}ms/beat"),
);
self.demo = Some(DemoRun {
driver: super::demo::DemoDriver::tour(beat_ms),
started: std::time::Instant::now(),
});
}
/// 🎬 Drive-step demo mode: emit the next due [`DemoStep`](super::demo::DemoStep)
/// onto the control channel (`$NORNIR_VIZ_CMD`) — a REAL `viz.click` the very
/// next [`poll_control_channel`](Self::poll_control_channel) consumes + applies,
/// so the tour goes through the exact same path a human/MCP click takes (motion
/// included). At most one beat per frame (the channel is consume-once). Loops
/// forever, rebasing the clock each pass, so a left-running window stays alive.
/// Returns the emitted step's label, if any (for the headless test).
fn demo_emit(&mut self, ctx: &egui::Context) -> Option<String> {
let Some(run) = self.demo.as_mut() else {
return None;
};
// Loop forever: once the pass is done, rebase the clock + rewind so the
// next pass keeps the same beat timing (a left-running window stays alive).
if run.driver.is_done() {
run.driver.rewind();
run.started = std::time::Instant::now();
}
let elapsed = run.started.elapsed().as_millis() as u64;
let step = run.driver.due(elapsed)?;
let _ = super::control::write_command(&step.cmd);
self.action_log.push(
super::action_log::Kind::Life,
format!("🎬 demo → {}", step.label),
);
// Keep painting so the next beat lands and the motion in between renders.
ctx.request_repaint_after(std::time::Duration::from_millis(120));
Some(step.label)
}
/// Robot-UI-tester drive-half: poll the control channel (`$NORNIR_VIZ_CMD`)
/// once per frame and apply any pending command — switch the active tab
/// (by `state_json()["tab"]` debug name) and/or the workspace. The command
/// file is consumed (removed) by [`control::take_pending`], so each command
/// fires exactly once; the result shows up in the next `$NORNIR_VIZ_STATE`
/// dump that `viz.state` reads back. See `control.rs`.
fn poll_control_channel(&mut self) {
let Some(pending) = super::control::take_pending() else {
return;
};
match pending {
Ok(cmd) => self.apply_command(&cmd),
Err(e) => {
self.action_log
.push(super::action_log::Kind::Error, format!("viz.click: {e}"));
}
}
}
/// Apply one robot-drive [`VizCommand`](super::control::VizCommand): set the
/// tab (validated by name) and/or switch the workspace, logging each as a
/// CLICK in the action trail so the drive is auditable in the same log as a
/// human click. Exposed `pub` so the inject-assert test can drive the exact
/// same code path the live control channel runs without touching files.
pub fn apply_command(&mut self, cmd: &super::control::VizCommand) {
if let Some(name) = &cmd.tab {
if let Some(t) = Tab::from_name(name) {
if self.tab != t {
self.action_log.push(
super::action_log::Kind::Click,
format!("viz.click tab → {name}"),
);
}
self.tab = t;
} else {
self.action_log.push(
super::action_log::Kind::Error,
format!("viz.click: unknown tab {name:?}"),
);
}
}
if let Some(ws) = &cmd.workspace {
if *ws != self.workspace_name {
self.action_log.push(
super::action_log::Kind::Click,
format!("viz.click workspace → {ws}"),
);
self.switch_workspace(ws.clone());
}
}
if let Some(pal) = &cmd.palette {
match super::facett_theme::Theme::by_name(pal) {
Some(t) if !super::facett_theme::same_palette(&t, &self.palette) => {
self.action_log.push(
super::action_log::Kind::Click,
format!("viz.click palette → {}", t.name),
);
self.set_palette(t);
}
Some(_) => {} // already active — no-op
None => self.action_log.push(
super::action_log::Kind::Error,
format!("viz.click: unknown palette {pal:?}"),
),
}
}
// R6 — set a named form field on the live viz (CLI/robot parity with
// RobotSession::set_field). Routed to the owning tab by key prefix.
if let Some(field) = &cmd.set_field {
match self.apply_set_field(&field.name, &field.value) {
Ok(()) => self.action_log.push(
super::action_log::Kind::Click,
format!("viz.set_field {} = {:?}", field.name, field.value),
),
Err(e) => self.action_log.push(
super::action_log::Kind::Error,
format!("viz.set_field {}: {e}", field.name),
),
}
}
// R6 — click a named button/action on the live viz (parity with
// RobotSession::click_by_id; a named key is the durable address here).
if let Some(key) = &cmd.click_id {
match self.apply_click_id(key) {
Ok(()) => self.action_log.push(
super::action_log::Kind::Click,
format!("viz.click_id {key}"),
),
Err(e) => self.action_log.push(
super::action_log::Kind::Error,
format!("viz.click_id {key}: {e}"),
),
}
}
// LAW4b — screenshot command: arm the screenshot state machine so the
// next draw_ui frame fires ViewportCommand::Screenshot.
if let Some(req) = &cmd.screenshot {
self.request_screenshot(req.out_path.clone());
}
}
/// The stable saga session id for this app instance (for the e2e proof test
/// + the Test tab's ⏺ Record/⏹ Stop status line).
pub fn saga_session_id(&self) -> &str {
&self.saga_session_id
}
// ── 🎬 Saga capture adapter (feature `saga`) ─────────────────────────────
/// The session context stamped onto every recorded `SagaEvent`: this app
/// instance's stable session id + app identity/version/commit.
#[cfg(feature = "saga")]
fn saga_ctx(&self) -> nornir_saga::event::SessionCtx {
nornir_saga::event::SessionCtx::new(
&self.saga_session_id,
"local",
"nornir-viz",
env!("CARGO_PKG_VERSION"),
option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown"),
)
}
/// Record the current tab as a replayable nav event. `component` is the tab
/// header's AccessKit label ([`Tab::label`]) so a replay's `ClickLabel`
/// re-drives the exact same header widget.
#[cfg(feature = "saga")]
fn saga_record_nav(&self) {
let route = format!("/tab/{:?}", self.tab);
let state = serde_json::json!({ "tab": format!("{:?}", self.tab) }).to_string();
nornir_saga::record(
nornir_saga::event::SagaEvent::nav(&route, self.tab.label(), "open", &state)
.in_session(&self.saga_ctx()),
);
}
/// Record a button/control activation as a replayable click event.
/// `component` MUST be the widget's AccessKit label so a replay's
/// `ClickLabel(component)` re-drives it. Public so any pane's on-click can
/// stamp a saga click through the same session ctx.
#[cfg(feature = "saga")]
pub fn saga_record_click(&self, component: &str, action: &str) {
let route = format!("/tab/{:?}", self.tab);
let state = serde_json::json!({ "tab": format!("{:?}", self.tab) }).to_string();
nornir_saga::record(
nornir_saga::event::SagaEvent::click(&route, component, action, &state)
.in_session(&self.saga_ctx()),
);
}
/// R6 — apply a named `set_field` command to the owning tab. The key is
/// `"<tab>.<field>"`; today the Funnel tab's intake/planner/demo fields are
/// wired (the live drive-half of the same verbs the in-process
/// `RobotSession::set_field` exposes). Returns `Err` for an unknown key.
fn apply_set_field(&mut self, name: &str, value: &str) -> Result<(), String> {
if let Some(field) = name.strip_prefix("funnel.") {
return self.funnel.set_field_for_drive(field, value);
}
// 💬 Chat live-drive: chat.input / chat.model / chat.system / chat.max_tokens.
if let Some(field) = name.strip_prefix("chat.") {
return self.chat.set_field_for_drive(field, value);
}
Err(format!("unknown field key {name:?}"))
}
/// R6 — apply a named `click_id` command (a button/action) to the owning tab.
fn apply_click_id(&mut self, key: &str) -> Result<(), String> {
if let Some(button) = key.strip_prefix("funnel.") {
return self.funnel.click_for_drive(button, &self.action_log);
}
// 🩸 Bloodstream drill-down: `bloodstream.expand:<crate>` drills into (or
// collapses) an organ's recursive external closure; `collapse_all` clears.
if let Some(action) = key.strip_prefix("bloodstream.") {
return self.bloodstream.click_for_drive(action);
}
// 💬 Chat live-drive: chat.send / chat.clear (runs the ACTIVE local model).
if let Some(button) = key.strip_prefix("chat.") {
return self.chat.click_for_drive(button, &self.action_log);
}
Err(format!("unknown button key {key:?}"))
}
/// **LAW4b** — arm a pending screenshot: record the desired `out_path` and
/// set `screenshot_pending = true`. The actual `ViewportCommand::Screenshot`
/// is fired in `draw_ui` each frame while `screenshot_pending` is `true`, so
/// the command reaches the egui backend during the normal paint loop.
/// Called from `apply_command` (control-channel drive) and directly from the
/// Ctrl+Shift+S keyboard handler.
pub fn request_screenshot(&mut self, out_path: Option<String>) {
self.screenshot_pending_path = out_path;
self.screenshot_pending = true;
self.action_log.push(
super::action_log::Kind::Click,
format!(
"LAW4b screenshot requested → {}",
self.screenshot_pending_path
.as_deref()
.unwrap_or("<warehouse only>")
),
);
}
/// **LAW4b** — encode a `ColorImage` to PNG bytes and persist:
/// 1. Write to `out_path` on disk (when `Some`).
/// 2. Append to the warehouse `robot_test_results` blob store so the
/// screenshot is durable and queryable (LAW 5 persist-to-warehouse).
/// Updates `screenshot_last_path` for the `state_json` dump.
fn handle_screenshot_image(&mut self, img: &egui::ColorImage, out_path: Option<String>) {
// Encode ColorImage (RGBA u8) → PNG bytes via the `image` crate.
let [w, h] = [img.width() as u32, img.height() as u32];
let rgba: Vec<u8> = img
.pixels
.iter()
.flat_map(|px| [px.r(), px.g(), px.b(), px.a()])
.collect();
let png_bytes = encode_rgba_png(w, h, &rgba);
match png_bytes {
Err(e) => {
self.action_log.push(
super::action_log::Kind::Error,
format!("LAW4b screenshot PNG encode failed: {e}"),
);
return;
}
Ok(bytes) => {
// 1. Write to disk when a path was requested.
let actual_path = if let Some(ref p) = out_path {
match std::fs::write(p, &bytes) {
Ok(()) => {
self.action_log.push(
super::action_log::Kind::Click,
format!("LAW4b screenshot written → {p} ({} bytes)", bytes.len()),
);
Some(p.clone())
}
Err(e) => {
self.action_log.push(
super::action_log::Kind::Error,
format!("LAW4b screenshot write failed {p}: {e}"),
);
None
}
}
} else {
None
};
self.screenshot_last_path = actual_path.or(out_path);
// ── emit-fallback (testmatrix only): the PNG write is a GPU-frame
// side effect a headless test can't observe via state_json until
// the frame fires. Record that a real PNG (non-empty, written to
// the requested path) landed. ok ⇔ the file exists on disk with
// the encoded bytes. Stripped from release.
#[cfg(feature = "testmatrix")]
{
let written_ok = self
.screenshot_last_path
.as_deref()
.map(|p| std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false))
.unwrap_or(false);
crate::selftest::emit(
"viz/app self-screenshot (LAW4b egui capture)",
"screenshot_command_pending_then_png_written",
written_ok,
&format!(
"{}x{}px → {} bytes PNG written to {:?}",
w,
h,
bytes.len(),
self.screenshot_last_path,
),
);
}
// 2. Append to the warehouse `robot_test_results` blob store
// (LAW 5: persist to warehouse, not just /tmp).
// This runs best-effort in local mode (where we have a warehouse
// path); in remote/empty mode we log a note but don't fail.
let snapshot_id = chrono::Utc::now()
.format("screenshot-%Y%m%d-%H%M%S")
.to_string();
let filename = self
.screenshot_last_path
.as_deref()
.and_then(|p| std::path::Path::new(p).file_name())
.and_then(|n| n.to_str())
.unwrap_or("screenshot.png")
.to_string();
let blob = crate::warehouse::blob_store::BlobRow::new(filename, bytes);
if let Source::Local(wh_path) = &self.source {
let wh_path = wh_path.clone();
match crate::warehouse::iceberg::IcebergWarehouse::open(&wh_path) {
Ok(wh) => {
match crate::warehouse::blob_store::put_blobs_dedup(
&wh,
"robot_test_results",
&snapshot_id,
vec![blob],
) {
Ok(n) => self.action_log.push(
super::action_log::Kind::Rpc,
format!("LAW4b screenshot → warehouse robot_test_results ({n} blobs)"),
),
Err(e) => self.action_log.push(
super::action_log::Kind::Error,
format!("LAW4b screenshot warehouse append: {e:#}"),
),
}
}
Err(e) => self.action_log.push(
super::action_log::Kind::Error,
format!("LAW4b screenshot warehouse open: {e:#}"),
),
}
} else {
self.action_log.push(
super::action_log::Kind::Click,
"LAW4b screenshot captured (remote mode: warehouse persist skipped)",
);
}
}
}
}
/// **testmatrix-only test hook** — feed a synthetic captured frame straight
/// into [`Self::handle_screenshot_image`] (the GPU-frame path), without a
/// real GPU/display. Lets the self-test exercise the PNG-write side effect
/// and read back the emit-fallback row. Compiled out of release.
#[cfg(feature = "testmatrix")]
pub fn test_feed_screenshot_frame(&mut self, w: usize, h: usize, out_path: Option<String>) {
// A solid non-blank RGBA frame so the encoded PNG is genuinely non-empty.
let rgba: Vec<u8> = std::iter::repeat([40u8, 120, 200, 255])
.take(w * h)
.flatten()
.collect();
let img = ColorImage::from_rgba_unmultiplied([w, h], &rgba);
self.screenshot_pending = false;
self.handle_screenshot_image(&img, out_path);
}
/// **testmatrix-only test hook** — inject a synthetic call graph into the
/// CallGraph pane and a synthetic funnel plan into the Funnel-3D pane, then
/// headlessly DRAW both tabs so their `#[cfg(feature = "testmatrix")]` egui
/// emit-fallback rows (`callgraph_rendered_non_blank`,
/// `funnel3d_rendered_non_blank`) fire as a NON-blank PASS — exercising the
/// render side-effect without a warehouse/GPU. Compiled out of release.
#[cfg(feature = "testmatrix")]
pub fn test_render_callgraph_and_funnel3d_nonblank(&mut self) {
// 1. CallGraph: inject a small ring graph + draw the CallGraph tab.
self.callgraph
.test_inject_graph(6, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)]);
self.tab = Tab::CallGraph;
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
// 2. Funnel-3D: load the deterministic demo DAG, flip the tab to 3D
// (which builds the Funnel3D cloud from the plan), then draw the tab.
let _ = self.funnel.run_demo_for_test(8);
let _ = self.funnel.set_3d_for_test(true);
self.tab = Tab::Funnel;
for _ in 0..2 {
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
}
}
/// **testmatrix-only test hook** — select the DepGraph tab and headlessly
/// DRAW it so `draw_dep_graph`'s `#[cfg(feature = "testmatrix")]` emit-fallback
/// row (`dep_graph_rendered_non_blank`) fires. The layout is built from the
/// loaded timeline, so the caller must open the app over a warehouse seeded
/// with a dep_graph snapshot + release lineage (else the layout is empty and
/// the emit — correctly — does not fire). Compiled out of release.
#[cfg(feature = "testmatrix")]
pub fn test_render_dep_graph_nonblank(&mut self) {
self.selected = None;
self.tab = Tab::DepGraph;
for _ in 0..2 {
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
}
}
/// Log a click trail to stderr whenever the tab or workspace changes, so the
/// operator watching the server log sees "what I clicked".
fn log_click(&mut self) {
let now = (self.tab, self.workspace_name.clone());
if now != self.last_click {
eprintln!(
"nornir-viz CLICK: tab={:?} workspace={} | {}",
self.tab, self.workspace_name, self.connect_status
);
self.last_click = now;
}
}
fn reload(&mut self) {
// The INPUT of a reload: which source + workspace we're asking for.
super::trace::emit_in(
"server.reload",
&serde_json::json!({ "source": self.source_label, "workspace": self.workspace_name }),
);
// No workspace selected (e.g. a fresh server with an EMPTY registry): skip
// the warehouse load — `Viz.Timeline` with an empty workspace returns
// NotFound (`workspace `` is not served`). The onboarding panel renders
// instead; sub-views re-hydrate once a workspace is registered + selected.
if self.workspace_name.is_empty() {
self.timeline = Ok(super::model::Timeline::default());
return;
}
self.timeline = self.source.load(&self.workspace_name);
// Re-read the release-op DAG on the next Release-tab draw too, so a fresh
// `nornir release run` shows without restarting the viz.
self.release.reload();
// 📡 Live Run re-hydrates from the durable warehouse `release_events`
// table (LAW persist-to-warehouse-stream-not-tmp): on reload/reboot the run
// is re-read from the warehouse, not just whatever `/tmp` log survived.
self.live.reload();
self.last_reload = std::time::Instant::now();
match &self.timeline {
Ok(tl) => {
let releases: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
self.action_log.push(
super::action_log::Kind::Rpc,
format!(
"reload timeline workspace={} → {releases} release(s)",
self.workspace_name,
),
);
// The OUTPUT data: the per-lane (per-repo) release counts the
// timeline actually carries — what the user sees painted.
super::trace::emit_out(
"server.reload",
&serde_json::json!({
"workspace": self.workspace_name,
"releases": releases,
"lanes": tl.lanes.iter()
.map(|l| serde_json::json!({ "repo": l.repo, "releases": l.nodes.len() }))
.collect::<Vec<_>>(),
}),
);
}
Err(e) => {
self.action_log.push(
super::action_log::Kind::Error,
format!("reload failed: {e:#}"),
);
super::trace::emit(
"server.reload",
super::trace::Phase::Out,
&serde_json::json!({ "workspace": self.workspace_name, "error": format!("{e:#}") }),
);
}
}
}
/// The server bundle for the ops tabs (None in local-warehouse mode).
fn server(&self) -> Option<super::ops_tabs::Server> {
match &self.source {
Source::Remote { endpoint, token } => Some(super::ops_tabs::Server {
endpoint: endpoint.clone(),
token: token.clone(),
}),
Source::Local(_) => None,
}
}
/// Draw the 🏛 Architecture CONTAINER: the arch tab paints the sub-view
/// selector strip + the lenses it owns (Live / Wiring / Hållpunkter / Metro),
/// then leaves the central area free for the two lenses whose renderers live on
/// the APP — `Deps` (needs the timeline) and `CallGraph` (the warehouse-backed
/// `CallGraphState`). This keeps ONE renderer per surface and makes the restored
/// function call graph reachable as a distinct Architecture lens.
fn draw_architecture(&mut self, ui: &mut egui::Ui) {
self.arch.draw(ui);
match self.arch.sub_view() {
// 🔗 Deps (CONSOLIDATED under Arch): the dep graph painted here, the
// same `draw_dep_graph` the old top-level Dep Graph tab used.
super::arch_tab::ArchSubView::Deps => {
egui::CentralPanel::default().show_inside(ui, |ui| match &self.timeline {
Ok(tl) if !tl.is_empty() => {
draw_dep_graph(
ui,
tl,
self.selected,
&mut self.dep_selected_repo,
&mut self.dep_view,
);
}
Ok(_) => {
ui.weak("no releases recorded yet — the dependency graph is built from the warehouse timeline.");
}
Err(e) => {
ui.colored_label(super::facett_theme::RED, format!("dep graph load failed:\n{e:#}"));
}
});
}
// 🕸 Call Graph (RESTORED): the real function/symbol call graph from
// `callgraph.rs`, drilled repo → crate → fn calls over `call_edges`.
// Drawn here because its renderer owns the warehouse-backed
// `CallGraphState`; the arch tab left the central area free for it.
super::arch_tab::ArchSubView::CallGraph => {
egui::CentralPanel::default().show_inside(ui, |ui| {
self.callgraph.draw(ui);
});
}
_ => {}
}
}
/// Re-list the server's workspaces into the picker (after a root-pane Add /
/// Kill mutated the set). No-op in local mode. If the *currently selected*
/// workspace was killed, drop the selection back to the empty "pick one"
/// state — we never auto-jump to another workspace (the server has no default
/// route, and the user picks from the top-bar). If nothing was selected yet,
/// stay unselected even as workspaces appear.
fn refresh_workspaces(&mut self) {
if let Source::Remote { endpoint, token } = &self.source {
if let Ok(ws) = super::remote::list_workspaces(endpoint, token) {
self.workspaces = ws;
// Only react to the active selection vanishing — never auto-select
// when there was no selection to begin with.
if !self.workspace_name.is_empty()
&& !self.workspaces.contains(&self.workspace_name)
{
self.switch_workspace(String::new());
}
}
}
}
/// React to a 🧬 root-pane op that mutated the server's workspace set (Add /
/// Kill / Populate / Refresh). Always re-lists the picker so it reflects the
/// change. If the op was an **Add** (a workspace was just registered), AUTO-
/// SELECT that workspace — drive the same picker switch a top-bar click does —
/// so the user immediately watches `W` populate live instead of having to pick
/// it manually. `switch_workspace` already reloads + re-scopes every tab, so we
/// skip the standalone `reload()` in that case (no double-fetch). Otherwise
/// (Kill / Populate / Refresh, no new workspace) we just reload the active view.
fn on_root_mutation(&mut self) {
self.refresh_workspaces();
if let Some(new_ws) = self.nornir_root.take_just_registered() {
// A delete + re-add of the SAME name must still re-scope and reload:
// switch_workspace() no-ops when the name is unchanged, and the kill
// path never clears `workspace_name`, so a re-add of the just-killed
// workspace would otherwise leave every pane pointed at stale data.
// Clear first to force the full re-scope + reload.
if new_ws == self.workspace_name {
self.workspace_name.clear();
}
self.switch_workspace(new_ws);
} else {
self.reload();
}
}
/// Switch the active workspace (the picker): re-scope every server-backed tab
/// (timeline, warehouse, call graph) and reload immediately.
fn switch_workspace(&mut self, ws: String) {
if ws == self.workspace_name {
return;
}
self.action_log.push(
super::action_log::Kind::Life,
format!("switch workspace {} → {}", self.workspace_name, ws),
);
self.workspace_name = ws.clone();
self.selected = None;
self.warehouse.set_workspace(ws.clone());
self.funnel.set_workspace(ws.clone());
self.mcp.set_workspace(ws.clone());
self.callgraph.set_workspace(ws.clone());
// 🏛 Architecture: re-point the thin RPC at the new workspace name now;
// its members are re-seeded below once the new repo set is known.
self.arch.set_workspace_name(ws.clone());
// Re-point the thin-mode RPC-backed tabs at the new workspace so their
// Viz.ReleaseEvents / Viz.BakeoffResults / Viz.ReleaseEvents-hydrate
// refetch for the right server workspace (no-ops in local mode).
self.release.set_workspace(ws.clone());
self.live.set_workspace(ws.clone());
// Re-scope the Knowledge tab: the workspace's repo set changes per
// workspace, so fetch the new members (server mode) and hand the tab the
// fresh root + repos. It drops the stale scans and arms a background
// rescan, so the map re-scopes for the new workspace without blocking.
if let Source::Remote { endpoint, token } = &self.source {
match super::remote::get_workspace(endpoint, token, &ws) {
Ok(info) => {
self.repos = info.members.into_iter().map(|(m, _)| m).collect();
eprintln!("nornir-viz: repos from server `{ws}` → {:?}", self.repos);
}
Err(e) => eprintln!("nornir-viz: Workspaces.Get(`{ws}`) failed on switch: {e:#}"),
}
}
// 🏛 Architecture: re-scope to the new workspace's members (drops the
// cached board so the next draw re-merges/re-fetches for the new set).
self.arch.set_workspace(self.repos.clone());
// N4 — re-seed the workspace git root so the source viewer keeps reading the
// real clones after a workspace switch (both modes).
self.arch.set_source_root(self.workspace_root.clone());
// Re-seed the click-to-code source for the new workspace's first member
// (local mode — remote has no checkout).
if let (Source::Local(_), Some(first)) = (&self.source, self.repos.first()) {
let repo_root = crate::config::repo_dir_resolved(&self.workspace_root, first);
self.arch.set_resolve_source(repo_root, first.clone());
}
// 🧬 RELEASE DASHBOARD: re-scope its gate inputs to the new workspace's
// repos (local mode), so the gate-overlaid graph re-scans the right set.
if matches!(&self.source, Source::Local(_)) {
self.nornir_dashboard.set_gate_inputs(
&self.workspace_root,
&self.repos,
crate::release::doctor::DepPolicy::default(),
);
} else {
self.nornir_dashboard.set_remote();
}
self.knowledge
.set_workspace(self.workspace_root.clone(), self.repos.clone());
self.knowledge.set_workspace_name(ws.clone());
// 🛡 Security scans the workspace's configured repos too — re-scope it
// with the same fresh root + members, else its repo buttons keep
// listing the previously selected workspace.
self.security
.set_workspace(self.workspace_root.clone(), self.repos.clone());
self.reload();
self.warehouse.refresh_tables();
}
/// **Age `started_at` past the intro splash** — the ONE writer of that
/// wind-back.
///
/// The opaque intro overlay swallows hit-tests for its first
/// `SPLASH_DURATION_SECS`, so every headless harness has to get past it before
/// a driver can click anything. Fourteen `inject_*_for_test` helpers each
/// carried a byte-identical copy of the two-line wind-back; they all call this
/// now (LAW 5 — one writer, not fourteen agreeing copies).
///
/// It is also the whole of what [`crate::viz::robot_remote`] needs. That module
/// used to call `inject_test_results_for_test(Vec::new())` purely for this side
/// effect, and when `ce553b9` cut the 🧪 Test tab and its injector the call was
/// left behind — a hard compile error that nothing caught, because the
/// `robot-remote` feature is built by no default and no CI command, so it had
/// not been compiled since. Seeding NO data is the honest thing here: the
/// robot-drive mount is meant to serve the app as it really starts.
#[doc(hidden)]
pub fn age_past_splash_for_test(&mut self) {
self.started_at = std::time::Instant::now()
- std::time::Duration::from_secs_f32(SPLASH_DURATION_SECS + 1.0);
}
/// Test-only data injector for the headless snapshot harness
/// (`tests/viz_snapshots.rs`).
///
/// Local `workspace_*` warehouses are empty in this checkout (the live
/// release / dep-graph / bench data lives on the `nornir-server` and is
/// reached over gRPC via [`with_remote`](Self::with_remote)). To render a
/// viz tab **with real, non-zero data** as a committed PNG proof — without
/// standing up a server — the snapshot test builds a concrete [`Timeline`]
/// from the public [`crate::viz::model`] types and injects it here.
///
/// Besides swapping in the timeline + repo list, this:
/// * selects `tab` by its `state_json()["tab"]` debug name (so the test
/// drives a specific view without depending on the private `Tab` enum),
/// * ages `started_at` past the splash so the opaque intro overlay does
/// not cover the captured frame, and
/// * disables `auto_refresh` so the next `draw_ui` frame does not reload
/// the (empty) source warehouse over the injected timeline.
///
/// Returns `true` if `tab` matched a known tab name.
#[doc(hidden)]
pub fn inject_timeline_for_test(
&mut self,
tab: &str,
timeline: Timeline,
repos: Vec<String>,
) -> bool {
let matched = match tab {
"Nornir" => {
self.tab = Tab::Nornir;
true
}
"Timeline" => {
self.tab = Tab::Timeline;
true
}
"DepGraph" => {
self.tab = Tab::DepGraph;
true
}
"CallGraph" => {
self.tab = Tab::CallGraph;
true
}
"Architecture" => {
self.tab = Tab::Architecture;
true
}
"Bloodstream" => {
self.tab = Tab::Bloodstream;
true
}
"Funnel" => {
self.tab = Tab::Funnel;
true
}
"TimeTravel" => {
self.tab = Tab::TimeTravel;
true
}
"LiveRun" => {
self.tab = Tab::LiveRun;
true
}
"Release" => {
self.tab = Tab::Release;
true
}
"Knowledge" => {
self.tab = Tab::Knowledge;
true
}
"Warehouse" => {
self.tab = Tab::Warehouse;
true
}
"Mcp" => {
self.tab = Tab::Mcp;
true
}
"Search" => {
self.tab = Tab::Search;
true
}
"Gates" => {
self.tab = Tab::Gates;
true
}
"Chat" => {
self.tab = Tab::Chat;
true
}
"Security" => {
self.tab = Tab::Security;
true
}
// ⚒ Dwarves (N5) — the merged forge tab. "Build"/"Brokkr" are kept as
// back-compat aliases (old control-channel links / robot keys) that
// route to the same tab.
"Dwarves" | "Build" | "Brokkr" => {
self.tab = Tab::Dwarves;
true
}
"Holger" => {
self.tab = Tab::Holger;
true
}
"Manual" => {
self.tab = Tab::Manual;
true
}
"Claude" => {
self.tab = Tab::Claude;
true
}
"Models" => {
self.tab = Tab::Models;
true
}
// These four were MISSING and the map had drifted behind `Tab::ALL`
// exactly the way the MCP tab list did: 🚇 Metro, 🧬 Time Helix, 🏢
// Warehouse Deck and 🎛 Settings all exist as panes and as `VIZ_TABS`
// entries, but this control channel answered `false` for them — so a
// headless driver that (correctly) avoids synthetic pointer buttons
// had NO way to reach them and silently drove the previous tab
// instead. `tab_control_channel_accepts_every_tab_id` in
// `tests/robot_settings_tab.rs` is the gate that keeps this in step.
"Metro" => {
self.tab = Tab::Metro;
true
}
"Helix" => {
self.tab = Tab::Helix;
true
}
"WarehouseDeck" => {
self.tab = Tab::WarehouseDeck;
true
}
"Settings" => {
self.tab = Tab::Settings;
true
}
_ => false,
};
if !repos.is_empty() {
self.repos = repos;
}
self.timeline = Ok(timeline);
self.auto_refresh = false;
// Push `started_at` back so the splash overlay has fully faded out.
self.age_past_splash_for_test();
matched
}
/// Test-only inject seam for the 📦 Holger pane: replace the live pane with
/// one seeded from a fixed set of repo summaries, so the headless matrix can
/// assert the pane lists the repos / reports counts WITHOUT a live holger
/// server or an on-disk znippy archive. Mirrors the `read_registry`
/// functional-API output (one [`RepoSummary`] per repo). No-op when holger
/// isn't embedded.
#[doc(hidden)]
#[cfg(feature = "embed-holger")]
pub fn inject_holger_for_test(&mut self, repos: Vec<crate::holger_embed::RepoSummary>) {
self.holger = super::holger_tab::HolgerTab::inject(repos);
self.holger.set_palette(self.palette);
}
/// Test-only: inject the 🔍 Search tab's `Index.Stats` result (no live server)
/// and select the tab — so a headless drive can read the LAW-2 `index_health`
/// classification back out of `state_json()["search"]` (an EMPTY code-index —
/// the FAT-workspace symlink-walker symptom — must read `"empty"`, never a
/// silent green). Mirrors `knowledge_scan_blocking_for_test`.
#[doc(hidden)]
pub fn inject_search_index_stats_for_test(
&mut self,
total: u64,
by_corpus: Vec<(String, String)>,
) {
self.tab = Tab::Search;
self.search.inject_index_stats_for_test(total, by_corpus);
}
/// Test-only: inject release-op rows into the 🚀 Release tab directly (no
/// warehouse on disk), then select the tab — so the inject-and-assert harness
/// can read the op-log rows + per-component status back out of
/// `state_json()["release"]`. Mirrors `inject_timeline_for_test` for the
/// release-events surface.
#[doc(hidden)]
pub fn inject_release_events_for_test(
&mut self,
rows: Vec<crate::warehouse::release_events::ReleaseEventRow>,
) {
self.tab = Tab::Release;
self.release.inject_for_test(rows);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: inject a Gate / Doctor model into the 🚀 Release tab (no checkout
/// to scan), then select the tab — so the robot/inject-assert harness can read
/// the `state_json()["release"]["gate"]` block back. Mirrors
/// `inject_release_events_for_test` for the gate surface.
#[doc(hidden)]
pub fn inject_release_gate_for_test(
&mut self,
doctor: crate::release::doctor::DoctorReport,
edition: nornir_release_core::edition::EditionReport,
) {
self.tab = Tab::Release;
self.release.inject_gate_for_test(doctor, edition);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: inject a Gate / Doctor model into the 🧬 nornir RELEASE
/// DASHBOARD (no checkout to scan) + select the Nornir tab, so the
/// robot/inject-assert harness reads `state_json()["nornir_dashboard"]` back.
/// Mirrors `inject_release_gate_for_test` for the dashboard surface.
#[doc(hidden)]
pub fn inject_dashboard_gate_for_test(
&mut self,
doctor: crate::release::doctor::DoctorReport,
edition: nornir_release_core::edition::EditionReport,
) {
self.tab = Tab::Nornir;
self.nornir_dashboard.inject_gate_for_test(doctor, edition);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: drive a node click on the 🧬 dashboard graph (parity with a
/// pointer click), scoping the release to `repo` + lighting its blast radius.
/// Returns whether the repo is a node in the current graph.
#[doc(hidden)]
pub fn dashboard_select_for_test(&mut self, repo: &str) -> bool {
self.nornir_dashboard.select_repo_for_test(repo)
}
/// Test-only: open the 🧬 dashboard's 🧙 RELEASE WIZARD (what the Release button
/// does), so the robot/inject-assert harness reads
/// `state_json()["nornir_dashboard"]["release_wizard"]` back.
#[doc(hidden)]
pub fn dashboard_open_wizard_for_test(&mut self) {
self.nornir_dashboard.open_wizard_for_test();
}
/// Test-only: arm/disarm a wizard step's override (advance-anyway past a red gate).
#[doc(hidden)]
pub fn dashboard_wizard_override_for_test(&mut self, step: &str, armed: bool) -> bool {
self.nornir_dashboard
.wizard_set_override_for_test(step, armed)
}
/// Test-only: advance the wizard one step if its gate allows; returns the new step.
#[doc(hidden)]
pub fn dashboard_wizard_advance_for_test(&mut self) -> String {
self.nornir_dashboard.wizard_advance_for_test()
}
/// Test-only: seed the Rehearse tier as passed/failed (no real stage run).
#[doc(hidden)]
pub fn dashboard_wizard_inject_rehearse_for_test(&mut self, ok: bool, lines: Vec<String>) {
self.nornir_dashboard
.wizard_inject_rehearse_for_test(ok, lines);
}
/// Test-only: type the promote confirm phrase into the wizard.
#[doc(hidden)]
pub fn dashboard_wizard_promote_confirm_for_test(&mut self, input: &str) {
self.nornir_dashboard
.wizard_set_promote_confirm_for_test(input);
}
/// Test-only: inject a merged [`ArchGraph`](crate::arch::ArchGraph) into the
/// 🏛 Architecture tab directly (no warehouse on disk / no server), exactly as
/// `merged_arch_from_warehouse` / `Viz.Architecture` would deliver, then select
/// the tab — so the inject-and-assert harness can read the board's node/edge/
/// per-kind counts back out of `state_json()["architecture"]` and snapshot it.
#[doc(hidden)]
pub fn inject_arch_for_test(
&mut self,
graph: crate::arch::ArchGraph,
members_with_data: Vec<String>,
) {
self.tab = Tab::Architecture;
self.arch.inject_for_test(graph, members_with_data);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: inject a pre-built [`WarehouseDeck`](facett_warehousedeck::WarehouseDeck)
/// into the 🏢 Warehouse Deck pane (no warehouse on disk / no server), exactly
/// as `WarehouseDeckPane::build` would assemble from the warehouse, then select
/// the tab — so the inject-and-assert harness reads the pane's pane-count /
/// per-kind counts back out of `state_json()["warehouse_deck"]`.
#[doc(hidden)]
pub fn inject_warehouse_deck_for_test(&mut self, deck: facett_warehousedeck::WarehouseDeck) {
self.tab = Tab::WarehouseDeck;
self.warehouse_deck.inject_for_test(deck);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: swap the 🔨 Build tab's build runner (inject a mock) so a headless
/// robot can drive the BUILD button WITHOUT spawning a real `nornir build`
/// subprocess, then dismiss the splash + stop auto-refresh. The Build tab renders
/// without a workspace/warehouse (its demo build-space, holger, is embedded), so
/// no seeding is needed. Drives `tests/viz_build_robot.rs`.
#[doc(hidden)]
pub fn set_build_runner_for_test(
&mut self,
runner: std::sync::Arc<dyn super::build_tab::BuildRunner>,
) {
self.dwarves.build.set_runner_for_test(runner);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// 🤖 Inject a [`ClaudeTransport`](super::claude::ClaudeTransport) into the
/// Claude tab — the test seam that drives a real send through a
/// [`FakeTransport`](super::claude::FakeTransport) (no network) and dismisses
/// the splash. Drives the Claude robot test in `tests/robot_viz.rs`.
#[doc(hidden)]
pub fn set_claude_transport_for_test(
&mut self,
transport: std::sync::Arc<dyn super::claude::ClaudeTransport>,
) {
self.claude.set_transport_for_test(transport);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// 🤖 Drive the Claude tab's send directly (set the prompt, then Send) —
/// used by the robot test after injecting a fake transport.
#[doc(hidden)]
pub fn claude_send_for_test(&mut self, prompt: impl Into<String>) {
self.claude.set_prompt_for_test(prompt);
self.claude.send_for_test();
}
/// 🤖 Poll the Claude tab's in-flight send (test seam — the robot pumps this
/// between frames until the assistant turn lands).
#[doc(hidden)]
pub fn claude_poll_for_test(&mut self) {
self.claude.poll();
}
/// 🧠 Inject a [`ModelDownloader`](super::models_tab::ModelDownloader) into the
/// Models tab — the test seam that drives a real download through a
/// [`FakeDownloader`](super::models_tab::FakeDownloader) (no network/disk) and
/// dismisses the splash. Drives the Models robot test in `tests/robot_viz.rs`.
#[doc(hidden)]
pub fn set_model_downloader_for_test(
&mut self,
downloader: std::sync::Arc<dyn super::models_tab::ModelDownloader>,
) {
self.models.set_downloader_for_test(downloader);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// 🧠 Start a download for catalog entry `idx` on the selected device (test
/// seam). Returns the new job id.
#[doc(hidden)]
pub fn models_download_for_test(&mut self, idx: usize) -> String {
self.models.download_for_test(idx)
}
/// 🧠 Select the download device (test seam).
#[doc(hidden)]
pub fn models_set_device_for_test(&mut self, device: super::models_tab::Device) {
self.models.set_device_for_test(device);
}
/// 🧠 Poll the Models tab's in-flight downloads (test seam — the robot pumps
/// this between frames until the download reaches a terminal status).
#[doc(hidden)]
pub fn models_poll_for_test(&mut self) {
self.models.poll_for_test();
}
/// 🧠 Cancel a download by job id (test seam). Returns whether one matched.
#[doc(hidden)]
pub fn models_cancel_for_test(&mut self, job_id: &str) -> bool {
self.models.cancel_for_test(job_id)
}
/// Test-only: drive a click on the 🏛 Architecture node with `label` (parity
/// with a pointer click), so a test can assert the downstream-trace highlight
/// via `state_json()["architecture"]`. Returns whether a node matched.
#[doc(hidden)]
pub fn arch_select_for_test(&mut self, label: &str) -> bool {
self.arch.select_by_label_for_test(label)
}
/// Test-only: inject scanned symbols into the 🏛 Architecture tab so the
/// click-to-code resolver + the boundary-landmark classifier run without a
/// checkout/warehouse scan.
#[doc(hidden)]
pub fn inject_arch_symbols_for_test(
&mut self,
symbols: Vec<crate::knowledge::symbols::SymbolRow>,
) {
self.arch.inject_symbols_for_test(symbols);
}
/// Test-only: drive the 🏛 Architecture tab's click-to-code on `label` (parity
/// with a node click), WITHOUT launching the editor, so a test asserts the
/// resolved `file:line` + inline preview via `state_json()["architecture"]`.
/// Returns whether a symbol resolved.
#[doc(hidden)]
pub fn arch_open_symbol_for_test(&mut self, label: &str) -> bool {
self.arch.open_symbol(label, false)
}
/// Test-only: switch the 🏛 Architecture container's sub-view (parity with a
/// selector chip click). Returns whether the id was valid.
#[doc(hidden)]
pub fn arch_set_sub_view_for_test(&mut self, id: &str) -> bool {
self.arch.set_sub_view_by_id_for_test(id)
}
/// Test-only: inject scanned symbols + call edges into the 🏛 Architecture
/// tab + build the 🚇 metro map, so a test reads the button→gRPC paths back
/// from `state_json()["architecture"]["metro"]` without a checkout scan.
#[doc(hidden)]
pub fn inject_arch_scan_for_test(
&mut self,
symbols: Vec<crate::knowledge::symbols::SymbolRow>,
calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
) {
self.arch.inject_scan_for_test(symbols, calls);
self.arch.build_metro_for_test();
}
/// Test-only: inject scanned facts WITH the SCIP-provenance fuel tag (the same
/// tag `query::load_preferred` returns — `"resolved/scip"` vs `"syn"`), then
/// rebuild the 🚇 metro, so a test asserts the per-line provenance overlay in
/// `state_json()["architecture"]["metro"]` without a `--features scip` warehouse.
#[doc(hidden)]
pub fn arch_inject_metro_fuel_for_test(
&mut self,
symbols: Vec<crate::knowledge::symbols::SymbolRow>,
calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
source: &str,
) {
self.arch
.inject_scan_with_source_for_test(symbols, calls, source);
self.arch.build_metro_for_test();
}
/// Test-only: select a 🚇 metro line by button id (parity with the line
/// selector click), so a test asserts the selected line's `state_json` slice.
#[doc(hidden)]
pub fn arch_select_metro_for_test(&mut self, button_id: &str) {
self.arch.select_metro_button_for_test(button_id);
}
/// Test-only: hydrate the 📡 Live Run pane from injected `release_events`
/// rows (no warehouse on disk), exactly as the durable warehouse read would,
/// then select the tab — so the inject-and-assert harness can read the
/// hydrated run + rendered event lines back out of `state_json()["live"]`.
/// Mirrors `inject_release_events_for_test` for the live surface (C3).
#[doc(hidden)]
pub fn inject_live_release_events_for_test(
&mut self,
rows: Vec<crate::warehouse::release_events::ReleaseEventRow>,
) {
self.tab = Tab::LiveRun;
self.live.inject_for_test(rows);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: hydrate the 📡 Live Run pane from the **real on-disk warehouse**
/// `release_events` table synchronously (the durable read path the pane runs on
/// load/reload), then select the tab — so an inject-and-assert test can seed
/// `release_events` rows to a warehouse, point the app at it, and read the
/// hydrated run back out of `state_json()["live"]` (C3, proves warehouse-sourced).
#[doc(hidden)]
pub fn live_hydrate_from_warehouse_for_test(&mut self) {
self.tab = Tab::LiveRun;
self.live.reload();
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only: drive the 🔗 Dep Graph view's deep toggle (C5) and collapse
/// set (I3) directly, so the inject-and-assert harness can flip to the
/// transitive-closure explode / collapse a node and read the resulting
/// laid-out graph back out of `state_json()["dep_graph"]`.
#[doc(hidden)]
pub fn set_dep_view_for_test(&mut self, deep: bool, collapsed: &[&str]) {
self.dep_view.deep = deep;
self.dep_view.collapsed = collapsed.iter().map(|s| s.to_string()).collect();
}
/// Test-only: drive the ⏳ Time Travel tab's cursor (`idx`), the C4 text⇄graph
/// toggle, and the reused dep-view deep flag directly, so the inject-and-assert
/// harness can pin to a point in time, switch to graph view, and read the
/// time-pinned graph (nodes/edges + direct/transitive classes) back out of
/// `state_json()["timetravel"]`. Mirrors `set_dep_view_for_test`.
#[doc(hidden)]
pub fn set_timetravel_for_test(&mut self, idx: usize, graph: bool, deep: bool) {
self.tab = Tab::TimeTravel;
self.timetravel.idx = idx;
self.timetravel.graph = graph;
self.timetravel.dep_view.deep = deep;
}
/// Test-only (slice-5 capstone): inject §f3 build-info rows into the 📡
/// build-info LIVE-TAIL pane directly — no Flight server — exactly as if
/// [`subscribe`](crate::build::flight_sink::subscribe) had returned them, then
/// select the 📡 Live Run tab. The inject-and-assert harness reads the growing
/// tail + per-sub-job status back out of `state_json()["build_info_live"]`.
/// Call it repeatedly to prove the tail GROWS across batches.
#[doc(hidden)]
pub fn inject_build_info_for_test(
&mut self,
rows: Vec<nornir_build_thing::build_info::BuildInfoRow>,
) {
self.tab = Tab::LiveRun;
self.build_info_tail.inject_for_test(rows);
self.auto_refresh = false;
self.age_past_splash_for_test();
}
/// Test-only (slice-5 auto-refresh): the 📡 build-info live-tail pane's inbox
/// sender, so a test can simulate the background poller delivering a freshly-
/// pulled batch on an interval (each `send` == one interval's `pull` rows).
/// The pane's own `draw` drains it — proving the pane AUTO-UPDATES without a
/// re-inject. Selects the 📡 Live Run tab so the pane is drawn + drained.
#[doc(hidden)]
pub fn build_info_poll_sender_for_test(
&mut self,
) -> std::sync::mpsc::Sender<Vec<nornir_build_thing::build_info::BuildInfoRow>> {
self.tab = Tab::LiveRun;
self.auto_refresh = false;
self.age_past_splash_for_test();
self.build_info_tail.poll_sender_for_test()
}
/// Test-only: re-scope the Knowledge tab to a fresh `workspace_root`/`repos`
/// (what a workspace switch does), so a test can assert the scans reset.
#[doc(hidden)]
pub fn knowledge_set_workspace_for_test(
&mut self,
workspace_root: PathBuf,
repos: Vec<String>,
) {
self.workspace_root = workspace_root.clone();
self.repos = repos.clone();
self.knowledge.set_workspace(workspace_root, repos);
}
/// Test-only: make the Knowledge tab ask its code room now (a draw does it
/// lazily), so a headless test reads the loaded state back.
#[doc(hidden)]
pub fn knowledge_load_for_test(&mut self) {
self.knowledge.load_now();
}
/// Test-only: mutable handle to the Funnel tab, so the E1 viz test can drive
/// the demo / nuke / confirm-guard through the same code paths the buttons
/// invoke and then assert `state_json()["funnel"]`.
#[doc(hidden)]
pub fn funnel_tab_for_test(&mut self) -> &mut super::funnel_tab::FunnelTabState {
&mut self.funnel
}
/// Test-only: drive the workspace picker exactly as a click does — re-scope
/// every tab (timeline, warehouse, release, leaderboard, knowledge, security,
/// …) to `ws` and reload. The workspace×function matrix uses this to prove
/// each function follows a switch with no stale leak from the previous one.
#[doc(hidden)]
pub fn switch_workspace_for_test(&mut self, ws: String) {
self.switch_workspace(ws);
}
/// Test-only: drive the 🧬 root-pane Add-workspace flow exactly as a successful
/// `Workspaces.Register` does — latch `name` as just-registered, then run the
/// post-mutation handler (`on_root_mutation`) the `draw` call site invokes. The
/// state test asserts this AUTO-SELECTS `name` (Add(W) ⇒ selected == W).
#[doc(hidden)]
pub fn register_workspace_for_test(&mut self, name: &str) {
self.nornir_root.set_just_registered_for_test(name);
self.on_root_mutation();
}
}
/// **LAW4b** — encode a raw RGBA byte slice to PNG using the `image` crate
/// (already in the `viz` feature graph via `docs-export`).
fn encode_rgba_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>> {
use image::{ImageBuffer, Rgba};
let buf: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba.to_vec())
.ok_or_else(|| anyhow::anyhow!("RGBA image buffer mismatch ({}×{})", width, height))?;
let mut out = Vec::new();
buf.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
const LANE_HEIGHT: f32 = 80.0;
const LANE_PAD: f32 = 24.0;
const NODE_RADIUS: f32 = 14.0;
const LEFT_GUTTER: f32 = 140.0;
/// A gate/release status → colour on the active facett palette (C8). Delegates
/// to the palette's shared semantic ramp so the timeline + side panel re-skin
/// with the theme.
fn status_color(theme: &super::facett_theme::Theme, status: &str) -> Color32 {
theme.status_color(status)
}
impl eframe::App for UrdrThreadsApp {
// eframe 0.34 made `ui(&mut Ui, frame)` the required entry point (the old
// `update(ctx, frame)` is deprecated). The given `ui` is a bare root with no
// CentralPanel, so the existing ctx-panel layout in `draw_ui` works unchanged
// when driven off `ui.ctx()`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.draw_ui(ui);
}
}
impl UrdrThreadsApp {
/// Full per-frame UI build, independent of `eframe::Frame` (it's unused) so
/// headless tests can render every tab through a bare `egui::Context`.
pub fn draw_ui(&mut self, ui: &mut egui::Ui) {
let ctx = &ui.ctx().clone();
// Apply the active facett palette's egui Visuals (C8) — themes the
// standard widgets (panels, buttons, selection, hyperlinks) to match the
// custom-painted panes. Only on change, so we don't fight egui every frame.
if !self.palette_applied {
ctx.set_visuals(self.palette.visuals());
// Spacious look-and-feel: widen the global egui spacing once, in the
// same "set it at the top" pass as the palette, so every standard
// widget inherits the roomier rhythm with no per-component change.
super::facett_theme::apply_spacious_spacing(ctx);
self.palette_applied = true;
}
// Auto-refresh: reload the timeline + warehouse table list on a timer so a
// server-monitored workspace animates live as the poll loop republishes.
if self.auto_refresh && self.last_reload.elapsed().as_secs() >= self.refresh_secs {
self.reload();
self.warehouse.refresh_tables();
ctx.request_repaint();
}
if self.auto_refresh {
// Keep the frame loop ticking even when idle so the timer fires.
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
// Robot-UI-tester drive-half: apply any pending `viz.click` command from
// the control channel BEFORE we paint, so this frame reflects it (and the
// post-frame `dump_state` shows the result to the next `viz.state`). Keep
// the loop ticking briefly so a command lands within a frame or two even
// when the window is otherwise idle.
// 🎬 Demo mode: emit the next due tour beat onto the control channel FIRST,
// so the poll below consumes + applies it this same frame (a real click).
self.demo_emit(ctx);
self.poll_control_channel();
ctx.request_repaint_after(std::time::Duration::from_millis(250));
// **LAW4b** — Ctrl+Shift+S keyboard shortcut: request a self-screenshot.
// The path defaults to /tmp/nornir_viz_screenshot_<timestamp>.png.
ctx.input(|i| {
if i.key_pressed(egui::Key::S) && i.modifiers.ctrl && i.modifiers.shift {
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let path = format!("/tmp/nornir_viz_screenshot_{ts}.png");
self.request_screenshot(Some(path));
}
});
// ── "Ask at close" guard (VIZ-TEST-JOBS) ────────────────────────────
// If the window is being closed while jobs are still running (detached
// server jobs the user may not want to abandon), cancel the close and pop
// a confirm modal. The running count is the 🧬 nornir tab's job list (last
// poll); `allow_close` lets a confirmed close through unguarded.
let close_requested = ctx.input(|i| i.viewport().close_requested());
if close_requested && !self.allow_close && self.nornir_root.running_jobs() > 0 {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.close_confirm_open = true;
}
if self.close_confirm_open {
let running = self.nornir_root.running_jobs();
egui::Window::new("Jobs still running")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(format!("{running} job(s) still running. Close anyway?"));
ui.horizontal(|ui| {
if ui.button("Close anyway").clicked() {
self.allow_close = true;
self.close_confirm_open = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui.button("Keep running").clicked() {
self.close_confirm_open = false;
}
});
});
}
// **LAW4b** — fire the ViewportCommand::Screenshot when a screenshot has
// been requested. The command is fired every frame until egui delivers
// the Event::Screenshot below; in practice one frame is enough.
if self.screenshot_pending {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
ctx.request_repaint(); // ensure the next frame arrives promptly
}
// **LAW4b** — consume Event::Screenshot: encode the ColorImage to PNG,
// write to the requested path, and append to the warehouse blob store.
// The event is delivered by egui in `ctx.input()`; we drain it here,
// before the UI is painted, so the state_json dump on the same frame
// already sees `pending=false` + `last_path` filled in.
let mut screenshot_image: Option<std::sync::Arc<egui::ColorImage>> = None;
ctx.input(|i| {
for ev in &i.raw.events {
if let egui::Event::Screenshot { image, .. } = ev {
screenshot_image = Some(image.clone());
}
}
});
if let Some(img) = screenshot_image {
self.screenshot_pending = false;
let out_path = self.screenshot_pending_path.take();
self.handle_screenshot_image(&img, out_path);
}
// Tab change detection for the action log (the selectable_values below
// mutate `self.tab` in place; we compare against this pre-row snapshot).
let tab_before = self.tab;
// ── Top-bar via the reusable facett TabBar component (D3b) ─────────────
// The bespoke inline chrome (workspace picker + 2-row tab strip + 🎨
// palette + ℹ About + live toggle) is now painted THROUGH
// `facett_ui::TabBar`. The bar owns the tab strip + theming; the host
// pickers/actions are passed in as left/right slot closures so the bar
// stays generic. Slot closures only accumulate *intents* into locals
// (so they don't fight the bar over a `&mut self` borrow); intents are
// applied to `self` after the panel closes. See
// `.nornir/top-bar-component.md`.
let tab_items = Tab::tab_items();
let active_id = format!("{:?}", self.tab);
let palette = self.palette; // Copy — drives the bar's theming.
// Intent accumulators set by the slot closures, applied post-panel.
let mut chosen_ws: Option<String> = None;
let mut reload_clicked = false;
let mut sync_clicked = false;
let mut toggle_action_log = false;
let mut toggle_about = false;
let mut tab_clicked: Option<String> = None;
// Read-only snapshots the (Fn-only) left slot needs.
let ws_now = self.workspace_name.clone();
let workspaces = self.workspaces.clone();
let source_label = self.source_label.clone();
let refresh_secs = self.refresh_secs;
let mut auto_refresh = self.auto_refresh; // checkbox mutates this local
let last_reload_secs = self.last_reload.elapsed().as_secs();
let action_log_count = self.action_log.count();
let show_action_log = self.show_action_log;
let show_about = self.show_about;
// ⟳ Sync now needs &mut ws_panel + a server handle; precompute the
// server so the closure captures only the disjoint `ws_panel` field.
let srv = self.server();
let ws_panel = &mut self.ws_panel;
egui::Panel::top("top").show(ui, |ui| {
let bar = super::facett_ui::TabBar::new("top", &tab_items, &active_id)
.split_at(TOP_BAR_SPLIT)
.with_control(super::facett_ui::BarControl::picker("workspace", &ws_now))
.with_control(super::facett_ui::BarControl::toggle("live", auto_refresh))
.with_control(super::facett_ui::BarControl::toggle("about", show_about))
.with_control(super::facett_ui::BarControl::toggle(
"action_log",
show_action_log,
));
tab_clicked = bar.show(
ui,
&palette,
// ── LEFT slot: heading + workspace picker + source/reload/live + sync ──
|ui| {
ui.heading("🧵 Urðr Threads");
ui.separator();
// Workspace picker — switching re-scopes timeline/warehouse/callgraph.
let mut chosen = ws_now.clone();
// No implicit default — an empty selection is "none", and the user
// must pick one from this picker (the server has no default route).
let label = if chosen.is_empty() {
"⚠ none — pick one".to_string()
} else {
chosen.clone()
};
egui::ComboBox::from_id_salt("ws_picker")
.selected_text(format!("workspace: {label}"))
.show_ui(ui, |ui| {
for ws in &workspaces {
ui.selectable_value(&mut chosen, ws.clone(), ws);
}
});
if chosen != ws_now {
chosen_ws = Some(chosen);
}
ui.separator();
ui.label(format!("source: {source_label}"));
if ui.button("↻ reload").clicked() {
reload_clicked = true;
}
ui.checkbox(&mut auto_refresh, "live")
.on_hover_text(format!("auto-refresh every {refresh_secs}s"));
ui.separator();
// Workspace info toggle + ⟳ Sync now (remote mode). A sync polls
// the remote (server-side republish) — reload the view at once so
// a cleared/changed warehouse shows now, not on the next tick.
if ws_panel.draw_controls(ui, srv.as_ref(), &ws_now) {
sync_clicked = true;
}
ui.label(format!("(last reload {last_reload_secs}s ago)"));
ui.separator();
},
// ── RIGHT slot: 🐞 action-log toggle + ℹ About + 🎨 palette picker ──
|ui| {
// 🐞 action-log overlay toggle + the live entry counter, so the
// operator can pop the trail open without a terminal.
let lbl = format!("🐞 log ({action_log_count})");
if ui
.selectable_label(show_action_log, lbl)
.on_hover_text(
"toggle the user-action debug trail (also at $NORNIR_VIZ_ACTIONLOG)",
)
.clicked()
{
toggle_action_log = true;
}
ui.separator();
// ℹ About — client/server version + endpoint + workspace + warehouse.
if ui
.selectable_label(show_about, "ℹ About")
.on_hover_text("client + server version, endpoint, workspace, warehouse")
.clicked()
{
toggle_about = true;
}
// NB: the 🎨 app-wide facett palette picker used to sit HERE, on
// the root top bar. The bar is navigation (the tab strip, the
// workspace picker) and actions (reload / live / sync / about);
// appearance is a PREFERENCE, so it moved into the 🎛 **Settings**
// tab (`draw_settings`) — the same place dwarves-demo's `settings`
// pane keeps it, so an operator moving between the suite's apps
// finds the palette control in one place.
},
);
});
// ── Apply the slot intents to `self` (the bar's closures only touched
// locals, so no borrow fight). Behaviour matches the old inline chrome. ─
self.auto_refresh = auto_refresh;
if let Some(ws) = chosen_ws {
self.switch_workspace(ws);
}
if reload_clicked {
self.action_log
.push(super::action_log::Kind::Click, "↻ reload");
#[cfg(feature = "saga")]
self.saga_record_click("↻ reload", "reload");
self.reload();
self.warehouse.refresh_tables();
}
if sync_clicked {
self.action_log
.push(super::action_log::Kind::Click, "⟳ sync now");
self.reload();
self.warehouse.refresh_tables();
self.callgraph.invalidate();
}
if toggle_action_log {
self.show_action_log = !self.show_action_log;
}
if toggle_about {
self.show_about = !self.show_about;
if self.show_about {
self.action_log
.push(super::action_log::Kind::Click, "ℹ About");
// Query the server version once, on open (remote mode).
self.ensure_server_info();
}
}
// A tab header click (real AccessKit/robot click or pointer) → switch
// the active tab, exactly as the old `selectable_value` rows did.
if let Some(id) = tab_clicked {
if let Some(t) = Tab::from_name(&id) {
self.tab = t;
}
}
// Keep the action log's durable-row context (workspace + active tab)
// current, so every `viz_actions` row is stamped with where the user was
// when they took the action (N5). Cheap: a single mutex update.
self.action_log
.set_context(&self.workspace_name, &format!("{:?}", self.tab));
// Log the tab switch (edge-triggered) the moment the row mutates it.
if self.tab != tab_before {
self.action_log.push(
super::action_log::Kind::Tab,
format!("{tab_before:?} → {:?}", self.tab),
);
// Structured twin: the tab switch as a typed event so an agent
// following $NORNIR_VIZ_TRACE knows which view's data comes next.
super::trace::emit_event(
"ui.tab",
&serde_json::json!({
"from": format!("{tab_before:?}"),
"to": format!("{:?}", self.tab),
}),
);
// 🎬 Saga capture (feature `saga`): the nav as a REPLAYABLE event whose
// `component` is the header button's AccessKit label, so a replay's
// ClickLabel(component) re-drives the exact same widget. No-op (inlined
// away) in a release build without the feature.
#[cfg(feature = "saga")]
self.saga_record_nav();
}
// 🎬 The right "Time machine" detail inspector + the bottom 🐞 action-log
// trail are NO LONGER hand-wired panels here — they are declarative panes in
// `shell_scene::nornir_shell_scene` (a DRAGGABLE master/detail split + a
// foldable action-log drawer), rendered by `scene::render` in the central
// area below and filled by this app's `SceneHost` impl
// (`draw_detail_body` / `draw_action_log_body`). The top-bar 🐞 toggle drives
// the drawer's fold (synced just before the render). See `.nornir/scene-design.md`.
// ℹ About window (C1): always shows SOMETHING real — client version,
// endpoint, workspace, warehouse — plus the server version (remote mode).
if self.show_about {
let about = self.about_info();
let theme = self.palette;
let mut open = self.show_about;
egui::Window::new("ℹ About — Urðr Threads")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
let row = |ui: &mut egui::Ui, k: &str, v: &str| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(k).strong());
ui.monospace(v);
});
};
ui.heading("🧵 Urðr Threads");
ui.label("the nornir warehouse visualizer");
ui.separator();
// The build identity — prominent, so it's obvious this is a
// fresh client (version + git SHA + build time).
ui.label(
egui::RichText::new(client_build())
.strong()
.monospace()
.color(super::facett_theme::GREEN),
);
let mode = about["mode"].as_str().unwrap_or("?");
row(ui, "mode", mode);
if mode == "remote" {
row(ui, "endpoint", about["endpoint"].as_str().unwrap_or("—"));
match about["server_version"].as_str() {
Some(v) => {
let rc = about["server_repo_count"].as_u64().unwrap_or(0);
row(ui, "server version", &format!("{v} ({rc} repos)"));
}
None => {
let note = about["server_note"]
.as_str()
.unwrap_or("server version: unknown");
ui.colored_label(super::facett_theme::AMBER, note);
}
}
} else {
row(ui, "warehouse", about["warehouse"].as_str().unwrap_or("—"));
}
row(ui, "workspace", about["workspace"].as_str().unwrap_or("—"));
ui.separator();
ui.label(
egui::RichText::new(about["connect_status"].as_str().unwrap_or(""))
.size(11.0)
.color(theme.text_dim),
);
});
self.show_about = open;
}
// 🎬 Central master shell: render the DECLARATIVE scene (a draggable
// master/detail split + a foldable action-log drawer + the Time-machine
// inspector), DRIVING the existing panes through this app's `SceneHost`
// impl. The 28-pane scene is rebuilt cheaply each frame; `self.nav` holds the
// live fold/split-ratio state the render reads and any interaction mutates.
// The old right `SidePanel`/bottom `TopBottomPanel`/central dispatch are now
// the `detail`/`action_log`/`active_pane` slots. See `.nornir/scene-design.md`.
egui::CentralPanel::default().show(ui, |ui| {
// The scene for the ACTIVE tab: identical shell, except that a tab with
// honest master/detail structure carries its own DRAGGABLE body split
// (`<Tab>_main` │ `<Tab>_inspector`) where the single `panes` slot sits.
// Pane names are per-tab, so each tab keeps its own live ratio + folds.
let scene = super::shell_scene::nornir_shell_scene_for(self.tab);
// The top-bar 🐞 toggle owns the trail's visibility → drive the drawer fold.
self.nav
.set_fold(super::shell_scene::ACTION_LOG_SLOT, !self.show_action_log);
// Take `nav` OUT so `self` is free to be the SceneHost during render; a
// fresh throwaway seeds the field for the borrow's duration.
let mut nav =
std::mem::replace(&mut self.nav, facett_app::scene::NavState::new(&scene));
let render = facett_app::scene::render(&scene, &mut nav, ui, self);
// A scene toggle of the drawer (menu/fold header) flows back to the flag.
self.show_action_log = !nav.is_folded(super::shell_scene::ACTION_LOG_SLOT);
self.nav = nav;
self.last_scene_render = render;
});
// Splash + watermark overlay (drawn last so it sits on top
// of everything but doesn't block clicks).
self.draw_nornir_overlay(ctx);
// 🚦 Pre-flight popup — only AFTER the splash has played, so it appears
// "after the logo popup" as intended. Feed it the warehouse MCP call
// count (scanning the telemetry once) so a 0-call host gets the
// "wire up your Claude agent" warning, then draw it over everything.
if self.started_at.elapsed().as_secs_f32() >= SPLASH_DURATION_SECS {
self.mcp.ensure_loaded();
// Build the `claude mcp add` command from THIS client's live
// connection (no hardcoded token in the binary). We do NOT pin
// NORNIR_WORKSPACE — the MCP should expose every workspace + its
// repos, not lock to one (see .nornir: multi-workspace-mcp).
let add_cmd = match &self.source {
Source::Remote { endpoint, token } => format!(
"# wire nornir's 56 MCP tools to Claude Code, pointed at this server:\n\
claude mcp add nornir \\\n \
--env NORNIR_SERVER={endpoint} \\\n \
--env NORNIR_SERVER_TOKEN={token} \\\n \
-- nornir-mcp\n\
# then in a session: /mcp → `nornir` connected (all workspaces)"
),
Source::Local(_) => "# wire nornir's 56 MCP tools to Claude Code:\n\
claude mcp add nornir -- nornir-mcp\n\
# then in a session: /mcp → `nornir` connected"
.to_string(),
};
self.preflight
.set_mcp_calls(self.mcp.total_calls(), self.mcp.is_loaded(), add_cmd);
self.preflight.draw(ctx);
} else {
// keep repainting so the popup appears promptly when the splash ends
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// External observability: log click trail (stderr) + dump the full UI
// state (pickers, tabs, populations, placeholders) so the test matrix
// and the operator can see what's on screen without a display.
self.log_click();
self.dump_state();
}
}
/// 🎬 nornir's app IS the [`SceneHost`](facett_app::scene::SceneHost): the declarative
/// master shell (`super::shell_scene`) DRIVES the existing panes through this impl —
/// the `active_pane` slot renders the active tab (delegating to every `Tab::id()`
/// slot), `detail` the Time-machine inspector, `action_log` the trail — and menu
/// commands drive reload / About / tab navigation. The scene reinvents nothing.
impl facett_app::scene::SceneHost for UrdrThreadsApp {
fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
if id == super::shell_scene::ACTIVE_PANE_SLOT {
self.draw_active_pane(ui);
return serde_json::json!({ "tab": self.tab.id() });
}
if id == super::shell_scene::DETAIL_SLOT {
self.draw_detail_body(ui);
return serde_json::json!({ "selected": self.selected.map(|u| u.to_string()) });
}
if id == super::shell_scene::ACTION_LOG_SLOT {
self.draw_action_log_body(ui);
return serde_json::json!({ "count": self.action_log.count() });
}
// A DECOMPOSED tab body: `<Tab>_main` / `<Tab>_inspector` — one half of the
// draggable master/detail split the tab's internals now declare.
if let Some((tab, part)) = super::shell_scene::body_slot(id) {
return self.draw_body_part(tab, part, ui);
}
// Every `Tab::id()` is itself a host-fillable slot (the `active_pane` slot
// delegates here): render that tab's pane, then restore the active tab.
if let Some(t) = Tab::from_name(id) {
let prev = self.tab;
self.tab = t;
self.draw_active_pane(ui);
self.tab = prev;
return serde_json::json!({ "tab": t.id() });
}
ui.weak(format!("no widget for slot: {id}"));
serde_json::Value::Null
}
fn command(&mut self, id: &str) {
match id {
"refresh" => {
self.action_log
.push(super::action_log::Kind::Click, "↻ reload (menu)");
self.reload();
self.warehouse.refresh_tables();
}
"about" => {
self.show_about = !self.show_about;
if self.show_about {
self.ensure_server_info();
}
}
other => {
// `go:<TabId>` menu items navigate the active tab (real switch).
if let Some(rest) = other.strip_prefix("go:") {
if let Some(t) = Tab::from_name(rest) {
self.tab = t;
}
}
}
}
}
}
impl UrdrThreadsApp {
/// **The per-frame LIVE populate poll**, driven regardless of which tab is on
/// screen. The per-repo panes (🧠 knowledge / 🏛 arch / 🔗 depgraph / 🚇 metro /
/// 🧪 test) render "⏳ in route for populate…" + auto-refetch off the snapshot
/// `repo_pane::publish_active_jobs` keeps fresh, but that poll used to run ONLY
/// inside the 🧬 nornir tab's `draw_jobs` — so a populate watched from a per-repo
/// pane saw a STALE snapshot and never showed live progress. Throttled (it shares
/// the nornir tab's ~1.5s cadence, so it's one RPC per cycle); keeps repainting
/// while a populate runs so those panes update live.
///
/// Extracted from `draw_active_pane` so a tab whose body is a master/detail SPLIT
/// (two host slots, [`super::shell_scene::body_split`]) polls exactly ONCE per
/// frame — from its main half — instead of once per half.
fn poll_populate_jobs(&mut self, ui: &egui::Ui) {
let srv = self.server();
// PLAN #6: the local warehouse path for populate-status reads in local mode
// (None in remote mode → the RPC path is used).
let local_wh = match &self.source {
Source::Local(p) => Some(p.clone()),
Source::Remote { .. } => None,
};
let active_ws = self.workspace_name.clone();
let populating =
self.nornir_root
.poll_active_jobs(srv.as_ref(), &active_ws, local_wh.as_ref());
if populating {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(800));
}
}
/// **ONBOARDING (not an error): no workspace is selected.** A fresh server with an
/// EMPTY registry has no workspace to select, so every non-🧬 tab would otherwise
/// call `Viz.Timeline` with an empty workspace and surface NotFound (`workspace ``
/// is not served`). Direct the user to add one instead. RAGNARÖK (LAW 2) bites a
/// POPULATED surface that renders empty — an empty registry is a legitimate resting
/// state, shown as onboarding, not red.
///
/// Emits the state as DATA (LAW 6) so the matrix/robot can assert the surface RAN
/// and is in the empty-registry onboarding state. Shared by the single-pane path
/// and the main half of a split tab body, so both tell the same story once.
fn draw_no_workspace_onboarding(&mut self, ui: &mut egui::Ui) {
let have_workspaces = !self.workspaces.is_empty();
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.heading("No workspace selected");
if have_workspaces {
// Workspaces ARE registered — the server has no default route, so the
// user must pick one from the top-bar.
ui.label("Select a workspace from the top bar to get started.");
} else {
ui.label("This server has no workspaces registered yet.");
ui.add_space(8.0);
ui.label("Add one in the 🧬 nornir tab → ➕ add workspace, or run:");
ui.code("nornir workspace register <name> --descriptor <git-ssh-url | path>");
}
ui.add_space(8.0);
ui.label(&self.connect_status);
});
super::trace::emit_out(
"viz.onboarding",
&serde_json::json!({
"no_workspaces": self.workspaces.is_empty(),
"workspace_name_empty": true,
"served": false,
"errored": false,
}),
);
}
/// **🎛 The Settings pane — the viz's preferences surface, and the ONE home of its
/// LOOK & FEEL.**
///
/// The app-wide facett palette picker used to live on the root top bar's RIGHT
/// slot, between the 🐞 action-log toggle and ℹ About. The top bar is NAVIGATION
/// (the tab strip, the workspace picker) and ACTIONS (reload / live / sync /
/// about); appearance is a PREFERENCE, so it moved here — the same split
/// dwarves-demo draws with its 🎛 `settings` tab.
///
/// Nothing was lost in the move: the same [`Theme::names`](super::facett_theme::Theme)
/// roster, driven through the same [`set_palette`](Self::set_palette), still
/// re-skins EVERY pane at once (status colours, accents, zebra/hover/selection,
/// gridlines, typography) plus the egui widgets — `set_palette` broadcasts to
/// every pane and clears `palette_applied`, so the next frame installs the new
/// visuals. `$NORNIR_VIZ_THEME` / `--theme` (the C8 CLI-parity resolution) are
/// untouched, and the action-log still records the switch.
fn draw_settings(&mut self, ui: &mut egui::Ui) {
ui.heading("🎛 Settings");
ui.add_space(4.0);
ui.label(
egui::RichText::new("Appearance")
.strong()
.color(self.palette.text),
);
ui.label(
egui::RichText::new(
"The app-wide facett palette. Switching re-skins EVERY pane at once — \
status colours, accents, zebra/hover/selection, gridlines, typography — \
plus the egui widgets. A local preference: nothing is sent to the server.",
)
.color(self.palette.text_dim),
);
ui.add_space(8.0);
let current = self.palette.name.to_string();
let mut chosen: Option<String> = None;
ui.horizontal_wrapped(|ui| {
ui.label("🎨 palette:");
for name in super::facett_theme::Theme::names() {
if ui.selectable_label(name == current, name).clicked() {
chosen = Some(name.to_string());
}
}
});
if let Some(name) = chosen {
if let Some(t) = super::facett_theme::Theme::by_name(&name) {
self.action_log.push(
super::action_log::Kind::Click,
format!("🎨 palette → {}", t.name),
);
self.set_palette(t);
}
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(format!("active palette: {}", self.palette.name))
.color(self.palette.text_dim),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Headless / CLI parity (C8): the same choice is selectable without the \
GUI via $NORNIR_VIZ_THEME or --theme.",
)
.color(self.palette.text_dim),
);
// LAW 6 — the surface reports what it did, as data, so the matrix/robot can
// assert the pane RAN and which palette it is showing.
super::trace::emit_out(
"viz.settings",
&serde_json::json!({
"palette": self.palette.name,
"palettes": super::facett_theme::Theme::names(),
"served": true,
"errored": false,
}),
);
}
/// **Render ONE HALF of a decomposed tab body** — the scene's `<Tab>_main` /
/// `<Tab>_inspector` slot (see [`super::shell_scene::body_split`]).
///
/// Stage 2 of the scene adoption: the shell used to hand each tab ONE opaque pane,
/// so minimise / maximise / drag operated on the whole tab and could do nothing
/// INSIDE it. A tab with honest master/detail structure now declares two named
/// panes, and this routes each to the matching half of the existing tab widget —
/// the tab's own drawing code is reused, not rewritten.
///
/// `self.tab` is made authoritative for the duration (the halves read it, and the
/// scene may render a slot for a tab that isn't active during a deep-link), then
/// restored. Returns the half's observable state, folded into the render's `slots`.
fn draw_body_part(
&mut self,
tab: Tab,
part: super::shell_scene::BodyPart,
ui: &mut egui::Ui,
) -> serde_json::Value {
use super::shell_scene::BodyPart;
let prev = self.tab;
self.tab = tab;
let main = matches!(part, BodyPart::Main);
// The AccessKit `id_salt` at this half's render root — the slot id itself, so
// the two halves can never collide with each other or with the whole-tab salt.
let salt = if main {
super::shell_scene::main_slot_id(tab)
} else {
super::shell_scene::inspector_slot_id(tab)
};
let mut served = true;
ui.push_id(salt.clone(), |ui| {
// Once per frame, from the main half only.
if main {
self.poll_populate_jobs(ui);
}
if self.workspace_name.is_empty() {
served = false;
if main {
self.draw_no_workspace_onboarding(ui);
} else {
ui.weak("(no workspace selected)");
}
return;
}
match (tab, main) {
// 🗄 Warehouse — the table roster (master) │ the row preview + chart
// (detail). Was an internal `SidePanel::left("wh_tables")` the shell
// could not see, fold, minimise or report the rects of.
(Tab::Warehouse, true) => self.warehouse.draw_tables(ui),
(Tab::Warehouse, false) => self.warehouse.draw_preview(ui),
// 🗺 Knowledge — the bubble chart (main) under the per-crate summary
// table (inspector). The two used to share one vertical flow, which is
// exactly how a growing readout pushed the canvas off the screen.
(Tab::Knowledge, true) => self.knowledge.draw_chart(ui),
(Tab::Knowledge, false) => self.knowledge.draw_summary(ui),
// 📡 Live Run — the release-run feed (main) over the §f3 build-info
// live-tail (inspector). Two independent feeds that used to share one
// scroll, so the busier one silently swallowed the other.
(Tab::LiveRun, true) => self.live.draw(ui),
(Tab::LiveRun, false) => self.build_info_tail.draw(ui),
_ => {
ui.weak(format!("no widget for slot: {salt}"));
}
}
});
self.tab = prev;
serde_json::json!({
"tab": tab.id(),
"part": if main { "main" } else { "inspector" },
"served": served,
})
}
/// Render the ACTIVE tab's pane into `ui` — the central pane body (formerly the
/// `CentralPanel` dispatch), now the scene's `active_pane` slot filled by this
/// app's [`facett_app::scene::SceneHost`] impl. Preserves the S3c stable
/// AccessKit `id_salt` (`Tab::id()`) at the pane render root so every atom stays
/// addressable, and keeps the per-frame active-jobs poll + onboarding/timeline
/// guards intact (its inner `return`s return from the `push_id` closure as before).
fn draw_active_pane(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let pane_salt = self.tab.id();
ui.push_id(pane_salt, |ui| {
// LIVE populate progress — drive the active-jobs poll EVERY frame,
// regardless of which tab is on screen. The per-repo panes (🧠 knowledge /
// 🏛 arch / 🔗 depgraph / 🚇 metro / 🧪 test) render "⏳ in route for
// populate…" + auto-refetch off the snapshot `repo_pane::publish_active_jobs`
// keeps fresh, but that poll used to run ONLY inside the 🧬 nornir tab's
// `draw_jobs` — so a populate watched from a per-repo pane saw a STALE
// snapshot and never showed live progress. Polling here (throttled, shares
// the nornir tab's ~1.5s cadence so it's one RPC per cycle) feeds those
// panes always; keep repainting while a populate runs so they update live.
self.poll_populate_jobs(ui);
// 🧬 The `nornir` root pane — server + workspace lifecycle ops. Renders
// without a timeline (it's where workspaces are born/killed). On a
// mutating op (add/kill/populate/refresh) re-list the workspaces +
// reload so the picker and views reflect the change immediately.
if self.tab == Tab::Nornir {
let srv = self.server();
// PLAN #6: the local warehouse path for populate-status reads in
// local mode (None in remote mode → the RPC path is used).
let local_wh = match &self.source {
Source::Local(p) => Some(p.clone()),
Source::Remote { .. } => None,
};
let active_ws = self.workspace_name.clone();
// 🧬 RELEASE DASHBOARD first (the go/no-go glance): the Release
// button + the gate-overlaid dependency graph + node→history (the
// history reuses the app's already-loaded timeline). The server +
// workspace-lifecycle ops follow below it, scrollable together.
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let tl = self.timeline.as_ref().ok();
self.nornir_dashboard.draw(ui, tl);
ui.add_space(12.0);
ui.separator();
let mutated = self.nornir_root.draw(
ui,
srv.as_ref(),
&self.workspaces,
&active_ws,
local_wh.as_ref(),
&self.action_log,
);
if mutated {
self.on_root_mutation();
}
});
return;
}
// ⚒ Dwarves (N5) — the merged build runner + CI forge. Renders WITHOUT a
// workspace/warehouse: building a repo doesn't need release history, the
// demo build-space (holger) is always available, and the forge is seeded
// with a demo pipeline (runs drive the `StepRunner` seam, default the
// instant `DemoRunner`). Placed before the onboarding guard so a fresh
// server can still drive a build / open the forge.
if self.tab == Tab::Dwarves {
self.dwarves.draw(ui);
return;
}
// 🤖 Claude — the Messages-API conversation pane. Renders WITHOUT a
// workspace/warehouse (it talks to the Anthropic API, not the timeline),
// so it sits alongside Build ahead of the onboarding guard. The HTTP call
// is behind the `ClaudeTransport` seam; a key-absent host shows the
// "set ANTHROPIC_API_KEY" hint rather than erroring.
if self.tab == Tab::Claude {
self.claude.draw(ui);
return;
}
// 🧠 Models — browse/download LLM+embedding models. Renders WITHOUT a
// workspace/warehouse (downloads are host-local, behind the
// `ModelDownloader` seam), so it sits alongside Build/Claude ahead of the
// onboarding guard.
if self.tab == Tab::Models {
self.models.draw(ui);
return;
}
// 🎛 Settings — the preferences pane (the app-wide facett PALETTE picker,
// moved off the root top bar). Reads nothing but the app's own palette, so
// it renders WITHOUT a workspace/warehouse and sits with Claude/Models
// ahead of the onboarding guard: a fresh install can still re-skin itself.
if self.tab == Tab::Settings {
self.draw_settings(ui);
return;
}
// ONBOARDING (not an error): a fresh server with an EMPTY registry has no
// workspace to select, so every non-🧬 tab would otherwise call
// `Viz.Timeline` with an empty workspace and surface NotFound
// (`workspace `` is not served`). Direct the user to add one instead.
// RAGNARÖK (LAW 2) bites a POPULATED surface that renders empty — an empty
// registry is a legitimate resting state, shown as onboarding, not red.
if self.workspace_name.is_empty() {
self.draw_no_workspace_onboarding(ui);
return;
}
// Live Run and Knowledge tabs render even when there's no
// timeline yet — they work without warehouse data.
if self.tab == Tab::LiveRun {
self.live.draw(ui);
// 📡 Build-info live-tail (slice-5): the §f3 feed folded per
// sub-job, painted beneath the release-run feed in the same pane.
ui.separator();
self.build_info_tail.draw(ui);
return;
}
if self.tab == Tab::Release {
self.release.draw(ui);
return;
}
// 💬 Chat renders without a timeline (it talks to the ACTIVE local
// model, not the warehouse), like Live Run / Test.
if self.tab == Tab::Chat {
self.chat.draw(ui, &self.action_log);
return;
}
if self.tab == Tab::Knowledge {
self.knowledge.draw(ui);
return;
}
if self.tab == Tab::Warehouse {
self.warehouse.draw(ui);
return;
}
if self.tab == Tab::WarehouseDeck {
self.warehouse_deck.draw(ui);
return;
}
if self.tab == Tab::Mcp {
self.mcp.draw(ui);
return;
}
// 📦 Holger — the built-in (married) registry pane. Renders without a
// timeline (it reads the in-process holger dev pair, not the
// warehouse). When holger isn't embedded the tab degrades to a note.
if self.tab == Tab::Holger {
#[cfg(feature = "embed-holger")]
{
self.holger.draw(ui);
}
#[cfg(not(feature = "embed-holger"))]
{
ui.heading("📦 Holger registry");
ui.label(
"holger is not embedded in this build (the `embed-holger` feature is \
off), so the registry pane is unavailable. Build with the default \
features to embed the /cache + /sparring dev pair.",
);
}
return;
}
// 📖 Manual — nornir's own docs/book-svg rendered via facett-docview.
// Renders without a timeline (it's static documentation, loaded lazily).
if self.tab == Tab::Manual {
self.manual.draw(ui);
return;
}
if self.tab == Tab::CallGraph {
// 🕸 Call Graph (back-compat top-level variant): route to the arch
// container's restored CallGraph sub-view — the real function/symbol
// call graph (`callgraph.rs`), NOT the Metro call-path map. The
// shared draw path below renders `self.callgraph` for that lens.
self.arch.set_sub_view(super::arch_tab::ArchSubView::CallGraph);
self.draw_architecture(ui);
return;
}
if self.tab == Tab::Architecture {
self.draw_architecture(ui);
return;
}
// 🚇 Metro (S5b): feed the facett MetroView a fresh MetroMap built by
// the `metro_feed` producer over the 🏛 Architecture tab's loaded
// facts (one loader, no second scan), then let the Facet draw it.
if self.tab == Tab::Metro {
let map = self.arch.facett_metro_map();
self.metro.set_workspace_name(self.workspace_name.clone());
self.metro.set_map(map);
self.metro.draw(ui);
return;
}
// 🧬 Time Helix: feed the facett HelixView a Scene built from the
// selected workspace's release timelines (demo helix when there are no
// releases yet, so the tab is never blank).
if self.tab == Tab::Helix {
let tl = self.timeline.as_ref().ok();
self.helix.draw(ui, tl);
return;
}
// 🩸 Bloodstream: paint the workspace as a circulatory system, fed the
// live timeline's crates + dep edges (falls back to nornir's OWN body
// from the embedded lockfile, so it is never blank). The recursive
// external drill-down resolves against the local checkout's lockfile.
if self.tab == Tab::Bloodstream {
let tl = self.timeline.as_ref().ok();
self.bloodstream.draw(ui, tl, &self.workspace_root);
return;
}
if self.tab == Tab::Funnel {
self.funnel.draw(ui, &self.action_log);
return;
}
let srv = self.server();
if self.tab == Tab::Search {
self.search.draw(ui, srv.as_ref(), &self.workspace_name, &self.action_log);
return;
}
if self.tab == Tab::Gates {
self.gates.draw(ui, srv.as_ref(), &self.workspace_name, &self.repos, &self.action_log);
return;
}
if self.tab == Tab::Security {
self.security.draw(ui, srv.as_ref(), &self.workspace_name);
return;
}
// ONBOARDING (registered-but-unpopulated NAMED workspace): the server
// answered `Viz.Timeline` with `NotFound: workspace `<name>` is not
// served` because the workspace is registered but its warehouse isn't
// built yet. That's NOT a failure — show a graceful "not populated —
// click ⬇ Populate" panel (route to the 🧬 nornir tab), never a red
// "Failed to load warehouse" error. (The empty-registry / no-selection
// onboarding is handled by the earlier `workspace_name.is_empty()` guard.)
let unpopulated_onboarding = self.timeline_is_unpopulated_onboarding();
match &self.timeline {
_ if unpopulated_onboarding => {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.heading(format!("Workspace `{}` is not populated yet", self.workspace_name));
ui.label("This workspace is registered but its warehouse hasn't been built.");
ui.add_space(8.0);
ui.label("Open the 🧬 nornir tab and click ⬇ Populate, or run:");
ui.code(format!("nornir workspace populate {}", self.workspace_name));
ui.add_space(8.0);
ui.label(&self.connect_status);
});
// EMIT the onboarding state as DATA (LAW 6): the surface RAN and is
// in the registered-but-unpopulated onboarding state — NOT the red
// `Failed to load warehouse` / `is not served` error path.
super::trace::emit_out(
"viz.onboarding",
&serde_json::json!({
"no_workspaces": self.workspaces.is_empty(),
"workspace_name_empty": false,
"unpopulated": true,
"workspace": self.workspace_name,
"served": false,
"errored": false,
}),
);
}
Err(e) => {
ui.colored_label(super::facett_theme::RED, format!("Failed to load warehouse:\n{e:#}"));
}
Ok(tl) if tl.is_empty() => {
ui.label(format!(
"No releases recorded for workspace `{}` yet.",
self.workspace_name
));
ui.label(&self.connect_status);
}
Ok(tl) => match self.tab {
Tab::Timeline => {
self.selected = draw_timeline(ui, tl, self.selected, &self.palette);
// EMIT-DOCTRINE (emit-fallback): the Timeline paint can't be
// observed from outside — self-report a functional_status row
// carrying the painted lane/release counts so the matrix proves
// the tab rendered non-blank (a blank timeline is a RED row).
let lanes = tl.lanes.len();
let nodes: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
nornir_testmatrix::functional_status(
"nornir/viz/timeline",
"timeline_rendered",
nodes > 0,
&format!("{lanes} lane(s), {nodes} release node(s) painted"),
);
}
Tab::DepGraph => {
if self.selected.is_none() {
ui.label("Showing latest snapshot — click a release in 🧵 Timeline to time-travel here.");
} else if let Some(rid) = self.selected {
if let Some(snap) = tl.snapshot_for(&rid) {
ui.label(format!(
"Pinned to release {} (snapshot {})",
rid, snap.snapshot_id
));
}
}
draw_dep_graph(ui, tl, self.selected, &mut self.dep_selected_repo, &mut self.dep_view);
}
Tab::TimeTravel => {
// Keyboard shortcuts while on this tab.
ctx.input(|i| {
if i.key_pressed(egui::Key::ArrowRight) {
if let Ok(tl) = &self.timeline {
if self.timetravel.idx + 1 < tl.release_order.len() {
self.timetravel.idx += 1;
}
}
}
if i.key_pressed(egui::Key::ArrowLeft) {
self.timetravel.idx = self.timetravel.idx.saturating_sub(1);
}
if i.key_pressed(egui::Key::Space) {
self.timetravel.playing = !self.timetravel.playing;
self.timetravel.last_tick = std::time::Instant::now();
}
});
if let Some(rid) = draw_timetravel(ui, tl, &mut self.timetravel) {
self.selected = Some(rid);
}
}
Tab::Search | Tab::Gates | Tab::Security => unreachable!("handled above"),
Tab::Dwarves => unreachable!("handled above"),
Tab::Nornir => unreachable!("handled above"),
Tab::LiveRun => unreachable!("handled above"),
Tab::Release => unreachable!("handled above"),
Tab::Chat => unreachable!("handled above"),
Tab::Knowledge => unreachable!("handled above"),
Tab::Warehouse => unreachable!("handled above"),
Tab::WarehouseDeck => unreachable!("handled above"),
Tab::Mcp => unreachable!("handled above"),
Tab::CallGraph => unreachable!("handled above"),
Tab::Architecture => unreachable!("handled above"),
Tab::Metro => unreachable!("handled above"),
Tab::Helix => unreachable!("handled above"),
Tab::Bloodstream => unreachable!("handled above"),
Tab::Funnel => unreachable!("handled above"),
Tab::Holger => unreachable!("handled above"),
Tab::Manual => unreachable!("handled above"),
Tab::Claude => unreachable!("handled above"),
Tab::Models => unreachable!("handled above"),
Tab::Settings => unreachable!("handled above"),
},
}
});
}
/// The right "Time machine" inspector body — the scene's `detail` slot. Formerly
/// the `SidePanel::right("detail")`; now a foldable pane on the DRAGGABLE
/// master/detail split (the fold header carries the title, so this omits it).
fn draw_detail_body(&mut self, ui: &mut egui::Ui) {
ui.separator();
// Workspace info + sync result (when the ℹ toggle is on).
self.ws_panel
.draw_panel(ui, self.server().as_ref(), &self.workspace_name);
match (self.selected, &self.timeline) {
(Some(rid), Ok(tl)) => {
ui.label(format!("release_id\n {rid}"));
ui.separator();
ui.label("git checkout commands:");
for lane in &tl.lanes {
if let Some(node) = lane.nodes.iter().find(|n| n.release_id == rid) {
let cmd = format!("cd {} && git checkout {}", lane.repo, node.sha);
ui.horizontal(|ui| {
ui.monospace(&cmd);
if ui.button("📋").on_hover_text("copy").clicked() {
ui.ctx().copy_text(cmd.clone());
}
});
let color = status_color(&self.palette, &node.gate_status);
ui.colored_label(
color,
format!(
" {} • status={} • tests {}/{} • branch={} • dirty={}",
lane.repo,
node.gate_status,
node.tests_passed,
node.tests_failed,
node.branch,
node.dirty,
),
);
if !node.published_versions.is_empty() {
ui.label(format!(
" published: {}",
node.published_versions
.iter()
.map(|(c, v)| format!("{c}@{v}"))
.collect::<Vec<_>>()
.join(", ")
));
}
ui.add_space(4.0);
}
}
}
(None, _) => {
ui.label("click a node in the timeline →");
}
(_, Err(e)) => {
ui.colored_label(super::facett_theme::RED, format!("load error:\n{e:#}"));
}
}
}
/// The bottom action-trail body — the scene's `action_log` DRAWER slot. Formerly
/// the `TopBottomPanel::bottom("action_log")`; now a foldable drawer that
/// auto-collapses on a phone-class width. The 🐞 title rides on the fold header.
fn draw_action_log_body(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(format!("({} total)", self.action_log.count()));
ui.separator();
ui.monospace(self.action_log.file_path());
});
ui.separator();
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink([false, false])
.show(ui, |ui| {
// Render the TAIL of the shared action-log FILE (a running `nornir
// bench`'s `[BENCH]` progress streams here too — the file is the
// single source of truth), colour-coded on the active palette.
for line in self.action_log.tail_lines(64 * 1024, 256) {
let color = if line.contains("[ERROR]") {
super::facett_theme::RED
} else if line.contains("[BENCH]") {
super::facett_theme::AMBER
} else if line.contains("[RPC]") {
self.palette.accent
} else if line.contains("[TAB]") {
super::facett_theme::GREEN
} else if line.contains("[QUERY]") {
super::facett_theme::AMBER
} else if line.contains("[LIFE]") {
self.palette.text_dim
} else {
self.palette.text
};
ui.monospace(egui::RichText::new(line).size(11.0).color(color));
}
});
// Keep the tail live while the drawer is open (bench progress).
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
}
/// 🎬 The declarative master shell as observable DATA (LAW #6): the pane tree,
/// which panes are foldable / drawers / draggable splits / chromed, the host-filled
/// tab slots, the live `folded` + `minimized` + `maximized` + `split_ratio` maps,
/// the menu/route counts, and the last `scene::render` facts. Folded into
/// `state_json()["scene"]`.
The file goes on; the console shows all of it.