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/panel.rs

//! The **dwarves CI/CD forge view** panel — now the shared `facett-brokkrview`
//! results component.
//!
//! The forge orchestrates nornir + holger; its operator view is *what a pipeline
//! did*: a run **roster** (recent forge runs with green/red/running chips + a Run
//! button per pipeline), a project→pipeline→stage→step **tree** (each step's live
//! status + the incremental disposition — `ran` / `cache-hit` / `unchanged` / …),
//! and a stage/step **DAG** tinted by status. All three are the reusable
//! [`facett_brokkrview::BrokkrView`].
//!
//! ## The fixture is gone (audit 2026-09-10)
//! All three used to be fed a **dwarves-shaped fixture** so the panel could run with no
//! backend: a roster of five invented runs (`#1042` passed · `#1041` passed · `#1040`
//! running · `#1039` failed · `#1038` passed, with invented shas `a1b2c3d4` /
//! `deadbeef` / `feedface` and invented durations), plus an all-green `build` tree and
//! DAG. A forge view's whole content is *what the forge just did*, and it had done none
//! of it. Worse, the LIVE path kept that tree and DAG even after swapping in real runs,
//! so a live roster sat above a fixture DAG with nothing marking the seam.
//!
//! Now the panel is fed by the forge or by nothing: [`ForgeView::live`] reads
//! `dwarf.v1/Dwarf.History` over grpc-web for the roster and then
//! `dwarf.v1/Dwarf.GetRunReport` for the newest run, and reconstructs the tree + DAG
//! from that report's own steps and `needs` edges. Until (or unless) that lands, the
//! panel draws the shared [`facett_core::empty::EmptyState`] naming the endpoint.
//!
//! ## From INTERIM to the shared component
//! This used to be two plain `facett_table::Table`s over a fixture, labelled
//! interim, awaiting the shared forge component. That component now exists
//! (`facett-brokkrview`, the egui renderer over dwarves' own brokkr Elm core), so
//! [`ForgeView`] is a thin wrapper that mounts it — the harness API
//! (`FacetApp` → `NativeDemo`/`WebDemo`) is unchanged, and the state hook stays
//! `__dwarves_state`. `facett-brokkrview` mirrors the dwarves brokkr model shape, so
//! there is no `dwarves → facett` dependency cycle.

use facett_brokkrview::{BrokkrView, DagView, RunRow, TreeView};
#[cfg(any(test, target_arch = "wasm32"))]
use facett_brokkrview::{DagNode, TreeStage, TreeStep};
use facett_core::empty::EmptyState;
use facett_core::Facet;

/// The forge feed, named the way an operator would go look it up.
const FORGE_FEED: &str = "dwarf.v1/Dwarf.History + Dwarf.GetRunReport (grpc-web)";

/// The dwarves forge view panel: the shared brokkr results component over a
/// dwarves-shaped fixture. A single [`Facet`] the
/// [`facett_wasm_demo`](facett_wasm_demo) harness mounts on a native window and a
/// wasm canvas from the same code.
// On native, `ForgeView` is constructed only by the tabbed shell's tests + this module's
// tests (the shipped native bin mounts the LIVE `dwarves_embed` forge, not this fixture
// view); the wasm bin mounts it directly. So it is dead in the non-test NATIVE bin build —
// allowed, exactly as the removed `panel()` helper used to suppress it.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub struct ForgeView {
    inner: BrokkrView,
    /// Set while the panel has no run to show — the NAMED empty state (which RPC, why),
    /// drawn in place of the roster/tree/DAG. This replaced the `#1038`…`#1042` fixture.
    empty: Option<EmptyState>,
    /// The LIVE grpc-web binding (wasm web demo only): the forge endpoint + bearer + the
    /// async result slot. `None` ⇒ the pure fixture panel (native tests, offline demo).
    #[cfg(target_arch = "wasm32")]
    live: Option<Live>,
}

