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