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

nordisk/dwarves

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"
        );
    }
}