/// The live grpc-web state the wasm Forge view polls each frame (see [`ForgeView::live`]).
#[cfg(target_arch = "wasm32")]
struct Live {
    /// The `dwarves-server` grpc-web base URL (e.g. `https://cicd.g11.se:7900`).
    server: String,
    /// The `authorization: Bearer <token>` secret (from the page's `?token=` param).
    token: String,
    /// Whether the one-shot fetch has been kicked onto the browser event loop yet.
    started: bool,
    /// The slot the async fetch writes its result into: `Ok((live runs, tree, DAG))` —
    /// the tree/DAG reconstructed from the NEWEST run's own report — or `Err(reason)`.
    slot: std::rc::Rc<std::cell::RefCell<Option<Result<(Vec<RunRow>, TreeView, DagView), String>>>>,
    /// The render provenance the verify hook reads: `"empty"` until a fetch resolves,
    /// then `"live"` (the panel rebuilt from server runs) or `"unreachable"` (the panel
    /// stays empty and says so).
    source: &'static str,
    /// How many live runs the server returned (0 until a successful fetch).
    run_count: usize,
}

impl Default for ForgeView {
    fn default() -> Self {
        Self::unrun("no forge endpoint was given to this panel")
    }
}

/// A dwarves-shaped forge fixture (pipelines · tree · DAG · run roster) for a TEST.
///
/// **`#[cfg(test)]` since 2026-09-10.** This was `fixture_parts()`, and both the offline
/// panel AND the live one drew from it — the live one kept its tree and DAG even after
/// replacing the roster with real server runs.
#[cfg(test)]
fn fixture_parts() -> (Vec<String>, TreeView, DagView, Vec<RunRow>) {
    let pipelines = vec![
        "build".to_string(),
        "test".to_string(),
        "bench".to_string(),
        "release".to_string(),
        "publish".to_string(),
    ];

    // The tree + DAG of the `build` pipeline (compile → unit/lint → release).
    let tree = TreeView {
        project: "dwarves".to_string(),
        pipeline: "build".to_string(),
        stages: vec![
            TreeStage {
                name: "compile".to_string(),
                steps: vec![TreeStep {
                    name: "cargo-build".to_string(),
                    status: "passed".to_string(),
                    disposition: Some("ran".to_string()),
                    cached: false,
                }],
            },
            TreeStage {
                name: "verify".to_string(),
                steps: vec![
                    TreeStep {
                        name: "unit".to_string(),
                        status: "passed".to_string(),
                        disposition: Some("cache-hit".to_string()),
                        cached: true,
                    },
                    TreeStep {
                        name: "lint".to_string(),
                        status: "passed".to_string(),
                        disposition: Some("unchanged".to_string()),
                        cached: false,
                    },
                ],
            },
            TreeStage {
                name: "forge".to_string(),
                steps: vec![TreeStep {
                    name: "release".to_string(),
                    status: "passed".to_string(),
                    disposition: Some("ran".to_string()),
                    cached: false,
                }],
            },
        ],
    };
    let dag = DagView {
        nodes: vec![
            DagNode {
                step: "cargo-build".into(),
                stage: "compile".into(),
                status: "passed".into(),
                disposition: Some("ran".into()),
            },
            DagNode {
                step: "unit".into(),
                stage: "verify".into(),
                status: "passed".into(),
                disposition: Some("cache-hit".into()),
            },
            DagNode {
                step: "lint".into(),
                stage: "verify".into(),
                status: "passed".into(),
                disposition: Some("unchanged".into()),
            },
            DagNode {
                step: "release".into(),
                stage: "forge".into(),
                status: "passed".into(),
                disposition: Some("ran".into()),
            },
        ],
        edges: vec![
            ("cargo-build".into(), "unit".into()),
            ("cargo-build".into(), "lint".into()),
            ("unit".into(), "release".into()),
            ("lint".into(), "release".into()),
        ],
    };

    // The recent-run roster (the "what the forge just did").
    let runs = vec![
        RunRow {
            run_id: "#1042".into(),
            pipeline: "build".into(),
            status: "passed".into(),
            trigger: "push".into(),
            sha: Some("a1b2c3d4".into()),
            steps: "4/4".into(),
            duration: "134.0s".into(),
        },
        RunRow {
            run_id: "#1041".into(),
            pipeline: "test".into(),
            status: "passed".into(),
            trigger: "push".into(),
            sha: Some("a1b2c3d4".into()),
            steps: "3/3".into(),
            duration: "302.0s".into(),
        },
        RunRow {
            run_id: "#1040".into(),
            pipeline: "bench".into(),
            status: "running".into(),
            trigger: "cron".into(),
            sha: None,
            steps: "1/3".into(),
            duration: "108.0s".into(),
        },
        RunRow {
            run_id: "#1039".into(),
            pipeline: "release".into(),
            status: "failed".into(),
            trigger: "manual".into(),
            sha: Some("deadbeef".into()),
            steps: "0/2".into(),
            duration: "31.0s".into(),
        },
        RunRow {
            run_id: "#1038".into(),
            pipeline: "publish".into(),
            status: "passed".into(),
            trigger: "manual".into(),
            sha: Some("feedface".into()),
            steps: "2/2".into(),
            duration: "56.0s".into(),
        },
    ];

    (pipelines, tree, dag, runs)
}

