dwarves-gui/src/status.rs
//! **StatusPane** — the forge's *health board*: a wall of one status **badge** per
//! pipeline (green/red/amber/grey), the UI surface of the `dwarves badge` verb.
//!
//! This pane is a pure CONSUMER of `dwarves::brokkr::badge` (Law #1 — one badge
//! source, never a twin): it reads a run history, asks the shared
//! [`badge::latest_status`] for each pipeline's newest-run status, and renders that
//! status as a chip whose colour + word come from the shared [`badge::color_for`] /
//! [`badge::status_text`]. Selecting a pipeline expands the **exact same SVG**
//! [`badge::svg`] emits over the wire — so the on-screen board and a README `<img>`
//! agree byte-for-byte.
//!
//! ## The feed (2026-09-10 — the fixture is gone)
//! Until 2026-09-10 the ONLY constructor was `from_fixture`: seven hand-written chips
//! (`build`/`test`/`bench`/`release`/`publish`/`history`/`unknown-pipe`) with
//! representative statuses, three of them green, behind a `source: "fixture"` marker
//! and a "(fixture — demo)" caption. The marker was honest and the pixels were not: a
//! health board that draws green chips IS a claim of health, whatever the caption says.
//!
//! The feed now is **`dwarf.v1/Dwarf.Show`** on the running `dwarves-server` — the
//! forge's own render-ready dump, the registered pipeline ROSTER plus the recent RUNS
//! in one round trip (`dwarves_embed::fetch_show`). `Show` and not `History`, because
//! a board draws a chip per *pipeline* and a pipeline that never ran contributes no
//! history row: fed by history alone, the board could only ever show the pipelines
//! that already worked.
//!
//! When that feed has nothing, the board says so **by name** — the shared
//! [`facett_core::empty::EmptyState`], carrying which feed, why (unreachable vs read-
//! and-empty), and the endpoint — and draws no chips at all.
use dwarves::brokkr::badge;
use dwarves::brokkr::history::{PipelineRunRow, RunStatus};
use facett_core::empty::EmptyState;
use facett_core::Facet;
/// The feed's name, as an operator would go look it up.
const FEED: &str = "dwarf.v1/Dwarf.Show";
/// One landed `Show` read: the pipeline names and the run rows, or why it failed.
type ShowRead = Result<(Vec<String>, Vec<PipelineRunRow>), String>;
/// One board row: a pipeline and its latest-run status (computed via the shared
/// selector — `None` = no run recorded yet ⇒ a grey "unknown" chip).
struct BadgeRow {
pipeline: String,
status: Option<RunStatus>,
}
/// The forge health board — a badge per pipeline over the forge's OWN `Show` dump.
pub struct StatusPane {
title: String,
runs: Vec<PipelineRunRow>,
pipelines: Vec<String>,
/// The pipeline whose full SVG is expanded (`None` = none selected).
selected: Option<String>,
/// Set when the feed produced no board — the NAMED empty state (which feed, why),
/// drawn in place of the chips. `None` = the board is real rows off the feed.
empty: Option<EmptyState>,
/// The forge endpoint this board reads (`""` for an injected board in a test).
endpoint: String,
/// The in-flight `Show` read, landed once per frame by [`StatusPane::poll`].
pending: Option<std::sync::mpsc::Receiver<ShowRead>>,
}
impl StatusPane {
/// **The shipped board** — read `Dwarf.Show` off the forge at `endpoint`.
///
/// Opens in the `unreachable` empty state and kicks the read onto a worker thread,
/// so shell construction never blocks on a socket and a dead server never wedges
/// the window. [`poll`](Self::poll) lands the answer.
pub fn from_forge(endpoint: &str, token: &str) -> Self {
let mut me = Self {
title: "dwarves — status board".to_string(),
runs: Vec::new(),
pipelines: Vec::new(),
selected: None,
empty: Some(
EmptyState::unreachable(FEED, format!("{endpoint} — not read yet"))
.with_hint("the forge board fills in when the read lands"),
),
endpoint: endpoint.to_string(),
pending: None,
};
me.start(token);
me
}
/// **A board over rows already in hand** — the ONE seam every feed funnels through
/// (the live `Show` land, and a test that injects a ledger). `pipelines` is the
/// roster to draw a chip for; `runs` the ledger the shared selector resolves each
/// chip's latest status from. An EMPTY roster is not a board: it becomes the named
/// `no-rows` empty state, never a blank rectangle.
pub fn from_rows(pipelines: Vec<String>, runs: Vec<PipelineRunRow>, detail: &str) -> Self {
let empty = pipelines.is_empty().then(|| {
EmptyState::no_rows(FEED, detail.to_string()).with_hint(
"register a pipeline (`dwarves repo add …`) or run one (`dwarves run …`)",
)
});
Self {
title: "dwarves — status board".to_string(),
runs,
pipelines,
selected: None,
empty,
endpoint: String::new(),
pending: None,
}
}
/// A board that is only its named empty state — the refusal form (an unreachable
/// forge, a feed this build cannot reach).
pub fn empty(state: EmptyState) -> Self {
Self {
title: "dwarves — status board".to_string(),
runs: Vec::new(),
pipelines: Vec::new(),
selected: None,
empty: Some(state),
endpoint: String::new(),
pending: None,
}
}
/// Kick the `Show` read onto a worker thread (never the egui thread).
fn start(&mut self, token: &str) {
if self.pending.is_some() || self.endpoint.is_empty() {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let endpoint = self.endpoint.clone();
let token = token.to_string();
if std::thread::Builder::new()
.name("status-show".into())
.spawn(move || {
let _ = tx.send(
dwarves_embed::fetch_show(&endpoint, &token).map_err(|e| format!("{e:#}")),
);
})
.is_ok()
{
self.pending = Some(rx);
}
}
/// Land a finished `Show` read, if one is ready. Non-blocking; called once per frame.
pub fn poll(&mut self) {
let Some(rx) = &self.pending else { return };
match rx.try_recv() {
Ok(res) => {
self.pending = None;
self.land(res);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.pending = None;
self.empty = Some(EmptyState::unreachable(
FEED,
format!("{} — the read worker died", self.endpoint),
));
}
}
}
/// True while a `Show` read is in flight (the frame asks for a repaint).
pub fn is_pending(&self) -> bool {
self.pending.is_some()
}
/// Apply a finished read. An UNREACHABLE forge and a forge that answered with an
/// empty registry are two different states and stay told apart — the first says
/// nothing about the forge's health, the second says the forge has no pipelines.
fn land(&mut self, res: ShowRead) {
match res {
Ok((pipelines, runs)) => {
let detail = format!(
"{} answered with {} pipeline(s) and {} run(s)",
self.endpoint,
pipelines.len(),
runs.len()
);
let endpoint = std::mem::take(&mut self.endpoint);
let selected = self.selected.take();
*self = Self::from_rows(pipelines, runs, &detail);
self.endpoint = endpoint;
self.selected = selected;
}
Err(e) => {
self.runs.clear();
self.pipelines.clear();
self.empty = Some(
EmptyState::unreachable(FEED, format!("{}: {e}", self.endpoint))
.with_hint("start the forge: `dwarves-server`"),
);
}
}
}
/// The board: each pipeline resolved to its latest status through the SHARED
/// selector (the same call the CLI/RPC make).
fn board(&self) -> Vec<BadgeRow> {
self.pipelines
.iter()
.map(|p| BadgeRow {
pipeline: p.clone(),
status: badge::latest_status(&self.runs, None, p),
})
.collect()
}
/// The selected pipeline's badge SVG (the wire-identical artifact), if one is
/// selected and known.
fn selected_svg(&self) -> Option<String> {
let p = self.selected.as_deref()?;
self.pipelines
.iter()
.any(|x| x == p)
.then(|| badge::svg(p, badge::latest_status(&self.runs, None, p)))
}
}
impl StatusPane {
/// **The health BOARD — the master half.** One clickable chip row per pipeline.
///
/// The `ui.heading("pipeline health")` this used to open with is gone: the pane wears
/// a chrome title bar carrying its own name now, and a heading is a second AccessKit
/// node competing with it for no gain.
fn board_ui(&mut self, ui: &mut egui::Ui) {
self.poll();
if self.is_pending() {
ui.ctx().request_repaint();
}
// No feed ⇒ the NAMED empty state, and NO chips. A board with nothing behind it
// draws nothing that looks like a verdict.
if let Some(e) = &self.empty {
e.ui(ui);
return;
}
let mut clicked: Option<String> = None;
for row in self.board() {
ui.horizontal(|ui| {
ui.monospace(format!("{:<14}", row.pipeline));
let word = badge::status_text(row.status);
let fill = hex_color(badge::color_for(row.status));
// The status chip — a colour-filled label (fill/word from the shared
// badge module; egui just draws it). Clicking a row expands its SVG.
let chip = egui::RichText::new(format!(" {word} "))
.background_color(fill)
.color(egui::Color32::WHITE)
.monospace();
if ui
.selectable_label(
self.selected.as_deref() == Some(row.pipeline.as_str()),
chip,
)
.clicked()
{
clicked = Some(row.pipeline.clone());
}
});
}
if let Some(p) = clicked {
// Toggle: re-clicking the open one collapses it.
self.selected = (self.selected.as_deref() != Some(p.as_str())).then_some(p);
}
}
/// **The expanded BADGE — the inspector half.** The exact SVG `dwarves badge` emits
/// over the wire for the selected pipeline, so the screen and a README `<img>` agree
/// byte-for-byte.
///
/// This is why the pane decomposes: the SVG is a multi-hundred-character blob that
/// used to be appended UNDER the board in the same vertical flow, so opening one
/// badge pushed the board up — and with enough pipelines, off the top of the pane —
/// with no divider to take the space back.
fn badge_ui(&mut self, ui: &mut egui::Ui) {
let Some(svg) = self.selected_svg() else {
ui.weak("(pick a pipeline on the board to expand its badge)");
return;
};
ui.label("badge SVG (copy into a README):");
egui::ScrollArea::both()
.auto_shrink([false, false])
.id_salt("status-badge-svg")
.show(ui, |ui| {
ui.monospace(svg);
});
}
}
/// **The Status body DECOMPOSES**: the board is the master, the expanded badge SVG the
/// detail — two scene panes with a draggable divider, each with its own chrome bar.
impl crate::tabbed::SplitPanel for StatusPane {
fn ui_main(&mut self, ui: &mut egui::Ui) {
self.board_ui(ui);
}
fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
self.badge_ui(ui);
serde_json::json!({
"selected": self.selected,
"selected_svg": self.selected_svg(),
})
}
}
/// Parse a shields hex (`#4c1` or `#e05d44`) into an egui colour — the fill for a
/// status chip. The hex itself comes from the shared `badge::color_for`, so this is
/// only the render, never a colour decision.
fn hex_color(hex: &str) -> egui::Color32 {
let h = hex.trim_start_matches('#');
let full: String = if h.len() == 3 {
h.chars().flat_map(|c| [c, c]).collect()
} else {
h.to_string()
};
let byte =
|i: usize| u8::from_str_radix(full.get(i..i + 2).unwrap_or("9f"), 16).unwrap_or(0x9f);
egui::Color32::from_rgb(byte(0), byte(2), byte(4))
}
impl Facet for StatusPane {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
// The whole board, in order — the shape this pane keeps when it is mounted
// ALONE (the unit tests below box it bare). In the shell it is mounted as a
// `SplitPanel`, and these two halves become two scene panes with a draggable
// divider between them; see the `SplitPanel` impl at the bottom of this file.
self.board_ui(ui);
self.badge_ui(ui);
}
fn state_json(&self) -> serde_json::Value {
let board = self.board();
let green = board
.iter()
.filter(|b| b.status.map(|s| s.is_green()).unwrap_or(false))
.count();
let badges: Vec<serde_json::Value> = board
.iter()
.map(|b| {
serde_json::json!({
"pipeline": b.pipeline,
"status": badge::status_text(b.status),
"color": badge::color_for(b.status),
"found": b.status.is_some(),
})
})
.collect();
let mut j = serde_json::json!({
"kind": "status",
"title": self.title,
// The FEED, always named — this replaced the `source`/`from_fixture` pair
// that used to admit the board was invented.
"feed": FEED,
"endpoint": self.endpoint,
"pending": self.pending.is_some(),
"count": board.len(),
"green_count": green,
"badges": badges,
"selected": self.selected,
"selected_svg": self.selected_svg(),
});
if let Some(e) = &self.empty {
j["empty_state"] = e.state_json();
}
j
}
fn update_json(&mut self, msg_json: &str) {
// `{"select":"build"}` expands a pipeline's SVG; `{"select":""}` clears. The
// same seam a ComboBox/row click drives, and the robot harness uses.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
if let Some(sel) = v.get("select").and_then(|s| s.as_str()) {
self.selected = (!sel.is_empty()).then(|| sel.to_string());
}
// `{"wait":true}` blocks for an in-flight `Show` read — the headless seam a
// robot scenario drives so it can assert on a LANDED board.
if v.get("wait").and_then(|w| w.as_bool()).unwrap_or(false) {
if let Some(rx) = self.pending.take() {
let res = rx.recv().unwrap_or_else(|e| Err(format!("{e}")));
self.land(res);
}
}
}
}
}
/// A ledger row for a test board (only the fields the badge selector reads matter).
/// **Test-only**: it is not reachable from any shipped constructor, which is the point
/// — the shipped board's rows come off `Dwarf.Show` or the board says it has none.
#[cfg(test)]
pub(crate) fn test_run(pipeline: &str, status: RunStatus) -> PipelineRunRow {
PipelineRunRow {
run_id: format!("{pipeline}-{}", status.label()),
pipeline: pipeline.to_string(),
project: "dwarves".to_string(),
trigger: "manual".to_string(),
sha: None,
status,
steps: 1,
passed: usize::from(status.is_green()),
duration_ms: 1,
job_ref: None,
finished_at: "2026-07-14T00:00:00Z".to_string(),
}
}
/// A board over a test ledger — the injection every board test uses in place of the
/// deleted `from_fixture`.
#[cfg(test)]
pub(crate) fn test_board() -> StatusPane {
let runs = vec![
test_run("build", RunStatus::Passed),
test_run("test", RunStatus::Passed),
test_run("bench", RunStatus::Running),
test_run("history", RunStatus::Passed), // older…
test_run("release", RunStatus::Failed),
test_run("history", RunStatus::Failed), // …newer wins
test_run("publish", RunStatus::Passed),
];
let pipelines = [
"build",
"test",
"bench",
"release",
"publish",
"history",
"unknown-pipe",
]
.iter()
.map(|s| s.to_string())
.collect();
StatusPane::from_rows(pipelines, runs, "injected test ledger")
}
#[cfg(test)]
mod tests {
use super::*;
/// The board resolves each pipeline to its LATEST status via the shared selector,
/// and folds it into `state_json` with the shared colour/word — RED-when-broken:
/// if `latest_status` picked the wrong run or the colour map regressed, `bench`
/// wouldn't be amber "running", `release` wouldn't be red "failing", and the
/// twice-run `history` pipeline wouldn't show its NEWER "failing".
#[test]
fn board_reflects_the_latest_status_per_pipeline() {
let pane = test_board();
let s = pane.state_json();
assert_eq!(s["kind"], "status");
assert_eq!(s["count"], 7);
assert_eq!(s["green_count"], 3, "build/test/publish are green");
let badges = s["badges"].as_array().unwrap();
let find = |name: &str| {
badges
.iter()
.find(|b| b["pipeline"] == name)
.unwrap()
.clone()
};
assert_eq!(find("build")["status"], "passing");
assert_eq!(find("build")["color"], "#4c1");
assert_eq!(
find("bench")["status"],
"running",
"running badge is amber, not red"
);
assert_eq!(find("bench")["color"], "#dfb317");
assert_eq!(find("release")["status"], "failing");
assert_eq!(
find("history")["status"],
"failing",
"the NEWER history run wins"
);
assert_eq!(find("unknown-pipe")["status"], "unknown");
assert_eq!(
find("unknown-pipe")["found"],
false,
"no run ⇒ found=false, a grey chip (never 404)"
);
}
/// **THE FIXTURE IS GONE** (audit 2026-09-10). The board's body used to be seven
/// hand-written chips built by `StatusPane::from_fixture()` — three of them green —
/// behind a `source:"fixture"` marker. This pins that it can never come back:
///
/// (a) a board built with NO feed draws the NAMED empty state (`dwarf.v1/Dwarf.Show`
/// + a cause), not chips — `count == 0`, `green_count == 0`;
/// (b) the deleted fixture's own pipeline names (`unknown-pipe` — the invented
/// never-registered pipeline that existed only to paint a grey chip) appear
/// NOWHERE in the empty board's state;
/// (c) the old honesty markers are gone from `state_json` — there is no `source`
/// and no `from_fixture`, because there is no fixture to mark;
/// (d) an UNREACHABLE forge and a forge that answered EMPTY stay told apart.
///
/// RED-when-broken: restore any fallback roster and (a)/(b) fail; re-add the marker
/// pair and (c) fails.
#[test]
fn board_shows_the_named_feed_never_a_fixture() {
// (a) + (b) + (c): the unreachable forge board.
let mut pane = StatusPane::from_forge("http://127.0.0.1:1", "");
pane.update_json(r#"{"wait":true}"#);
let s = pane.state_json();
assert_eq!(s["count"], 0, "no feed ⇒ no chips at all: {s}");
assert_eq!(s["green_count"], 0, "an empty board is never green: {s}");
assert_eq!(s["empty_state"]["empty"], true);
assert_eq!(
s["empty_state"]["feed"], "dwarf.v1/Dwarf.Show",
"the empty state NAMES the feed"
);
assert_eq!(s["empty_state"]["cause"], "unreachable");
assert!(
s["empty_state"]["detail"]
.as_str()
.unwrap()
.contains("127.0.0.1:1"),
"…and names the endpoint that refused: {s}"
);
let dump = s.to_string();
assert!(
!dump.contains("unknown-pipe"),
"the deleted fixture's invented pipeline is gone: {dump}"
);
assert!(
s.get("source").is_none(),
"no `source` marker — there is no fixture to mark: {dump}"
);
assert!(
s.get("from_fixture").is_none(),
"no `from_fixture` flag either: {dump}"
);
assert_eq!(
s["feed"], "dwarf.v1/Dwarf.Show",
"the pane names its feed unconditionally"
);
// (d) reachable-but-empty is a DIFFERENT state, and it is not "unreachable".
let empty = StatusPane::from_rows(
Vec::new(),
Vec::new(),
"the forge answered with 0 pipelines",
);
let e = empty.state_json();
assert_eq!(
e["empty_state"]["cause"], "no-rows",
"read-and-empty ≠ unreachable: {e}"
);
assert_eq!(e["count"], 0);
}
/// Selecting a pipeline expands its wire-identical SVG over the JSON seam; empty
/// clears it. Drives the pane exactly as the robot harness / a row click does.
#[test]
fn selecting_a_pipeline_expands_the_shared_svg() {
let mut pane = test_board();
assert!(pane.state_json()["selected"].is_null());
assert!(pane.state_json()["selected_svg"].is_null());
pane.update_json(r#"{"select":"build"}"#);
let s = pane.state_json();
assert_eq!(s["selected"], "build");
let svg = s["selected_svg"].as_str().unwrap();
assert!(svg.starts_with("<svg") && svg.contains("passing") && svg.contains("#4c1"));
pane.update_json(r#"{"select":""}"#);
assert!(
pane.state_json()["selected"].is_null(),
"empty selection clears"
);
}
}