src/viz/nornir_tab.rs
//! 🧬 **`nornir` root pane** — the server/root operations that are *not*
//! workspace-scoped data views: workspace **lifecycle** (Add / Kill), **Populate**
//! and **Refresh/Poll-now**, plus the server **status / version**.
//!
//! Every other pane is a *view onto one workspace's data*; this one is where a
//! workspace is born and where it dies. (The user notes it could equally have
//! been called "server" — it holds the root, server-level operations.)
//!
//! # Operation layout (decision: ops live in the pane they belong to)
//! * **Add workspace** — form (name + descriptor path/URL + mode + poll) →
//! `Workspaces.Register` (eager populate). CLI: `nornir workspace add`.
//! * **Kill workspace** — a confirm-gated de-register → `Workspaces.Remove`.
//! CLI: `nornir workspace rm`.
//! * **Populate** — clone members now, build async → `Workspaces.Fetch{background}`.
//! CLI: `nornir workspace populate`.
//! * **Refresh / Poll-now** — force a fetch+rebuild now → `Workspaces.Fetch{force}`.
//! CLI: `nornir workspace fetch`.
//! * **Server status** — `Health.Ping` (version + repo count + status).
//!
//! All are **remote-only** (they drive a running `nornir-server`); in local mode
//! the pane shows the same one-line hint as the other server-backed panes.
//!
//! Follows the facett discovery contract the test matrix walks: a `*View`-named
//! type with `local()`/`remote()` ctors, a [`state_json`](NornirRootView::state_json)
//! that reports every button + form field + last result (LAW #6 — "see what the
//! user sees" as data), so the headless matrix mechanically discovers the pane and
//! its operations.
use std::path::PathBuf;
use eframe::egui::{self};
use super::action_log::{ActionLog, Kind};
use super::facett_theme::{GREEN, RED, Theme};
use super::ops_tabs::Server;
use super::remote;
use crate::registry::RosterRow;
use crate::warehouse::clone_events::CloneEventRow;
// The job list is the reusable, nornir-agnostic facett component; we only map
// our `jobs::JobRecord` onto its `JobEntry` and let it render.
use facett_jobview::{Facet, JobEntry, JobList, JobStatus};
/// 🧬 The `nornir` root pane state. Remote-only; holds the Add-workspace form,
/// the kill-confirm latch, and the last result of each root operation so the
/// rendered outcome survives between frames and is reported in `state_json`.
pub struct NornirRootView {
/// Add-workspace form fields.
new_name: String,
new_descriptor: String,
new_mode: String, // "monitored" | "pushed" | "external"
new_poll: String,
/// The workspace selected for the (confirm-gated) Kill button.
kill_target: String,
/// Two-step confirm latch for Kill (a destructive root op).
kill_armed: bool,
/// Whether the Kill also purges the workspace's on-disk `builds/` data (the
/// `--purge` of `nornir workspace rm`). Off = registry-only (back-compat).
kill_purge: bool,
/// Last result of each operation (rendered + reported), `Ok(msg)`/`Err(msg)`.
last_add: Option<Result<String, String>>,
/// The name of the workspace registered by the LAST successful Add, latched
/// for the caller to pick up via [`take_just_registered`](Self::take_just_registered)
/// so the app can auto-SELECT the freshly-added workspace (the picker switch)
/// — the user sees it populate live instead of having to choose it manually.
just_registered: Option<String>,
last_kill: Option<Result<String, String>>,
last_populate: Option<Result<String, String>>,
last_refresh: Option<Result<String, String>>,
/// Cached `Health.Ping` server identity, fetched on demand.
server_info: Option<Result<remote::ServerInfo, String>>,
/// PLAN #6: cached per-member populate status (`clone_events`) for the active
/// workspace — `Ok(rows)` newest-first, or the load error. Loaded on demand via
/// `Viz.CloneEvents` (remote) or the local warehouse (local mode).
clone_events: Option<Result<Vec<CloneEventRow>, String>>,
/// The workspace the cached `clone_events` belong to (so a workspace switch
/// invalidates the cache).
clone_events_ws: String,
/// PO1: the multi-workspace POPULATE ROSTER — every served workspace × its
/// rolled-up populate verdict (green/red/stale + failing member + error).
/// `Ok(rows)` or the load error; `None` until first loaded. Remote reads the
/// `Workspaces.PopulateStatus` roll-up RPC (ONE round-trip for the whole
/// roster); local rolls up the active workspace from its warehouse.
roster: Option<Result<Vec<RosterRow>, String>>,
/// `true` once the roster auto-loaded (so it doesn't reload every frame).
roster_loaded: bool,
/// 🧰 The unified job list — the reusable `facett-jobview` component (no
/// nornir UI code). Fed from `Viz.Jobs` (remote) or the local `jobs.redb`
/// (lock-tolerant) on a ~1.5s poll; we only map `jobs::JobRecord → JobEntry`.
jobs_view: JobList,
/// Workspace the cached job list belongs to (a switch forces a reload).
jobs_ws: String,
/// Last job poll (throttles the ~1.5s refresh — swappable to skade streaming).
jobs_last_poll: Option<std::time::Instant>,
/// Last job-load error (shown above the list), or `None`.
jobs_error: Option<String>,
/// Parent job ids whose **default fold** has already been decided, so the
/// auto-collapse rule runs at most once per parent and never fights a user
/// toggle afterward. A parent is recorded here the first time it is seen
/// fully done (all descendants terminal-success) and gets folded; running /
/// failed-bearing parents stay unfolded and are *not* recorded (they may
/// still complete later, at which point they fold once). See
/// [`apply_default_job_folds`](Self::apply_default_job_folds).
jobs_fold_decided: std::collections::HashSet<String>,
theme: Theme,
}
impl Default for NornirRootView {
fn default() -> Self {
Self {
new_name: String::new(),
new_descriptor: String::new(),
new_mode: "monitored".into(),
new_poll: "60s".into(),
kill_target: String::new(),
kill_armed: false,
kill_purge: false,
last_add: None,
just_registered: None,
last_kill: None,
last_populate: None,
last_refresh: None,
server_info: None,
clone_events: None,
clone_events_ws: String::new(),
roster: None,
roster_loaded: false,
jobs_view: JobList::new("Jobs"),
jobs_ws: String::new(),
jobs_last_poll: None,
jobs_error: None,
jobs_fold_decided: std::collections::HashSet::new(),
theme: Theme::default(),
}
}
}
impl NornirRootView {
/// Local-warehouse ctor (no server → the pane shows the server-backed hint).
pub fn local() -> Self {
Self::default()
}
/// Remote (thin-client) ctor — identical state; the live `Server` handle is
/// passed per-frame to [`draw`](Self::draw), mirroring the other ops panes.
pub fn remote() -> Self {
Self::default()
}
/// Re-skin with a facett palette (broadcast from the top-bar picker).
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Take (and clear) the workspace name registered by the last successful Add,
/// if any. The app calls this after a mutating [`draw`](Self::draw) so it can
/// auto-SELECT the freshly-added workspace (drive the picker switch), making
/// the new workspace start populating live in the panes without the user
/// having to pick it from the top-bar. Returns `None` once consumed.
#[must_use]
pub fn take_just_registered(&mut self) -> Option<String> {
self.just_registered.take()
}
/// Render the root pane. `srv` is `None` in local mode (shows the hint).
/// `workspaces` is the current picker list (the kill-target choices).
/// Returns `true` when an operation mutated the server's workspace set (Add /
/// Kill / Populate / Refresh succeeded) so the caller can re-list workspaces +
/// reload the view immediately, the same contract `WorkspacePanel::draw_controls`
/// uses for ⟳ Sync now.
#[must_use]
pub fn draw(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
workspaces: &[String],
active_ws: &str,
local_warehouse: Option<&PathBuf>,
log: &ActionLog,
) -> bool {
let theme = self.theme;
ui.heading("🧬 nornir — server & workspace operations");
// ── Workspace ROSTER + populate status (PO1) ─────────────────────────
// Every served workspace × {members, last-synced, populate verdict, last
// error}. Remote reads the `Workspaces.PopulateStatus` roll-up; local
// rolls up the active workspace from its warehouse. The red/green roster.
self.draw_roster(ui, srv, workspaces, active_ws, local_warehouse, log);
// ── Populate status (clone_events, PLAN #6) ──────────────────────────
// Works in BOTH modes: remote reads `Viz.CloneEvents`, local reads the
// workspace warehouse (lock-tolerant). Shows per-member ok/failed + the
// error detail + when, so a thin client sees *why* a member didn't
// populate instead of only missing data.
self.draw_populate_status(ui, srv, active_ws, local_warehouse, log);
// ── 🧰 Jobs (the facett-jobview component) — works in BOTH modes, so it
// renders before the local-mode early return below.
self.draw_jobs(ui, srv, active_ws, local_warehouse);
let Some(srv) = srv else {
ui.add_space(20.0);
ui.label(
"The nornir root pane's lifecycle ops are server-backed — launch the viz against \
a running nornir-server (NORNIR_SERVER=…) to add/kill/populate workspaces.",
);
return false;
};
let mut mutated = false;
// ── Server status (Health.Ping) ──────────────────────────────────────
ui.separator();
ui.horizontal(|ui| {
ui.strong("server:");
if ui
.button("⟳ status")
.on_hover_text("Health.Ping — version + repo count")
.clicked()
{
log.push(Kind::Rpc, "Health.Ping".to_string());
self.server_info =
Some(remote::ping(&srv.endpoint, &srv.token).map_err(|e| format!("{e:#}")));
}
match &self.server_info {
Some(Ok(i)) => {
ui.colored_label(
GREEN,
format!(
"{} · nornir {} · {} repo(s)",
i.status, i.version, i.repo_count
),
);
}
Some(Err(e)) => {
ui.colored_label(RED, e);
}
None => {
ui.colored_label(theme.text_dim, format!("{}", srv.endpoint));
}
}
});
// ── Add workspace (Workspaces.Register, eager populate) ───────────────
ui.separator();
ui.strong("➕ add workspace");
egui::Grid::new("nornir_add_ws")
.num_columns(2)
.spacing([8.0, 4.0])
.show(ui, |ui| {
ui.label("name:");
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.desired_width(220.0)
.hint_text("workspace name"),
);
ui.end_row();
ui.label("descriptor:");
ui.add(
egui::TextEdit::singleline(&mut self.new_descriptor)
.desired_width(360.0)
.hint_text("git ssh URL, or local path to a checked-out workspace repo"),
);
ui.end_row();
ui.label("mode:");
egui::ComboBox::from_id_salt("nornir_add_mode")
.selected_text(self.new_mode.clone())
.show_ui(ui, |ui| {
for m in ["monitored", "pushed", "external"] {
ui.selectable_value(&mut self.new_mode, m.to_string(), m);
}
});
ui.end_row();
ui.label("poll:");
ui.add(
egui::TextEdit::singleline(&mut self.new_poll)
.desired_width(100.0)
.hint_text("60s"),
);
ui.end_row();
});
if ui
.button("➕ Add workspace")
.on_hover_text("Workspaces.Register (eager populate) — CLI: nornir workspace add")
.clicked()
{
// Give feedback instead of silently doing nothing on a missing name/descriptor.
if self.new_name.trim().is_empty() {
self.last_add = Some(Err("name is required".to_string()));
} else if self.new_descriptor.trim().is_empty() {
self.last_add = Some(Err(
"descriptor is required — a git ssh URL or a local checked-out repo path"
.to_string(),
));
} else {
log.push(
Kind::Rpc,
format!("Workspaces.Register name={}", self.new_name),
);
let res = remote::register_workspace(
&srv.endpoint,
&srv.token,
self.new_name.trim(),
self.new_descriptor.trim(),
&self.new_mode,
self.new_poll.trim(),
);
// LATCH the server's canonical workspace name on success so the app
// can auto-SELECT it (the picker switch) — the user watches `W`
// populate live instead of having to pick it from the top-bar.
if let Ok((name, _, _)) = &res {
self.just_registered = Some(name.clone());
}
self.last_add = Some(
res.map(|(name, mode, members)| {
format!("registered `{name}` ({mode}, {members} member(s))")
})
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_add, Some(Ok(_)));
}
}
result_line(ui, theme, &self.last_add);
// ── Kill workspace (Workspaces.Remove, confirm-gated) ─────────────────
ui.separator();
ui.strong("🗑 kill workspace");
if self.kill_target.is_empty() {
self.kill_target = workspaces.first().cloned().unwrap_or_default();
}
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("nornir_kill_target")
.selected_text(if self.kill_target.is_empty() { "—".into() } else { self.kill_target.clone() })
.show_ui(ui, |ui| {
for w in workspaces {
ui.selectable_value(&mut self.kill_target, w.clone(), w);
}
});
ui.checkbox(&mut self.kill_purge, "purge data")
.on_hover_text("Also delete the workspace's on-disk builds/ data (warehouse + catalog.redb + jobs.redb), not just the registry row. CLI: nornir workspace rm --purge");
if !self.kill_armed {
if ui.button("🗑 Kill…").clicked() && !self.kill_target.trim().is_empty() {
self.kill_armed = true;
}
} else {
let warn = if self.kill_purge {
format!("⚠ remove `{}` AND purge its builds/ data?", self.kill_target)
} else {
format!("⚠ remove `{}`?", self.kill_target)
};
ui.colored_label(RED, warn);
if ui.button("✓ confirm kill").clicked() {
let purge = self.kill_purge;
log.push(
Kind::Rpc,
format!("Workspaces.Remove name={} purge={purge}", self.kill_target),
);
self.last_kill = Some(
remote::remove_workspace(&srv.endpoint, &srv.token, self.kill_target.trim(), purge)
.map(|()| {
if purge {
format!("removed `{}` (purged builds/ data)", self.kill_target)
} else {
format!("removed `{}`", self.kill_target)
}
})
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_kill, Some(Ok(_)));
self.kill_armed = false;
}
if ui.button("✕ cancel").clicked() {
self.kill_armed = false;
}
}
});
result_line(ui, theme, &self.last_kill);
// ── Populate + Refresh/Poll-now (Workspaces.Fetch) ────────────────────
ui.separator();
ui.strong("🔄 populate / refresh");
ui.horizontal(|ui| {
let target = if self.kill_target.is_empty() {
workspaces.first().cloned().unwrap_or_default()
} else {
self.kill_target.clone()
};
if ui
.button("⬇ Populate")
.on_hover_text("Workspaces.Fetch{background} — clone members now, build async (CLI: nornir workspace populate)")
.clicked()
&& !target.trim().is_empty()
{
log.push(Kind::Rpc, format!("Workspaces.Fetch(background) name={target}"));
self.last_populate = Some(
fetch(&srv.endpoint, &srv.token, &target, false, true)
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_populate, Some(Ok(_)));
}
if ui
.button("⟳ Refresh / poll-now")
.on_hover_text("Workspaces.Fetch{force} — poll + rebuild this workspace now (CLI: nornir workspace fetch)")
.clicked()
&& !target.trim().is_empty()
{
log.push(Kind::Rpc, format!("Workspaces.Fetch(force) name={target}"));
self.last_refresh = Some(
fetch(&srv.endpoint, &srv.token, &target, true, false)
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_refresh, Some(Ok(_)));
}
});
result_line(ui, theme, &self.last_populate);
result_line(ui, theme, &self.last_refresh);
mutated
}
/// Throttled poll of the job ledger (`Viz.Jobs` remote / `jobs.redb` local)
/// that PUBLISHES the active-jobs snapshot for the per-repo panes' shared
/// empty-state helper ([`super::repo_pane::publish_active_jobs`]) — the one
/// poll that feeds every pane's `classify_empty` / `populate_active_for`, so
/// they show "⏳ in route for populate…" + live progress without each running
/// its own RPC.
///
/// Split out of [`draw_jobs`] so the app can drive it EVERY frame regardless
/// of the active tab: a populate is normally watched from a per-repo pane
/// (🧠 knowledge / 🏛 arch / 🔗 depgraph), NOT the 🧬 nornir tab where
/// `draw_jobs` renders — so when the poll lived only inside `draw_jobs` the
/// snapshot went stale the moment the user left this tab, and the panes never
/// showed live populate progress (they sat on "not scanned yet"). Shares the
/// `jobs_last_poll` / `jobs_ws` throttle with `draw_jobs`, so on the nornir tab
/// both callers still issue ONE `Viz.Jobs` RPC per ~1.5s cycle.
///
/// Returns `true` while a populate/clone/scan job is active for `active_ws`
/// (so the caller can keep the frame repainting for live progress).
pub fn poll_active_jobs(
&mut self,
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> bool {
use std::time::{Duration, Instant};
if active_ws.is_empty() {
return false;
}
let due = self.jobs_ws != active_ws
|| self
.jobs_last_poll
.is_none_or(|t| t.elapsed() >= Duration::from_millis(1500));
if due {
self.jobs_ws = active_ws.to_string();
self.jobs_last_poll = Some(Instant::now());
match load_jobs(srv, active_ws, local_warehouse) {
Ok(recs) => {
self.jobs_error = None;
super::repo_pane::publish_active_jobs(&recs);
self.jobs_view
.set_jobs(recs.iter().map(job_entry).collect());
// Apply the default-open rule each refresh: collapse parents
// that have fully finished (tidy), keep running / failing work
// expanded. Idempotent + user-toggle-preserving.
self.apply_default_job_folds(&recs);
}
// SURFACE the failure instead of swallowing it: keep the error so
// `draw_jobs` paints "jobs unavailable: …" (and a stale snapshot is
// not silently treated as "no jobs"). A populate that can't be polled
// is no longer an invisible no-op.
Err(e) => self.jobs_error = Some(e),
}
}
super::repo_pane::populate_active_for(active_ws, "")
}
/// **Default-open rule for the Jobs tree.** The Jobs panel is a collapsible
/// tree (a populate parent → its clone / knowledge-scan / security / scip /
/// index / snapshot children, recursively). facett-jobview renders every
/// parent *unfolded* by default; this layers nornir's policy on top:
///
/// * keep a parent EXPANDED while it is `running`/`queued` or has any
/// running/queued/**failed** descendant, so in-progress and erroring work
/// is visible at a glance;
/// * COLLAPSE a parent once its whole subtree has finished cleanly (every
/// descendant terminal-success), to keep finished work tidy.
///
/// It is applied each refresh but is **idempotent per parent**: a parent is
/// auto-folded at most once (recorded in [`jobs_fold_decided`](Self::jobs_fold_decided)),
/// so once the user expands a collapsed-done parent it stays expanded, and a
/// parent the user is watching is never yanked shut mid-run — it only folds
/// the first frame its subtree is fully done. Drives the public
/// `facett-jobview` fold API (`is_folded` / `toggle_fold`), so the flat
/// renderer/tree is reused, not replaced.
fn apply_default_job_folds(&mut self, recs: &[crate::jobs::JobRecord]) {
use std::collections::HashMap;
// parent_id → its direct children (by index into `recs`).
let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, r) in recs.iter().enumerate() {
if let Some(p) = r.parent_id.as_deref() {
children.entry(p).or_default().push(i);
}
}
// Does `id`'s subtree (the node itself or any descendant) still need
// attention — i.e. is anything in it non-`done`? `failed`/`running`/
// `queued` all keep the parent open. Bounded by a visited guard so a
// malformed cycle can't spin.
fn subtree_needs_attention(
id: &str,
recs: &[crate::jobs::JobRecord],
children: &HashMap<&str, Vec<usize>>,
by_id: &HashMap<&str, usize>,
seen: &mut std::collections::HashSet<String>,
) -> bool {
if !seen.insert(id.to_string()) {
return false;
}
if let Some(&idx) = by_id.get(id) {
if recs[idx].status != crate::jobs::status::DONE {
return true;
}
}
if let Some(kids) = children.get(id) {
for &ci in kids {
let cid = recs[ci].job_id.as_str();
if subtree_needs_attention(cid, recs, children, by_id, seen) {
return true;
}
}
}
false
}
let by_id: HashMap<&str, usize> = recs
.iter()
.enumerate()
.map(|(i, r)| (r.job_id.as_str(), i))
.collect();
// Only parents (rows that actually have children) get a fold decision.
let parent_ids: Vec<String> = children.keys().map(|s| s.to_string()).collect();
for pid in parent_ids {
if self.jobs_fold_decided.contains(&pid) {
continue; // decided once already — respect the prior/user state.
}
let mut seen = std::collections::HashSet::new();
if subtree_needs_attention(&pid, recs, &children, &by_id, &mut seen) {
continue; // still running / failed somewhere → keep it expanded.
}
// Fully done: collapse it once, then record the decision so the user
// can re-open it permanently.
if !self.jobs_view.is_folded(&pid) {
// FC-2: mutation goes through the Msg surface (toggle_fold is now private).
let _ = self
.jobs_view
.update(facett_jobview::Msg::ToggleFold(pid.clone()));
}
self.jobs_fold_decided.insert(pid);
}
}
/// PLAN #6: render the per-member **populate status** for `active_ws` — the
/// `clone_events` outcomes (ok / failed + error detail + when). Loaded on
/// demand (a ⟳ button), and auto-loaded once when the active workspace
/// changes, so a thin client sees *why* a member didn't populate.
/// Poll + render the job list (the `facett-jobview` component). Polls
/// `Viz.Jobs` (remote) or the local `jobs.redb` (lock-tolerant) at most every
/// ~1.5s — the `bench_live` cadence, swappable to skade streaming-on-commit.
fn draw_jobs(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) {
use std::time::Duration;
// S3c — STABLE id_salt for the `jobs` SUB-PANEL (registry id
// `Nornir.jobs`): it hides inside the 🧬 tab with no `Tab::ALL` variant, so
// namespace its atoms so the walk addresses them uniquely. Wraps BOTH the
// empty-workspace guard and the populated path.
ui.push_id("Nornir.jobs", |ui| {
// EMPTY-WORKSPACE GUARD: an empty registry has no workspace, so `Viz.Jobs`
// with an empty workspace returns `workspace `` is not served` (a RED error
// atom). Render a neutral onboarding line and skip the poll/RPC. Mirrors the
// populate-status guard above + `app.rs`'s `workspace_name.is_empty()`.
if active_ws.is_empty() {
ui.separator();
egui::CollapsingHeader::new("🧰 Jobs")
.default_open(true)
.show(ui, |ui| {
ui.colored_label(self.theme.text_dim, "no workspace selected");
});
return;
}
// Throttled poll + PUBLISH of the active-jobs snapshot (shared with the
// app-level drive in `app.rs` so the per-repo panes see live progress even
// when this tab isn't on screen — one `Viz.Jobs` RPC per cycle either way).
self.poll_active_jobs(srv, active_ws, local_warehouse);
ui.separator();
egui::CollapsingHeader::new("🧰 Jobs")
.default_open(true)
.show(ui, |ui| {
if let Some(e) = &self.jobs_error {
ui.colored_label(RED, format!("jobs unavailable: {e}"));
}
// Drive the facett component through its canonical Facet contract.
Facet::ui(&mut self.jobs_view, ui);
});
// Keep the ~1.5s poll cadence ticking even when idle (no input events).
ui.ctx().request_repaint_after(Duration::from_millis(1500));
}); // close the S3c `Nornir.jobs` push_id scope
}
/// Number of currently-running jobs — drives the app's "ask at close" guard.
pub fn running_jobs(&self) -> usize {
self.jobs_view.count(JobStatus::Running)
}
/// PO1: render the multi-workspace POPULATE ROSTER — a table of every served
/// workspace × {members, last-synced, populate verdict, last error}. Loaded on
/// demand (a ⟳ button) and auto-loaded once. Remote reads the
/// `Workspaces.PopulateStatus` roll-up (one round-trip); local rolls up the
/// active workspace from its warehouse. A red verdict names the failing member.
fn draw_roster(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
workspaces: &[String],
active_ws: &str,
local_warehouse: Option<&PathBuf>,
log: &ActionLog,
) {
let theme = self.theme;
ui.push_id("Nornir.workspaces", |ui| {
ui.separator();
ui.horizontal(|ui| {
ui.strong("📋 workspace roster");
if ui
.button("⟳ load")
.on_hover_text("Workspaces.PopulateStatus — per-workspace populate roll-up (green/red/stale)")
.clicked()
{
log.push(Kind::Rpc, "Workspaces.PopulateStatus".to_string());
self.roster = Some(load_roster(srv, workspaces, active_ws, local_warehouse));
self.roster_loaded = true;
}
});
if !self.roster_loaded {
self.roster = Some(load_roster(srv, workspaces, active_ws, local_warehouse));
self.roster_loaded = true;
}
match &self.roster {
Some(Ok(rows)) if rows.is_empty() => {
ui.colored_label(theme.text_dim, "no workspaces registered");
}
Some(Ok(rows)) => {
let red = rows.iter().filter(|r| r.populate == "red").count();
if red == 0 {
ui.colored_label(GREEN, format!("✓ all {} workspace(s) populated ok", rows.len()));
} else {
ui.colored_label(RED, format!("✗ {red} workspace(s) failed to populate"));
}
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
egui::Grid::new("nornir_ws_roster").num_columns(5).striped(true).spacing([12.0, 2.0]).show(ui, |ui| {
ui.strong("workspace"); ui.strong("members"); ui.strong("last synced"); ui.strong("populate"); ui.strong("last error");
ui.end_row();
for r in rows {
ui.label(&r.name);
ui.label(r.members.to_string());
ui.label(&r.last_synced);
match r.populate.as_str() {
"green" => { ui.colored_label(GREEN, "✓ ok"); }
"red" => { ui.colored_label(RED, "✗ failed"); }
_ => { ui.colored_label(theme.text_dim, "· stale"); }
}
if r.failing_member.is_empty() {
ui.label("");
} else {
ui.colored_label(RED, format!("{}: {}", r.failing_member, r.last_error));
}
ui.end_row();
}
});
});
}
Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
None => {}
}
});
}
fn draw_populate_status(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
log: &ActionLog,
) {
let theme = self.theme;
// S3c — STABLE id_salt for the `populate_status` SUB-PANEL (registry id
// `Nornir.populate_status`): hides inside the 🧬 tab with no `Tab::ALL`
// variant, so namespace its atoms (incl. the `nornir_clone_events` Grid) so
// the walk addresses them uniquely. Wraps the guard + the populated path.
ui.push_id("Nornir.populate_status", |ui| {
ui.separator();
// EMPTY-WORKSPACE GUARD (the atom-walk class): on a fresh server with an
// empty registry there is NO workspace, so calling `Viz.CloneEvents` with
// an empty workspace makes the server answer `workspace `` is not served`
// — a NotFound that would render as a RED error atom. The empty registry is
// a legitimate onboarding state, not an error: show a neutral line and skip
// the RPC entirely (mirrors `app.rs`'s `workspace_name.is_empty()` guard).
if active_ws.is_empty() {
ui.horizontal(|ui| {
ui.strong("🩺 populate status");
ui.colored_label(theme.text_dim, "no workspace selected");
});
return;
}
ui.horizontal(|ui| {
ui.strong("🩺 populate status");
ui.colored_label(theme.text_dim, active_ws);
if ui
.button("⟳ load")
.on_hover_text("clone_events — per-member populate outcomes (Viz.CloneEvents / local warehouse)")
.clicked()
{
log.push(Kind::Rpc, format!("Viz.CloneEvents workspace={active_ws}"));
self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
self.clone_events_ws = active_ws.to_string();
}
});
// Auto-load once on a workspace switch (or first paint) so the pane shows
// status without a manual click — matches the other data panes.
if self.clone_events.is_none() || self.clone_events_ws != active_ws {
self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
self.clone_events_ws = active_ws.to_string();
}
match &self.clone_events {
Some(Ok(rows)) if rows.is_empty() => {
ui.colored_label(theme.text_dim, "no clone/populate events recorded yet");
}
Some(Ok(rows)) => {
let failed = rows.iter().filter(|r| r.status == "error").count();
if failed == 0 {
ui.colored_label(GREEN, format!("✓ all {} member event(s) ok", rows.len()));
} else {
ui.colored_label(RED, format!("✗ {failed} member(s) failed to populate"));
}
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
egui::Grid::new("nornir_clone_events").num_columns(4).striped(true).spacing([12.0, 2.0]).show(ui, |ui| {
ui.strong("member"); ui.strong("op"); ui.strong("status"); ui.strong("detail");
ui.end_row();
for r in rows.iter().take(50) {
ui.label(&r.member);
ui.label(&r.op);
if r.status == "error" {
ui.colored_label(RED, "✗ error");
} else {
ui.colored_label(GREEN, "✓ ok");
}
ui.label(&r.detail);
ui.end_row();
}
});
});
}
Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
None => {}
}
}); // close the S3c `Nornir.populate_status` push_id scope
}
/// The `nornir` pane's slice of `state_json` (LAW #6): every button this pane
/// hosts (so the matrix can mechanically discover the new operation surface),
/// the Add-workspace form's current field values, the kill-confirm state, and
/// the last result of each root operation. No "TODO"/"not wired" sentinels.
pub fn state_json(&self) -> serde_json::Value {
let res = |r: &Option<Result<String, String>>| match r {
None => serde_json::json!({ "ran": false }),
Some(Ok(m)) => serde_json::json!({ "ran": true, "ok": true, "message": m }),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
};
// The Jobs panel's TREE shape, surfaced at the nornir level so a robot test
// can assert the nesting + the default-open decision directly: `roots` =
// top-level rows, `parents` = rows with children, `children` = sub-jobs,
// `folded` = parents currently collapsed (the auto-collapse rule folds
// fully-done parents). See [`apply_default_job_folds`](Self::apply_default_job_folds).
let jobs_tree = {
let entries = self.jobs_view.jobs();
let ids: std::collections::HashSet<&str> =
entries.iter().map(|e| e.id.as_str()).collect();
let parent_ids: std::collections::HashSet<&str> = entries
.iter()
.filter_map(|e| e.parent_id.as_deref())
.collect();
let children = entries.iter().filter(|e| e.parent_id.is_some()).count();
// A root is a top-level row: no parent, or an orphan whose parent isn't
// in the set (facett promotes those to the top level too).
let roots = entries
.iter()
.filter(|e| e.parent_id.as_deref().is_none_or(|p| !ids.contains(p)))
.count();
// Parents that are actually present as rows (have at least one child).
let parents = entries
.iter()
.filter(|e| parent_ids.contains(e.id.as_str()))
.count();
let folded: Vec<&str> = entries
.iter()
.filter(|e| parent_ids.contains(e.id.as_str()) && self.jobs_view.is_folded(&e.id))
.map(|e| e.id.as_str())
.collect();
serde_json::json!({
"roots": roots,
"parents": parents,
"children": children,
"folded": folded,
})
};
serde_json::json!({
"palette": self.theme.name,
// The operation buttons this pane exposes — id ⇒ the gRPC RPC each
// fires (CLI parity in the doc). The matrix walks this to prove every
// root op is wired.
"buttons": [
{ "id": "server_status", "rpc": "Health.Ping", "heavy": false },
{ "id": "add_workspace", "rpc": "Workspaces.Register", "heavy": false },
{ "id": "kill_workspace", "rpc": "Workspaces.Remove", "heavy": false, "confirm": true },
{ "id": "populate", "rpc": "Workspaces.Fetch", "heavy": false },
{ "id": "refresh", "rpc": "Workspaces.Fetch", "heavy": false },
{ "id": "populate_status", "rpc": "Viz.CloneEvents", "heavy": false },
{ "id": "workspaces_roster", "rpc": "Workspaces.PopulateStatus", "heavy": false },
{ "id": "jobs", "rpc": "Viz.Jobs", "heavy": false },
],
// PO1: the multi-workspace POPULATE ROSTER this pane renders — every
// served workspace × its rolled-up verdict (green/red/stale), member
// count, last-synced, and the first failing member + its error. So the
// matrix asserts the red/green roll-up + the failing member is surfaced
// ("see what the user sees" as data, LAW #6). `green`/`red`/`stale`
// counts give the at-a-glance verdict.
"workspaces": match &self.roster {
None => serde_json::json!({ "loaded": false }),
Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
Some(Ok(rows)) => serde_json::json!({
"loaded": true,
"ok": true,
"total": rows.len(),
"green": rows.iter().filter(|r| r.populate == "green").count(),
"red": rows.iter().filter(|r| r.populate == "red").count(),
"stale": rows.iter().filter(|r| r.populate == "stale").count(),
"rows": rows.iter().map(|r| serde_json::json!({
"name": r.name,
"mode": r.mode,
"members": r.members,
"last_synced": r.last_synced,
"populate": r.populate,
"failing_member": r.failing_member,
"last_error": r.last_error,
})).collect::<Vec<_>>(),
}),
},
// 🧰 The unified job list (the facett-jobview component's own state).
"jobs": self.jobs_view.state_json(),
// The Jobs panel's TREE shape (computed above), surfaced at the nornir
// level so a robot test can assert nesting + the default-open decision.
"jobs_tree": jobs_tree,
"jobs_error": self.jobs_error,
"add_form": {
"name": self.new_name,
"descriptor": self.new_descriptor,
"mode": self.new_mode,
"poll": self.new_poll,
},
"kill": { "target": self.kill_target, "armed": self.kill_armed },
"results": {
"add": res(&self.last_add),
"kill": res(&self.last_kill),
"populate": res(&self.last_populate),
"refresh": res(&self.last_refresh),
},
"server": match &self.server_info {
None => serde_json::json!({ "pinged": false }),
Some(Ok(i)) => serde_json::json!({
"pinged": true, "ok": true,
"status": i.status, "version": i.version, "repo_count": i.repo_count,
}),
Some(Err(e)) => serde_json::json!({ "pinged": true, "ok": false, "error": e }),
},
// PLAN #6: the per-member populate status this pane renders — every
// member's clone outcome (ok/error + detail + when) for the active
// workspace, so the matrix can assert the failed member + error is
// surfaced ("see what the user sees" as data, LAW #6).
"populate_status": match &self.clone_events {
None => serde_json::json!({ "loaded": false }),
Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
Some(Ok(rows)) => serde_json::json!({
"loaded": true,
"ok": true,
"workspace": self.clone_events_ws,
"total": rows.len(),
"failed": rows.iter().filter(|r| r.status == "error").count(),
"members": rows.iter().map(|r| serde_json::json!({
"member": r.member,
"op": r.op,
"status": r.status,
"detail": r.detail,
"remote": r.remote,
"ts": crate::warehouse::clone_events::ts_to_rfc3339(r.ts_micros),
"elapsed_ms": r.elapsed_ms,
})).collect::<Vec<_>>(),
}),
},
})
}
/// Test/headless helper: inject populate-status rows directly (the same rows a
/// `Viz.CloneEvents` read would yield) so the matrix can assert `state_json`
/// surfaces a failed member + its error without a live server. (LAW: tests
/// inject real values + assert real output.)
pub fn set_clone_events_for_test(&mut self, workspace: &str, rows: Vec<CloneEventRow>) {
self.clone_events_ws = workspace.to_string();
self.clone_events = Some(Ok(rows));
}
/// PO1 test/headless helper: inject the multi-workspace populate ROSTER rows
/// directly (the same rows a `Workspaces.PopulateStatus` read would yield), so
/// the matrix asserts `state_json["nornir"]["workspaces"]` surfaces every
/// workspace's verdict + the failing member's error without a live server.
pub fn set_roster_for_test(&mut self, rows: Vec<RosterRow>) {
self.roster = Some(Ok(rows));
self.roster_loaded = true;
}
/// Test/headless helper: inject job-ledger rows directly (the same
/// [`crate::jobs::JobRecord`]s a `Viz.Jobs` read would yield), mapped through
/// the real [`job_entry`] path, so the matrix can assert
/// `state_json["nornir"]["jobs"]` renders a **queued** job as queued (the serial
/// scheduler's visible-wait UX) without a live server. (LAW: inject real
/// values + assert real rendered output.)
pub fn set_jobs_for_test(&mut self, recs: &[crate::jobs::JobRecord]) {
self.jobs_view
.set_jobs(recs.iter().map(job_entry).collect());
// Exercise the same default-open rule the live poll applies, so a state
// test sees the real fold decision (done parents collapsed, in-progress
// ones expanded) — not just the raw facett default.
self.apply_default_job_folds(recs);
}
/// Test/headless helper: latch a just-registered workspace name directly (as a
/// successful `Workspaces.Register` Add would), so a state test can drive the
/// app's auto-SELECT wiring (`take_just_registered` → `switch_workspace`)
/// without a live server.
pub fn set_just_registered_for_test(&mut self, name: &str) {
self.just_registered = Some(name.to_string());
}
}
/// PO1: load the multi-workspace populate ROSTER. Remote reads the
/// `Workspaces.PopulateStatus` roll-up RPC (the whole roster in one round-trip,
/// a compact summary that can't overflow the gRPC cap). Local mode has a single
/// served workspace (the one the viz is pointed at), so it rolls THAT workspace's
/// warehouse `clone_events` up into one [`RosterRow`] via the SAME shared verdict
/// the server uses. Returns the roster rows or a human error string.
fn load_roster(
srv: Option<&Server>,
_workspaces: &[String],
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> Result<Vec<RosterRow>, String> {
if let Some(srv) = srv {
// The remote fetch decodes the wire-stable `nornir_wire::view::rows::RosterRow`
// mirror (the Step-4 decoupling); map it back to the core `RosterRow` the
// pane still renders (the pane keeps the core type until it moves).
return remote::fetch_populate_status(&srv.endpoint, &srv.token)
.map(|rows| rows.into_iter().map(RosterRow::from).collect())
.map_err(|e| format!("{e:#}"));
}
// Local mode: one served workspace — roll its clone_events up into a row.
if active_ws.is_empty() {
return Ok(Vec::new());
}
let rows = load_clone_events(None, active_ws, local_warehouse)?;
let verdict = crate::warehouse::clone_events::workspace_populate_verdict(&rows);
Ok(vec![RosterRow {
name: active_ws.to_string(),
mode: "local".into(),
members: verdict.members,
last_synced: verdict.last_synced.clone(),
populate: verdict.state,
failing_member: verdict.failing_member,
last_error: verdict.last_error,
}])
}
/// Load the active workspace's `clone_events` populate outcomes: remote via the
/// `Viz.CloneEvents` RPC, local via the workspace warehouse (lock-tolerant
/// `open_read_only`). Returns rows newest-first or a human error string.
fn load_clone_events(
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> Result<Vec<CloneEventRow>, String> {
if let Some(srv) = srv {
// Decode the wire mirror rows, then map back to the core `CloneEventRow`
// the pane + `workspace_populate_verdict` consume (see load_roster).
return remote::fetch_clone_events(&srv.endpoint, &srv.token, active_ws)
.map(|rows| rows.into_iter().map(CloneEventRow::from).collect())
.map_err(|e| format!("{e:#}"));
}
// Local mode: read the workspace's own warehouse without contending the
// single-writer lock.
let Some(root) = local_warehouse else {
return Ok(Vec::new());
};
use crate::warehouse::clone_events::{CloneSelector, query_clone_events};
let wh = crate::warehouse::iceberg::IcebergWarehouse::open_read_only(root)
.map_err(|e| format!("open warehouse: {e:#}"))?;
wh.block_on(query_clone_events(
&wh,
&CloneSelector::Workspace(active_ws.to_string()),
))
.map_err(|e| format!("{e:#}"))
}
/// Load the job ledger for `active_ws`: remote via `Viz.Jobs`, else the local
/// `jobs.redb` beside the warehouse (lock-tolerant read). Newest-first.
fn load_jobs(
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> Result<Vec<crate::jobs::JobRecord>, String> {
if let Some(srv) = srv {
return remote::fetch_jobs(&srv.endpoint, &srv.token, active_ws, "", 200)
.map_err(|e| format!("{e:#}"));
}
let Some(root) = local_warehouse else {
return Ok(Vec::new());
};
crate::jobs::JobStore::open_read_only(root)
.and_then(|s| s.list(&crate::jobs::JobSelector::All))
.map_err(|e| format!("{e:#}"))
}
/// Map a nornir [`crate::jobs::JobRecord`] onto the host-agnostic facett
/// [`JobEntry`]. The `result_ref` becomes a `meta` row when present.
fn job_entry(r: &crate::jobs::JobRecord) -> JobEntry {
let meta = if r.result_ref.is_empty() {
Vec::new()
} else {
vec![("result".to_string(), r.result_ref.clone())]
};
let entry = JobEntry {
id: r.job_id.clone(),
kind: r.kind.clone(),
target: r.target.clone(),
status: JobStatus::parse(&r.status),
started_micros: r.ts_start_micros,
elapsed_ms: r.elapsed_ms,
detail: r.detail_json.clone(),
meta,
// Hierarchy: a populate's per-member clone + build children carry the
// parent populate job's id, so facett-jobview folds them underneath it.
parent_id: r.parent_id.clone(),
};
// **PHASE.** `JobRecord::phase` is written durably by jera's `daemon_report_sink`
// and preserved across `finish()`, and this mapper dropped it — as did
// `dwarves_embed::jobsfleet::entry_from_record`, the other host that turns a
// `JobRecord` into a `JobEntry`. Two hosts, one field, zero surfacing: everything
// the write side gained (the mirror, the preserve limb) landed behind a read side
// that threw the value away, so `cutting` on disk rendered as a bare `running`.
//
// Stamped through `JobEntry::with_phase`, NOT a local `meta.push(("phase", …))`:
// that writer owns the key's spelling and the "blank is not a phase" rule for every
// host, so the two mappers cannot drift into agreeing-until-they-do not (LAW 5).
// `fraction()` is the durable mirror of the progress tick and rides the existing
// `with_progress` writer for the same reason.
//
// NOT carried here, and named rather than hidden: this mapper still surfaces far
// less than dwarves-embed's (no `node`, no `by`/actor, no `workspace` meta, and
// `detail` is the raw `detail_json` rather than a folded summary). Unifying the two
// into one mapper needs a home both repos can reach — `facett-jobview` with an
// optional `jera` dep — which is a bigger change than this field.
let Some(dp) = r.daemon_phase() else { return entry };
let entry = entry.with_phase(Some(dp.name.as_str()));
match dp.fraction() {
Some(f) => entry.with_progress(f),
None => entry,
}
}
/// Render an `Ok`/`Err` result line in the palette colours (shared by the ops).
fn result_line(ui: &mut egui::Ui, theme: Theme, r: &Option<Result<String, String>>) {
let _ = theme;
match r {
Some(Ok(m)) => {
ui.colored_label(GREEN, format!("✓ {m}"));
}
Some(Err(e)) => {
ui.colored_label(RED, format!("✗ {e}"));
}
None => {}
}
}
/// Thin wrapper over `Workspaces.Fetch` that yields a one-line human summary
/// (Populate uses `background=true`; Refresh uses `force=true`).
fn fetch(
endpoint: &str,
token: &str,
name: &str,
force: bool,
background: bool,
) -> anyhow::Result<String> {
// `fetch_workspace` only exposes `force`; the background path is the same
// RPC with `background=true`, surfaced here via the dedicated helper.
let (fetched, changed, errors, snapshot) = if background {
remote::populate_workspace(endpoint, token, name)?
} else {
remote::fetch_workspace(endpoint, token, name, force)?
};
let snap = if snapshot.is_empty() {
String::new()
} else {
format!(" → snapshot {}", &snapshot[..12.min(snapshot.len())])
};
let errs = if errors.is_empty() {
String::new()
} else {
format!(", {} error(s)", errors.len())
};
Ok(format!(
"{} fetched, {} changed{snap}{errs}",
fetched,
changed.len()
))
}
#[cfg(test)]
mod tests {
use super::*;
/// LAW 1 (inject-assert) + LAW #6 (components emit what they render): inject a
/// `clone_events` row-set with one OK and one FAILED member into the pane, then
/// assert `state_json` surfaces the failed member, its error detail, the
/// failed-count, and the `Viz.CloneEvents` button — "see what the user sees" as
/// data. Not a "didn't panic" test.
#[test]
fn state_json_surfaces_failed_member_populate_status() {
let mut view = NornirRootView::remote();
view.set_clone_events_for_test(
"nordisk",
vec![
CloneEventRow {
ts_micros: 2,
workspace: "nordisk".into(),
member: "korp".into(),
remote: "https://github.com/nordisk/korp".into(),
op: "clone-fetch".into(),
status: "error".into(),
detail: "clone-fetch …: Couldn't obtain Username".into(),
elapsed_ms: 7,
},
CloneEventRow {
ts_micros: 1,
workspace: "nordisk".into(),
member: "facett".into(),
remote: "git@github.com:nordisk/facett.git".into(),
op: "clone-fetch".into(),
status: "ok".into(),
detail: "abc123".into(),
elapsed_ms: 42,
},
],
);
let s = view.state_json();
let ps = &s["populate_status"];
assert_eq!(ps["loaded"], true);
assert_eq!(ps["ok"], true);
assert_eq!(ps["workspace"], "nordisk");
assert_eq!(ps["total"], 2);
assert_eq!(ps["failed"], 1, "one member failed to populate");
let members = ps["members"].as_array().expect("members array");
let korp = members
.iter()
.find(|m| m["member"] == "korp")
.expect("failed member korp is surfaced");
assert_eq!(korp["status"], "error");
assert!(
korp["detail"]
.as_str()
.unwrap()
.contains("Couldn't obtain Username"),
"the error detail is readable in state_json, got: {}",
korp["detail"]
);
assert_eq!(korp["op"], "clone-fetch");
assert!(
korp["ts"].as_str().unwrap().contains('T'),
"ts rendered as RFC3339"
);
// The populate-status button is discoverable by the matrix.
let buttons = s["buttons"].as_array().unwrap();
assert!(
buttons
.iter()
.any(|b| b["id"] == "populate_status" && b["rpc"] == "Viz.CloneEvents"),
"the populate-status surface is wired to Viz.CloneEvents"
);
}
/// PO1 (inject-assert) + LAW #6: seed a multi-workspace roster — one RED
/// workspace (a failing member) and one GREEN — then assert
/// `state_json["nornir"]["workspaces"]` surfaces both verdicts, the green/red
/// counts, AND the failing member + its error. The roster's red/green roll-up,
/// as data. Also asserts the roster button is wired to Workspaces.PopulateStatus.
#[test]
fn state_json_surfaces_workspace_roster_red_and_green() {
let mut view = NornirRootView::remote();
view.set_roster_for_test(vec![
RosterRow {
name: "nordisk".into(),
mode: "monitored".into(),
members: 8,
last_synced: "2026-06-23T10:00:00+00:00".into(),
populate: "red".into(),
failing_member: "korp".into(),
last_error: "Couldn't obtain Username".into(),
},
RosterRow {
name: "holger".into(),
mode: "monitored".into(),
members: 2,
last_synced: "2026-06-23T10:01:00+00:00".into(),
populate: "green".into(),
..Default::default()
},
]);
let s = view.state_json();
let roster = &s["workspaces"];
assert_eq!(roster["loaded"], true);
assert_eq!(roster["ok"], true);
assert_eq!(roster["total"], 2);
assert_eq!(roster["green"], 1);
assert_eq!(roster["red"], 1, "one workspace failed to populate");
let rows = roster["rows"].as_array().unwrap();
let nordisk = rows.iter().find(|r| r["name"] == "nordisk").unwrap();
assert_eq!(nordisk["populate"], "red");
assert_eq!(nordisk["failing_member"], "korp");
assert!(
nordisk["last_error"]
.as_str()
.unwrap()
.contains("Couldn't obtain Username"),
"the failing member's error is readable in state_json"
);
let holger = rows.iter().find(|r| r["name"] == "holger").unwrap();
assert_eq!(holger["populate"], "green");
// The roster surface is discoverable + wired to the roll-up RPC.
let buttons = s["buttons"].as_array().unwrap();
assert!(
buttons
.iter()
.any(|b| b["id"] == "workspaces_roster" && b["rpc"] == "Workspaces.PopulateStatus"),
"the workspace roster is wired to Workspaces.PopulateStatus"
);
}
/// Serial-scheduler UX (inject-assert) + LAW #6: inject a job ledger holding a
/// **running** bench and a second bench **queued** behind it (exactly the rows
/// the serial scheduler writes — one exclusive job runs, the next waits
/// `queued`). Assert `state_json["nornir"]["jobs"]` renders the queued job AS
/// queued, counts one running + one queued, and that the Jobs surface is wired
/// to `Viz.Jobs`. This is the "see the queue" guarantee, as data.
#[test]
fn state_json_surfaces_queued_bench_behind_running_bench() {
use crate::jobs::{JobRecord, kind, status};
let mut view = NornirRootView::remote();
view.set_jobs_for_test(&[
JobRecord {
job_id: "running-bench".into(),
kind: kind::BENCH_RUN.into(),
target: "alpha".into(),
workspace: "nordisk".into(),
status: status::RUNNING.into(),
ts_start_micros: 2,
ts_end_micros: None,
elapsed_ms: None,
detail_json: String::new(),
result_ref: String::new(),
parent_id: None,
node: None,
phase: None,
actor: None,
actor_method: None,
},
JobRecord {
job_id: "queued-bench".into(),
kind: kind::BENCH_RUN.into(),
target: "beta".into(),
workspace: "nordisk".into(),
status: status::QUEUED.into(),
ts_start_micros: 1,
ts_end_micros: None,
elapsed_ms: None,
detail_json: String::new(),
result_ref: String::new(),
parent_id: None,
node: None,
phase: None,
actor: None,
actor_method: None,
},
]);
let s = view.state_json();
let jobs = &s["jobs"];
assert_eq!(jobs["count"], 2);
assert_eq!(jobs["running"], 1, "exactly one bench runs");
assert_eq!(
jobs["queued"], 1,
"the second bench is visibly queued behind it"
);
let rows = jobs["jobs"].as_array().expect("jobs array");
let queued = rows
.iter()
.find(|j| j["id"] == "queued-bench")
.expect("the queued bench is rendered, not hidden");
assert_eq!(
queued["status"], "queued",
"the waiting bench renders as queued (the visible-wait UX)"
);
let running = rows.iter().find(|j| j["id"] == "running-bench").unwrap();
assert_eq!(running["status"], "running");
// The Jobs surface is discoverable + wired to Viz.Jobs.
let buttons = s["buttons"].as_array().unwrap();
assert!(
buttons
.iter()
.any(|b| b["id"] == "jobs" && b["rpc"] == "Viz.Jobs"),
"the jobs surface is wired to Viz.Jobs"
);
}
/// Build a populate-style job record (parent or child) for the tree tests.
fn job_rec(
id: &str,
kind: &str,
status: &str,
start: i64,
parent: Option<&str>,
) -> crate::jobs::JobRecord {
crate::jobs::JobRecord {
job_id: id.into(),
kind: kind.into(),
target: id.into(),
workspace: "nordisk".into(),
status: status.into(),
ts_start_micros: start,
ts_end_micros: None,
elapsed_ms: None,
detail_json: String::new(),
result_ref: String::new(),
parent_id: parent.map(|p| p.to_string()),
node: None,
phase: None,
actor: None,
actor_method: None,
}
}
/// The Jobs panel is a TREE: a running populate parent with two member-clone
/// children NESTS the children under it (depth 1), stays EXPANDED (in-progress
/// work visible), and the nornir + facett state surfaces both report the shape.
#[test]
fn jobs_panel_nests_children_under_running_parent_and_stays_expanded() {
use crate::jobs::{kind, status};
let mut view = NornirRootView::remote();
view.set_jobs_for_test(&[
job_rec("pop", kind::WORKSPACE_POPULATE, status::RUNNING, 100, None),
job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
job_rec(
"m2",
kind::WORKSPACE_CLONE,
status::RUNNING,
120,
Some("pop"),
),
]);
let s = view.state_json();
// nornir's own tree surface: one root parent, two children, nothing folded
// (the parent is still running, so the default-open rule keeps it expanded).
let tree = &s["jobs_tree"];
assert_eq!(
tree["roots"], 1,
"the populate parent is the single root row"
);
assert_eq!(tree["parents"], 1, "one row has children");
assert_eq!(tree["children"], 2, "two member clones are sub-jobs");
assert_eq!(
tree["folded"].as_array().unwrap().len(),
0,
"a running parent stays EXPANDED by default"
);
// facett's display tree: parent at depth 0, both children indented at depth 1.
let rows = s["jobs"]["jobs"].as_array().expect("jobs tree rows");
let parent = rows
.iter()
.find(|r| r["id"] == "pop")
.expect("parent row present");
assert_eq!(parent["depth"], 0, "parent at top level");
for child in ["m1", "m2"] {
let row = rows
.iter()
.find(|r| r["id"] == child)
.expect("child row present");
assert_eq!(row["depth"], 1, "{child} nested under its parent");
assert_eq!(
row["parent_id"], "pop",
"{child} links to the populate parent"
);
}
}
/// The collapse rule + toggle: a FULLY-DONE populate parent (all children done)
/// is auto-COLLAPSED by default (tidy), its hidden kids replaced by a roll-up
/// summary; a failed descendant instead keeps the parent EXPANDED so the error
/// stays visible.
#[test]
fn jobs_panel_collapses_done_parent_but_keeps_failing_one_expanded() {
use crate::jobs::{kind, status};
// (a) Every descendant finished cleanly → the parent folds by default.
let mut done = NornirRootView::remote();
done.set_jobs_for_test(&[
job_rec("pop", kind::WORKSPACE_POPULATE, status::DONE, 100, None),
job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
job_rec("scan", kind::KNOWLEDGE_SCAN, status::DONE, 120, Some("pop")),
]);
let s = done.state_json();
let folded: Vec<&str> = s["jobs_tree"]["folded"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
folded,
["pop"],
"a fully-done parent is collapsed by default"
);
// Folded: only the parent row shows; its kids are rolled up into a summary.
let rows = s["jobs"]["jobs"].as_array().unwrap();
assert!(
rows.iter().all(|r| r["depth"] == 0),
"no child rows while folded"
);
let parent = rows.iter().find(|r| r["id"] == "pop").unwrap();
assert!(
parent["child_summary"].as_str().unwrap().contains("2/2 ✓"),
"the folded parent rolls its done children up: {}",
parent["child_summary"]
);
// (b) A failed child keeps the parent EXPANDED so the failure is visible.
let mut failing = NornirRootView::remote();
failing.set_jobs_for_test(&[
job_rec("pop", kind::WORKSPACE_POPULATE, status::DONE, 100, None),
job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
job_rec("sec", kind::SECURITY_SCAN, status::FAILED, 120, Some("pop")),
]);
let s = failing.state_json();
assert_eq!(
s["jobs_tree"]["folded"].as_array().unwrap().len(),
0,
"a parent with a failed descendant stays expanded"
);
// The failed child is visible nested under the parent.
let rows = s["jobs"]["jobs"].as_array().unwrap();
let sec = rows
.iter()
.find(|r| r["id"] == "sec")
.expect("failed child visible");
assert_eq!(sec["depth"], 1);
assert_eq!(sec["status"], "failed");
}
}