/// An EMPTY tree/DAG pair — the shape the shared component takes when there is no run
/// to draw. Not a "blank build pipeline": no stages, no nodes, no edges.
fn empty_parts() -> (TreeView, DagView) {
    (
        TreeView {
            project: String::new(),
            pipeline: String::new(),
            stages: Vec::new(),
        },
        DagView {
            nodes: Vec::new(),
            edges: Vec::new(),
        },
    )
}

#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
impl ForgeView {
    /// **The panel with no run** — an empty roster, tree and DAG, carrying the NAMED
    /// reason. `why` is the concrete detail (the endpoint that refused, the fact that
    /// no endpoint was configured).
    pub fn unrun(why: impl Into<String>) -> Self {
        let (tree, dag) = empty_parts();
        Self {
            inner: BrokkrView::new("dwarves — CI/CD forge view", Vec::new(), tree, dag),
            empty: Some(
                EmptyState::not_configured(FORGE_FEED, why)
                    .with_hint("start the forge (`dwarves-server`) and reload with ?server=…"),
            ),
            #[cfg(target_arch = "wasm32")]
            live: None,
        }
    }

    /// **Build the panel from a forge's own answer** — the ONE seam every real feed
    /// funnels through. `runs` is the `Dwarf.History` roster; `report` is the newest
    /// run's `Dwarf.GetRunReport`, from which the tree and the DAG are RECONSTRUCTED
    /// (its steps grouped by stage, its `needs` as the edges) rather than kept from a
    /// fixture. Pipelines are the distinct pipeline names the roster actually names.
    pub fn from_forge(runs: Vec<RunRow>, tree: TreeView, dag: DagView, title: &str) -> Self {
        let mut pipelines: Vec<String> = Vec::new();
        for r in &runs {
            if !r.pipeline.is_empty() && !pipelines.iter().any(|p| p == &r.pipeline) {
                pipelines.push(r.pipeline.clone());
            }
        }
        let empty = runs.is_empty().then(|| {
            EmptyState::no_rows(FORGE_FEED, "the forge answered with zero runs")
                .with_hint("trigger one: `dwarves run <pipeline>`")
        });
        Self {
            inner: BrokkrView::new(title, pipelines, tree, dag).with_runs(runs),
            empty,
            #[cfg(target_arch = "wasm32")]
            live: None,
        }
    }

    /// The panel over a fixture — **test-only** (see [`fixture_parts`]).
    #[cfg(test)]
    pub fn from_fixture() -> Self {
        let (pipelines, tree, dag, runs) = fixture_parts();
        let inner =
            BrokkrView::new("dwarves — CI/CD forge view", pipelines, tree, dag).with_runs(runs);
        Self {
            inner,
            empty: None,
            #[cfg(target_arch = "wasm32")]
            live: None,
        }
    }

    /// **The LIVE web forge view (wasm)** — open on the NAMED empty state (the canvas
    /// says which RPC it is waiting on, never blank and never a sample), then fetch the
    /// recent-run roster AND the newest run's report from a running `dwarves-server`
    /// over grpc-web. On the first `ui()` frame the fetch is kicked onto the browser
    /// event loop; when it resolves the whole panel — roster, tree and DAG — is rebuilt
    /// from the server's answer and `source` flips to `"live"`. If the server is
    /// unreachable the panel STAYS empty and `source` becomes `"unreachable"`, which is
    /// a different fact from "the forge has no runs" and is kept apart from it.
    ///
    /// `server` is the forge base URL (e.g. `https://cicd.g11.se:7900`) and `token` the
    /// bearer — both from the page's `?server=…&token=…` query params (never hardcoded).
    #[cfg(target_arch = "wasm32")]
    pub fn live(server: String, token: String) -> Self {
        let mut me = Self::unrun(format!("{server} — not read yet"));
        me.live = Some(Live {
            server,
            token,
            started: false,
            slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
            source: "empty",
            run_count: 0,
        });
        me
    }

    /// Drive the live grpc-web fetch (wasm): kick it once, then apply a resolved result.
    /// Called at the top of each `ui()` frame. Uses `spawn_local` (browser event-loop IO,
    /// single-threaded — NOT a CPU thread; ROOT LAW #0 concerns parallelism).
    #[cfg(target_arch = "wasm32")]
    fn poll_live(&mut self, ctx: &egui::Context) {
        if self.live.is_none() {
            return;
        }
        // (1) Kick the one-shot fetch onto the browser event loop, once.
        {
            let live = self.live.as_mut().unwrap();
            if !live.started {
                live.started = true;
                let slot = live.slot.clone();
                let server = live.server.clone();
                let token = live.token.clone();
                let ctx = ctx.clone();
                wasm_bindgen_futures::spawn_local(async move {
                    let result = async {
                        // (a) the roster — `Dwarf.History`.
                        let reply = dwarves_web_client::fetch_history(&server, &token, 50)
                            .await
                            .map_err(|s| s.message().to_string())?;
                        let runs: Vec<RunRow> = reply.runs.iter().map(map_run).collect();
                        // (b) the tree + DAG — the NEWEST run's own `Dwarf.GetRunReport`.
                        // They used to be kept from the fixture even here, so a live
                        // roster sat above a DAG the server had never seen.
                        let (tree, dag) = match reply.runs.first() {
                            Some(newest) => {
                                match dwarves_web_client::fetch_run_report(
                                    &server,
                                    &token,
                                    &newest.run_id,
                                )
                                .await
                                {
                                    Ok(report) => parts_from_report(&report),
                                    // The roster is real even when one report read fails;
                                    // an EMPTY tree/DAG beside it is the honest pairing.
                                    Err(_) => empty_parts(),
                                }
                            }
                            None => empty_parts(),
                        };
                        Ok((runs, tree, dag))
                    }
                    .await;
                    *slot.borrow_mut() = Some(result);
                    // Wake the UI so the resolved panel is applied on the next frame.
                    ctx.request_repaint();
                });
            }
        }
        // (2) Apply a resolved result exactly once (take it out of the slot).
        let taken = self.live.as_ref().unwrap().slot.borrow_mut().take();
        if let Some(result) = taken {
            match result {
                Ok((runs, tree, dag)) => {
                    let n = runs.len();
                    let live = self.live.take().unwrap();
                    let mut rebuilt =
                        Self::from_forge(runs, tree, dag, "dwarves — CI/CD forge view (live)");
                    rebuilt.live = Some(Live {
                        source: "live",
                        run_count: n,
                        started: true,
                        ..live
                    });
                    *self = rebuilt;
                }
                Err(reason) => {
                    // The panel STAYS empty — but the reason changes from "not read yet"
                    // to the server's own refusal, and `unreachable` is kept distinct
                    // from "the forge answered with zero runs".
                    let server = self.live.as_ref().unwrap().server.clone();
                    self.empty = Some(
                        EmptyState::unreachable(FORGE_FEED, format!("{server}: {reason}"))
                            .with_hint("check the forge is up and the ?token= is right"),
                    );
                    self.live.as_mut().unwrap().source = "unreachable";
                }
            }
        }
    }
}

/// **Reconstruct the tree + DAG from a run's OWN report** — the steps grouped by stage
/// (in first-seen order, so the render follows the pipeline), and the `needs` edges the
/// server sent. `disposition`/`cached` stay `None`/`false`: the `dwarf.v1` `BuildRow`
/// does not carry them, and an invented "cache-hit" is exactly the kind of detail the
/// deleted fixture supplied.
#[cfg(target_arch = "wasm32")]
fn parts_from_report(report: &dwarves_web_client::pb::RunReport) -> (TreeView, DagView) {
    let run = report.run.clone().unwrap_or_default();
    let mut stages: Vec<TreeStage> = Vec::new();
    let mut nodes: Vec<DagNode> = Vec::new();
    let mut edges: Vec<(String, String)> = Vec::new();
    for s in &report.steps {
        let step = TreeStep {
            name: s.step.clone(),
            status: s.status.clone(),
            disposition: None,
            cached: false,
        };
        match stages.iter_mut().find(|st| st.name == s.stage) {
            Some(st) => st.steps.push(step),
            None => stages.push(TreeStage {
                name: s.stage.clone(),
                steps: vec![step],
            }),
        }
        nodes.push(DagNode {
            step: s.step.clone(),
            stage: s.stage.clone(),
            status: s.status.clone(),
            disposition: None,
        });
        for need in &s.needs {
            edges.push((need.clone(), s.step.clone()));
        }
    }
    (
        TreeView {
            project: run.project.clone(),
            pipeline: run.pipeline.clone(),
            stages,
        },
        DagView { nodes, edges },
    )
}

/// Map a live wire `RunRow` (`dwarf.v1`) onto the shared `facett-brokkrview` roster row.
#[cfg(target_arch = "wasm32")]
fn map_run(r: &dwarves_web_client::pb::RunRow) -> RunRow {
    RunRow {
        run_id: r.run_id.clone(),
        pipeline: r.pipeline.clone(),
        status: r.status.clone(),
        trigger: r.trigger.clone(),
        sha: None,
        steps: format!("{}/{}", r.passed, r.steps),
        duration: format!("{:.1}s", r.duration_ms as f64 / 1000.0),
    }
}

// `ForgeView` is a thin wrapper: delegate the whole `Facet` contract (and thus the
// `Panel` seam via the blanket impl) to the shared `BrokkrView` component.
impl Facet for ForgeView {
    fn title(&self) -> &str {
        self.inner.title()
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        // Drive the live grpc-web fetch before painting (wasm web demo only).
        #[cfg(target_arch = "wasm32")]
        self.poll_live(ui.ctx());
        if let Some(e) = &self.empty {
            // No run to show ⇒ no roster, no tree, no DAG. The five `#1038`…`#1042` rows
            // that used to fill this space were a report on work nobody had done.
            e.ui(ui);
            return;
        }
        self.inner.ui(ui);
    }
    fn state_json(&self) -> serde_json::Value {
        let mut v = self.inner.state_json();
        // Fold in the live-binding oracle so a headless drive (`window.__dwarves_state()`)
        // can tell a LIVE server roster from an empty panel.
        #[cfg(target_arch = "wasm32")]
        if let (Some(obj), Some(live)) = (v.as_object_mut(), self.live.as_ref()) {
            obj.insert("live_source".into(), serde_json::json!(live.source));
            obj.insert("live_server".into(), serde_json::json!(live.server));
            obj.insert("live_run_count".into(), serde_json::json!(live.run_count));
        }
        if let (Some(obj), Some(e)) = (v.as_object_mut(), self.empty.as_ref()) {
            obj.insert("forge_empty".into(), e.state_json());
            obj.insert("feed".into(), serde_json::json!(FORGE_FEED));
        }
        v
    }
    fn update_json(&mut self, msg_json: &str) {
        self.inner.update_json(msg_json);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn forge_view_renders_the_shared_brokkr_component_over_a_dwarves_fixture() {
        let v = ForgeView::from_fixture();
        let s = v.state_json();
        // The shared component's state shape (roster + tree + DAG), fed the dwarves fixture.
        assert_eq!(s["title"], "dwarves — CI/CD forge view");
        assert_eq!(s["view"], "roster", "opens on the run roster");
        assert_eq!(s["run_count"], 5, "five recent forge runs");
        assert_eq!(s["green_count"], 3, "three passed runs");
        // The forge's own pipelines are the Run buttons.
        let pipelines = s["pipelines"].as_array().unwrap();
        assert_eq!(pipelines.len(), 5);
        assert_eq!(pipelines[0], "build");
        // The build pipeline's DAG is present and green.
        assert_eq!(s["dag"]["all_green"], true);
        assert_eq!(s["tree"]["project"], "dwarves");
    }

    /// **THE `#1038`…`#1042` ROSTER IS GONE** (audit 2026-09-10). The panel used to open
    /// on five invented runs with invented shas and durations, plus an all-green `build`
    /// tree + DAG — a report on work the forge had not done. Pins:
    ///
    ///   (a) a panel with no forge has ZERO runs and ZERO pipelines;
    ///   (b) it carries the NAMED empty state over the two `dwarf.v1` RPCs;
    ///   (c) none of the fixture's invented run ids or shas survives;
    ///   (d) a forge that ANSWERED with zero runs is a different cause (`no-rows`) from
    ///       a panel that never got to ask (`not-configured`).
    ///
    /// RED-when-broken: restore any fallback roster and (a)/(c) fail.
    #[test]
    fn a_forgeless_panel_names_its_feed_never_a_fixture() {
        let v = ForgeView::default();
        let s = v.state_json();

        // (a) + (b)
        assert_eq!(s["run_count"], 0, "no forge ⇒ no runs on the roster: {s}");
        assert_eq!(s["green_count"], 0, "…and none of them green: {s}");
        assert_eq!(s["pipelines"].as_array().map(|p| p.len()).unwrap_or(9), 0);
        assert_eq!(s["forge_empty"]["empty"], true);
        assert_eq!(
            s["forge_empty"]["feed"], FORGE_FEED,
            "the empty state NAMES both RPCs"
        );
        assert_eq!(s["forge_empty"]["cause"], "not-configured");

        // (c) the fixture's invented facts appear NOWHERE.
        let whole = s.to_string();
        for gone in [
            "#1042", "#1041", "#1040", "#1039", "#1038", "a1b2c3d4", "deadbeef", "feedface",
        ] {
            assert!(
                !whole.contains(gone),
                "the deleted fixture's `{gone}` is gone: {whole}"
            );
        }

        // (d) a forge that answered with nothing is read-and-empty, not unasked.
        let (tree, dag) = empty_parts();
        let answered = ForgeView::from_forge(Vec::new(), tree, dag, "dwarves — CI/CD forge view");
        assert_eq!(answered.state_json()["forge_empty"]["cause"], "no-rows");
    }

    #[test]
    fn forge_view_drives_through_the_json_seam() {
        // The panel is driveable headless through the same JSON `update_json` seam
        // the robot harness uses — switch to the DAG surface.
        let mut v = ForgeView::from_fixture();
        v.update_json(r#"{"set_view":"dag"}"#);
        assert_eq!(v.state_json()["view"], "dag");
    }
}