facett-brokkrview/src/lib.rs
//! **facett-brokkrview** — the brokkr CI/build **results** view facet.
//!
//! `facett-brokkr` (the sibling) is the *builder* — it authors a `brokkr.toml`.
//! This crate is the *renderer* of what a pipeline **did**: the facett-side egui
//! view over the pure Elm core dwarves ships in
//! `dwarves/src/brokkr/elm.rs` (`Brokkr`/`BrokkrModel`) and its secondary facets in
//! `dwarves/src/brokkr/facets.rs` (`TreeFacet`/`DagFacet`). It presents three
//! surfaces, switchable in the UI, over one serializable model:
//!
//! * **Roster** — the recent-run list (newest first) with green/red/running
//! status chips, a **Run** button per known pipeline, the selected run, and a
//! short activity log. Mirrors `BrokkrModel`.
//! * **Tree** — the project → pipeline → stage → step hierarchy, each step
//! carrying its live status chip **and** the incremental *disposition* (the
//! `CachedRunReport` taxonomy: `ran` / `cache-hit` / `unchanged` / `when-skipped`
//! / `upstream-skipped` / `failed`, plus a `cached` flag) tinted distinctly.
//! * **DAG** — the stage/step graph; nodes are laid out by longest-path depth over
//! the `need → step` edges and tinted by status (pending→passed/failed/skipped).
//!
//! ## The Elm seam (and why there is no `dwarves` dependency)
//!
//! dwarves *depends on this crate* to render its forge view, so this crate must
//! NOT depend on `dwarves` (that would be a cycle). Instead — exactly like
//! `facett-brokkr` keeps its own copy of the `brokkr.toml` schema — it defines its
//! own serializable mirror of the dwarves model and adopts a dwarves
//! `Brokkr::state_json()` payload through [`BrokkrView::from_brokkr_state`]. It
//! never depends on `nornir` either (the no-nornir-edge `facett-brokkr` keeps).
//!
//! It implements the canonical [`facett_core::Elm`] contract (state / update /
//! view / title), and [`facett_core::impl_facet_via_elm!`] bridges that to
//! [`facett_core::Facet`]; because `Facet` is a [`facett_core::Panel`] via the
//! blanket impl, **`BrokkrView` is a `Panel` for free**. The `Panel`/`Facet`
//! `update_json` mutation path is routed into the Elm `update` (see the macro
//! extra-block below), so the headless robot harness drives it through JSON
//! messages and asserts `state_json` — one wire, every backend.
use std::collections::BTreeMap;
use egui::{Align2, Color32, FontId, Sense, Stroke, Ui, vec2};
use facett_core::Elm;
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// Palette — the Tillsynia look shared with facett-brokkr.
// ─────────────────────────────────────────────────────────────────────────────
const ACCENT: Color32 = Color32::from_rgb(247, 198, 0); // Tillsynia yellow
const TEXT_COL: Color32 = Color32::from_rgb(214, 219, 228);
const EDGE: Color32 = Color32::from_rgb(150, 156, 168);
const NODE: Color32 = Color32::from_rgb(58, 62, 74);
/// The chip colour for a run/step status label (`passed`/`failed`/`running`/…).
fn status_color(status: &str) -> Color32 {
match status {
"passed" => Color32::from_rgb(120, 190, 140), // green
"failed" => Color32::from_rgb(230, 120, 120), // red
"running" => Color32::from_rgb(120, 170, 230), // blue
"skipped" => Color32::from_rgb(150, 156, 168), // grey
"pending" | "idle" => Color32::from_rgb(120, 124, 134),
_ => TEXT_COL,
}
}
/// The chip colour for an incremental disposition (`ran`/`cache-hit`/…).
fn disposition_color(disp: &str) -> Color32 {
match disp {
"ran" => Color32::from_rgb(120, 190, 140),
"cache-hit" => Color32::from_rgb(120, 170, 230),
"unchanged" => Color32::from_rgb(150, 156, 168),
"when-skipped" | "upstream-skipped" => Color32::from_rgb(180, 160, 120),
"failed" => Color32::from_rgb(230, 120, 120),
_ => EDGE,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The observable model — OUR mirror of the dwarves brokkr Elm shape.
// ─────────────────────────────────────────────────────────────────────────────
/// A row in the run roster (one past pipeline run). Mirrors dwarves' `RunRow`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RunRow {
/// Run id.
pub run_id: String,
/// Pipeline name.
pub pipeline: String,
/// Status chip label (`passed`/`failed`/`running`/`skipped`).
pub status: String,
/// Trigger label.
pub trigger: String,
/// Short sha, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
/// Steps passed / total, pre-rendered (`4/4`).
pub steps: String,
/// Duration, pre-rendered (`1.2s`).
pub duration: String,
}
/// One step row in the tree, with its live status chip + optional disposition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TreeStep {
/// Step name.
pub name: String,
/// Status chip label (`idle`/`running`/`passed`/`failed`/`skipped`).
pub status: String,
/// The incremental disposition (`ran`/`cache-hit`/…), when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition: Option<String>,
/// Whether the step was served from the action cache.
#[serde(default)]
pub cached: bool,
}
/// One stage grouping steps.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TreeStage {
/// Stage name.
pub name: String,
/// The stage's steps.
pub steps: Vec<TreeStep>,
}
/// The project → pipeline → stage → step hierarchy. Mirrors dwarves' `TreeFacet`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TreeView {
/// The project the pipeline lives in.
pub project: String,
/// The pipeline name.
pub pipeline: String,
/// The stages, each with its steps.
pub stages: Vec<TreeStage>,
}
/// One node in the DAG view. Mirrors dwarves' `DagFacetNode`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DagNode {
/// Step name (the node id).
pub step: String,
/// Owning stage.
pub stage: String,
/// Status (`pending`/`running`/`passed`/`failed`/`skipped`).
pub status: String,
/// The incremental disposition, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition: Option<String>,
}
/// The stage/step graph. Mirrors dwarves' `DagFacet` (`need → step` edges).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct DagView {
/// Nodes in topo order.
pub nodes: Vec<DagNode>,
/// `need → step` edges (the transit lines).
pub edges: Vec<(String, String)>,
}
impl DagView {
/// Whether every node reached `passed` (the all-green verdict).
pub fn all_green(&self) -> bool {
!self.nodes.is_empty() && self.nodes.iter().all(|n| n.status == "passed")
}
}
/// Which results surface is active.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
/// The recent-run roster + Run buttons + log.
#[default]
Roster,
/// The project → pipeline → stage → step tree.
Tree,
/// The stage/step DAG.
Dag,
}
/// The complete observable state of the brokkr results view (facett FC-1/FC-3).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BrokkrViewModel {
/// The active surface.
pub view: ViewMode,
/// The pipelines this facet knows about (the Run buttons).
pub pipelines: Vec<String>,
/// The run roster, newest first.
pub runs: Vec<RunRow>,
/// The currently selected run, if any.
pub selected: Option<String>,
/// A short activity log (newest last).
pub log: Vec<String>,
/// The tree surface.
pub tree: TreeView,
/// The DAG surface.
pub dag: DagView,
#[serde(default)]
pub nav: facett_core::nav::Navigable,
#[serde(default)]
pub fitted: bool,
#[serde(default)]
pub selected_step: Option<String>,
/// The camera flight under way (🎯 Focus / Fit), UI time — never serialized.
#[serde(skip)]
pub flight: Option<facett_core::nav::NavFlight>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Messages + effects — the FC-2 / FC-8 seam.
// ─────────────────────────────────────────────────────────────────────────────
/// A finished run fed back by the host after a [`BrokkrViewEffect::RunPipeline`]
/// (or replayed by the robot harness). Carries enough to grow the roster **and**
/// fold the per-step statuses/dispositions into the tree + DAG.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FinishedRun {
/// Run id.
pub run_id: String,
/// Pipeline name.
pub pipeline: String,
/// Overall status (`passed`/`failed`/`running`/`skipped`).
#[serde(default = "default_status")]
pub status: String,
/// Trigger label.
#[serde(default)]
pub trigger: String,
/// Short sha, if any.
#[serde(default)]
pub sha: Option<String>,
/// Steps that passed.
#[serde(default)]
pub passed: usize,
/// Total steps.
#[serde(default)]
pub total: usize,
/// Duration in milliseconds.
#[serde(default)]
pub duration_ms: u64,
/// step name → status label, folded into the tree + DAG.
#[serde(default)]
pub steps: BTreeMap<String, String>,
/// step name → disposition (`ran`/`cache-hit`/…), folded into the tree + DAG.
#[serde(default)]
pub dispositions: BTreeMap<String, String>,
/// step names served from the action cache (set the `cached` flag).
#[serde(default)]
pub cached: Vec<String>,
}
fn default_status() -> String {
"passed".to_string()
}
impl FinishedRun {
fn to_run_row(&self) -> RunRow {
RunRow {
run_id: self.run_id.clone(),
pipeline: self.pipeline.clone(),
status: self.status.clone(),
trigger: self.trigger.clone(),
sha: self.sha.as_ref().map(|s| s.chars().take(8).collect()),
steps: format!("{}/{}", self.passed, self.total),
duration: format!("{:.1}s", self.duration_ms as f64 / 1000.0),
}
}
}
/// Every input the view accepts (facett FC-2). Serde-tagged so a JSON message
/// (`{"run":"build"}`, `{"set_view":"tree"}`, `{"run_finished":{…}}`) drives the
/// same [`update`](BrokkrView::update) the UI calls — the `update_json` seam.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrokkrViewMsg {
/// Select a run row (by id).
SelectRun(String),
/// Note a pipeline as the focus (roster log breadcrumb).
SelectPipeline(String),
/// Press "Run" for a pipeline.
Run(String),
/// Switch the active surface.
SetView(ViewMode),
/// The host reports a run finished.
RunFinished(FinishedRun),
SelectStep(String),
Pan(egui::Vec2),
Zoom {
factor: f32,
anchor: egui::Pos2,
},
Fit(egui::Rect),
RequestRefit,
/// 🎯 Focus: fly step `step` to the centre of the visible DAG canvas.
FocusStep { step: String, now: f64 },
/// Fit as a flight: the whole DAG, centred in the visible canvas.
FitFly(f64),
/// Advance a camera flight to `now`.
FlyTick(f64),
}
/// Side work as data (facett FC-8): the host runs the actual pipeline and feeds the
/// result back as [`BrokkrViewMsg::RunFinished`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrokkrViewEffect {
/// Run this pipeline, then send `RunFinished`.
RunPipeline(String),
}
// ─────────────────────────────────────────────────────────────────────────────
// The view.
// ─────────────────────────────────────────────────────────────────────────────
/// The brokkr results view facet: the Elm pure core (state + update) plus the
/// egui render. A [`facett_core::Panel`] via the blanket impl.
#[derive(Clone)]
pub struct BrokkrView {
title: String,
model: BrokkrViewModel,
scene: std::sync::Arc<facett_flow::FlowScene>,
/// The DAG canvas, tracked: its visible part (a fit centres on it) and its moves
/// (the screen-space camera follows the canvas).
track: facett_core::nav::CanvasTrack,
}
impl std::fmt::Debug for BrokkrView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrokkrView")
.field("title", &self.title)
.field("model", &self.model)
.finish()
}
}
impl PartialEq for BrokkrView {
fn eq(&self, other: &Self) -> bool {
self.title == other.title && self.model == other.model
}
}
impl BrokkrView {
/// A view seeded with the known pipelines and the tree/DAG surfaces (no runs).
/// **Where the DAG is on screen**: the scene's node boxes through the camera —
/// what a robot compares with [`seen_rect`](Self::seen_rect).
pub fn screen_box(&self) -> Option<egui::Rect> {
let b = self.scene.bounds();
if !(b.width() > 0.0 || b.height() > 0.0) {
return None;
}
let nav = self.model.nav;
Some(egui::Rect::from_two_pos(nav.to_screen(b.min), nav.to_screen(b.max)))
}
/// Where step `step` is on screen now (its node centre through the camera).
pub fn step_screen_pos(&self, step: &str) -> Option<egui::Pos2> {
let i = self.model.dag.nodes.iter().position(|n| n.step == step)?;
let n = self.scene.graph().nodes.get(i)?;
Some(self.model.nav.to_screen(egui::Pos2::new(n.position[0], n.position[1])))
}
/// The part of the last DAG canvas the viewer saw (canvas ∩ clip).
pub fn seen_rect(&self) -> Option<egui::Rect> {
self.track.seen()
}
pub fn new(
title: impl Into<String>,
pipelines: Vec<String>,
tree: TreeView,
dag: DagView,
) -> Self {
Self {
title: title.into(),
scene: std::sync::Arc::new(Self::build_scene(&dag)),
track: Default::default(),
model: BrokkrViewModel {
view: ViewMode::Roster,
pipelines,
runs: Vec::new(),
selected: None,
log: Vec::new(),
tree,
dag,
nav: Default::default(),
fitted: false,
selected_step: None,
flight: None,
},
}
}
/// Adopt a dwarves `Brokkr::state_json()` payload — the roster wire
/// (`{ pipelines, runs, selected, log }`). Missing/oddly-typed fields default
/// to empty, so a partial or evolving payload never panics. This is the
/// backend-(2) native cross-host seam: dwarves' service hands its
/// `Brokkr::state_json()` across, this view renders it.
pub fn from_brokkr_state(title: impl Into<String>, state: &serde_json::Value) -> Self {
let pipelines = state
.get("pipelines")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let runs = state
.get("runs")
.cloned()
.and_then(|v| serde_json::from_value::<Vec<RunRow>>(v).ok())
.unwrap_or_default();
let selected = state
.get("selected")
.and_then(|v| v.as_str().map(str::to_string));
let log = state
.get("log")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Self {
scene: std::sync::Arc::new(Self::build_scene(&DagView::default())),
track: Default::default(),
title: title.into(),
model: BrokkrViewModel {
view: ViewMode::Roster,
pipelines,
runs,
selected,
log,
tree: TreeView::default(),
dag: DagView::default(),
nav: Default::default(),
fitted: false,
selected_step: None,
flight: None,
},
}
}
/// A ready-made demo (a `build → test → ship` pipeline with a green run
/// already folded into the tree + DAG) so the surface is never blank — the
/// same shape the dwarves Brokkr tab ships.
pub fn demo(title: impl Into<String>) -> Self {
let pipelines = vec!["nornir-ci".to_string()];
let tree = TreeView {
project: "nornir".to_string(),
pipeline: "nornir-ci".to_string(),
stages: vec![
TreeStage {
name: "build".to_string(),
steps: vec![TreeStep {
name: "compile".to_string(),
status: "passed".to_string(),
disposition: Some("ran".to_string()),
cached: false,
}],
},
TreeStage {
name: "test".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: "ship".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: "compile".into(),
stage: "build".into(),
status: "passed".into(),
disposition: Some("ran".into()),
},
DagNode {
step: "unit".into(),
stage: "test".into(),
status: "passed".into(),
disposition: Some("cache-hit".into()),
},
DagNode {
step: "lint".into(),
stage: "test".into(),
status: "passed".into(),
disposition: Some("unchanged".into()),
},
DagNode {
step: "release".into(),
stage: "ship".into(),
status: "passed".into(),
disposition: Some("ran".into()),
},
],
edges: vec![
("compile".into(), "unit".into()),
("compile".into(), "lint".into()),
("unit".into(), "release".into()),
("lint".into(), "release".into()),
],
};
let mut v = Self::new(title, pipelines, tree, dag);
v.model.runs = vec![
RunRow {
run_id: "run-3".into(),
pipeline: "nornir-ci".into(),
status: "passed".into(),
trigger: "push".into(),
sha: Some("a1b2c3d4".into()),
steps: "4/4".into(),
duration: "12.4s".into(),
},
RunRow {
run_id: "run-2".into(),
pipeline: "nornir-ci".into(),
status: "failed".into(),
trigger: "manual".into(),
sha: Some("deadbeef".into()),
steps: "2/4".into(),
duration: "3.1s".into(),
},
RunRow {
run_id: "run-1".into(),
pipeline: "nornir-ci".into(),
status: "passed".into(),
trigger: "push".into(),
sha: None,
steps: "4/4".into(),
duration: "11.9s".into(),
},
];
v.model.selected = Some("run-3".into());
v.model.log = vec!["run 'nornir-ci' passed — 4/4 steps".to_string()];
v
}
/// Seed the run roster (newest first) and pre-select the newest — the builder
/// form a host uses to compose a full fixture (pipelines + tree + DAG via
/// [`new`](Self::new), then the recent runs here). Additive over [`new`](Self::new).
pub fn with_runs(mut self, runs: Vec<RunRow>) -> Self {
self.model.selected = runs.first().map(|r| r.run_id.clone());
self.model.runs = runs;
self
}
/// Read the complete observable state.
pub fn model(&self) -> &BrokkrViewModel {
&self.model
}
/// Count of green (`passed`) runs in the roster.
pub fn green_count(&self) -> usize {
self.model
.runs
.iter()
.filter(|r| r.status == "passed")
.count()
}
/// Fold a finished run's per-step statuses + dispositions into the tree.
fn fold_into_tree(&mut self, f: &FinishedRun) {
for stage in &mut self.model.tree.stages {
for step in &mut stage.steps {
if let Some(status) = f.steps.get(&step.name) {
step.status = status.clone();
}
if let Some(disp) = f.dispositions.get(&step.name) {
step.disposition = Some(disp.clone());
}
if f.cached.iter().any(|c| c == &step.name) {
step.cached = true;
}
}
}
}
/// Fold a finished run's per-step statuses + dispositions into the DAG.
fn fold_into_dag(&mut self, f: &FinishedRun) {
for node in &mut self.model.dag.nodes {
if let Some(status) = f.steps.get(&node.step) {
node.status = status.clone();
}
if let Some(disp) = f.dispositions.get(&node.step) {
node.disposition = Some(disp.clone());
}
}
}
// ── the render surfaces (pure — paint + collect Msgs) ───────────────────────
fn roster_ui(&self, ui: &mut Ui, msgs: &mut Vec<BrokkrViewMsg>) {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("Run:").strong());
for p in &self.model.pipelines {
if ui.button(format!("▶ {p}")).clicked() {
msgs.push(BrokkrViewMsg::Run(p.clone()));
}
}
ui.separator();
ui.label(
egui::RichText::new(format!(
"{} green / {} runs",
self.green_count(),
self.model.runs.len()
))
.small()
.color(status_color("passed")),
);
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
if self.model.runs.is_empty() {
ui.weak("No runs yet — press a Run button above.");
}
for r in &self.model.runs {
let selected = self.model.selected.as_deref() == Some(r.run_id.as_str());
ui.horizontal(|ui| {
// status chip
ui.label(
egui::RichText::new(&r.status)
.small()
.strong()
.color(status_color(&r.status)),
);
let label =
format!("{} {} {} {}", r.run_id, r.pipeline, r.steps, r.duration);
if ui.selectable_label(selected, label).clicked() {
msgs.push(BrokkrViewMsg::SelectRun(r.run_id.clone()));
}
});
}
});
if !self.model.log.is_empty() {
ui.add_space(6.0);
ui.separator();
ui.label(egui::RichText::new("log").strong().color(ACCENT));
for line in &self.model.log {
ui.label(
egui::RichText::new(format!("• {line}"))
.small()
.color(TEXT_COL),
);
}
}
}
fn tree_ui(&self, ui: &mut Ui) {
let t = &self.model.tree;
ui.label(
egui::RichText::new(format!("{} · {}", t.project, t.pipeline))
.strong()
.color(ACCENT),
);
ui.separator();
if t.stages.is_empty() {
ui.weak("No tree — no pipeline loaded.");
return;
}
egui::ScrollArea::vertical().show(ui, |ui| {
for stage in &t.stages {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(
egui::RichText::new(format!("▸ {}", stage.name))
.strong()
.color(TEXT_COL),
);
for step in &stage.steps {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&step.status)
.small()
.strong()
.color(status_color(&step.status)),
);
ui.label(egui::RichText::new(&step.name).color(TEXT_COL));
if let Some(disp) = &step.disposition {
ui.label(
egui::RichText::new(disp)
.small()
.color(disposition_color(disp)),
);
}
if step.cached {
ui.label(
egui::RichText::new("⏺ cached")
.small()
.color(status_color("running")),
);
}
});
}
});
}
});
}
fn build_scene(dag: &DagView) -> facett_flow::FlowScene {
let ids: BTreeMap<_, _> = dag
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.step.as_str(), i as u32))
.collect();
let edges: Vec<_> = dag
.edges
.iter()
.filter_map(|(a, b)| Some((*ids.get(a.as_str())?, *ids.get(b.as_str())?)))
.collect();
let layout = facett_core::layered::layered(dag.nodes.len(), &edges, Default::default());
let positions = facett_core::layered::place(&layout, [210.0, 80.0], [0.0, 0.0]);
let graph = facett_flow::Graph {
nodes: dag
.nodes
.iter()
.enumerate()
.map(|(i, n)| facett_flow::Node {
id: format!("step{i}"),
label: n.step.clone(),
position: positions[i],
group: if n.stage.trim().is_empty() {
"steps".into()
} else {
n.stage.clone()
},
kind: 1,
})
.collect(),
edges: edges
.iter()
.map(|&(a, b)| facett_flow::Edge {
from: format!("step{a}"),
to: format!("step{b}"),
})
.collect(),
};
let mut scene = facett_flow::FlowScene::new(graph).expect("bounded layered DAG");
scene.set_arrows(true);
Self::apply_status(dag, &mut scene);
scene
}
fn apply_status(dag: &DagView, scene: &mut facett_flow::FlowScene) {
scene
.set_status(facett_flow::attr::StatusTable {
nodes: dag
.nodes
.iter()
.map(|n| match n.status.as_str() {
"passed" => Some(facett_flow::attr::Status::Ok),
"failed" => Some(facett_flow::attr::Status::Error),
_ => None,
})
.collect(),
edges: vec![None; scene.graph().edges.len()],
})
.expect("DAG status ordering");
}
pub fn scene_generation(&self) -> u64 {
self.scene.generation()
}
fn dag_ui(&self, ui: &mut Ui, msgs: &mut Vec<BrokkrViewMsg>) {
let dag = &self.model.dag;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(if dag.all_green() {
"DAG · all green"
} else {
"DAG"
})
.strong()
.color(if dag.all_green() {
status_color("passed")
} else {
ACCENT
}),
);
if ui.button("Fit").clicked() {
msgs.push(match self.track.seen() {
Some(_) => BrokkrViewMsg::FitFly(ui.input(|i| i.time)),
None => BrokkrViewMsg::RequestRefit,
});
}
});
ui.separator();
if dag.nodes.is_empty() {
ui.weak("No DAG — no pipeline loaded.");
return;
}
let (rect, resp) = ui.allocate_exact_size(
{
let avail = facett_core::nav::canvas_size(ui);
vec2(avail.x.max(240.0), avail.y.max(180.0))
},
Sense::click_and_drag(),
);
let mut nav = self.model.nav;
// First paint: fit, CENTRED in the part of the canvas the viewer sees. After
// that the screen-space camera follows the canvas when it moves.
let (seen, moved) = self.track.frame(ui, rect);
let now = ui.input(|i| i.time);
if !self.model.fitted {
nav.fit(self.scene.bounds().expand(60.0), seen, 0.08);
msgs.push(BrokkrViewMsg::Fit(seen));
} else if moved != egui::Vec2::ZERO {
nav.pan(moved);
msgs.push(BrokkrViewMsg::Pan(moved));
}
if self.model.flight.is_some() {
msgs.push(BrokkrViewMsg::FlyTick(now));
ui.ctx().request_repaint();
}
let focus = facett_core::fly::NavMode::remembered(ui.ctx()) == facett_core::fly::NavMode::Focus;
for gesture in facett_core::nav::gestures(ui, rect, &resp) {
msgs.push(match gesture {
facett_core::nav::NavGesture::Pan(delta) => BrokkrViewMsg::Pan(delta),
facett_core::nav::NavGesture::Zoom { factor, anchor } => {
BrokkrViewMsg::Zoom { factor, anchor }
}
facett_core::nav::NavGesture::Fit => BrokkrViewMsg::Fit(rect),
});
}
let view = facett_flow::View::from_navigable(&nav, rect);
let mut palette = facett_flow::FlowPalette::from_theme(&facett_core::theme(ui));
palette.middle = NODE;
palette.edge = EDGE;
palette.status.ok = status_color("passed");
palette.status.error = status_color("failed");
let painted = facett_flow::paint(
ui,
ui.id().with("brokkrview-flow"),
rect,
&self.scene,
&view,
&facett_flow::PaintOpts {
palette: Some(palette),
animation: facett_flow::Animation::None,
cpu: facett_flow::CpuStyle::Full {
max_edges: usize::MAX,
},
..Default::default()
},
);
let painter = ui.painter_at(rect);
let base_id = ui.id().with("dag-step");
let focused = ui.memory(|memory| memory.focused());
let priority: Vec<usize> = painted
.visible_nodes
.iter()
.filter_map(|shown| {
let step = &dag.nodes[shown.node].step;
(self.model.selected_step.as_deref() == Some(step.as_str())
|| focused == Some(facett_core::a11y::stable_id(base_id, step)))
.then_some(shown.node)
})
.collect();
let mut semantic_slots = 2_000usize.saturating_sub(priority.len());
for shown in &painted.visible_nodes {
if !priority.contains(&shown.node) {
if semantic_slots == 0 {
continue;
}
semantic_slots -= 1;
}
let n = &dag.nodes[shown.node];
let point = shown.rect.center();
let label_rect =
egui::Rect::from_min_size(point + vec2(12.0, -20.0), vec2(185.0, 40.0))
.intersect(rect);
let sub = match &n.disposition {
Some(d) => format!("{} · {} · {}", n.stage, n.status, d),
None => format!("{} · {}", n.stage, n.status),
};
painter.text(
point + vec2(13.0, -7.0),
Align2::LEFT_CENTER,
&n.step,
FontId::proportional(13.0),
TEXT_COL,
);
painter.text(
point + vec2(13.0, 9.0),
Align2::LEFT_CENTER,
&sub,
FontId::proportional(10.0),
EDGE,
);
let selected = self.model.selected_step.as_deref() == Some(n.step.as_str());
if selected {
painter.circle_stroke(point, 12.0, Stroke::new(2.0, ACCENT));
}
let node = facett_core::a11y::node(
ui,
base_id,
&n.step,
Sense::click(),
label_rect,
facett_core::a11y::Semantics::button(format!("{} · {}", n.step, sub))
.selected(selected),
);
if node.clicked() {
if focus {
msgs.push(BrokkrViewMsg::FocusStep { step: n.step.clone(), now });
}
msgs.push(BrokkrViewMsg::SelectStep(n.step.clone()));
}
}
// Pointer selection has no label budget: tiny/distant nodes remain pickable.
if resp.clicked()
&& let Some(pointer) = resp.interact_pointer_pos()
&& let Some(facett_flow::Hit::Node(i)) =
self.scene.hit(view.world(pointer), 8.0 / view.scale)
{
if focus {
msgs.push(BrokkrViewMsg::FocusStep { step: dag.nodes[i].step.clone(), now });
}
msgs.push(BrokkrViewMsg::SelectStep(dag.nodes[i].step.clone()));
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Elm contract + the Facet/Panel bridge.
// ─────────────────────────────────────────────────────────────────────────────
impl Elm for BrokkrView {
type Model = BrokkrViewModel;
type Msg = BrokkrViewMsg;
type Effect = BrokkrViewEffect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &BrokkrViewModel {
&self.model
}
fn update(&mut self, msg: BrokkrViewMsg) -> Vec<BrokkrViewEffect> {
match msg {
BrokkrViewMsg::SelectStep(step) => {
if self.model.dag.nodes.iter().any(|n| n.step == step) {
self.model.selected_step = Some(step);
}
Vec::new()
}
BrokkrViewMsg::Pan(delta) => {
self.model.flight = None;
if delta.is_finite() {
self.model.nav.pan(delta);
self.model.fitted = true;
}
Vec::new()
}
BrokkrViewMsg::Zoom { factor, anchor } => {
self.model.flight = None;
if factor.is_finite() && factor > 0.0 && anchor.is_finite() {
self.model.nav.zoom_to(factor, anchor);
self.model.fitted = true;
}
Vec::new()
}
BrokkrViewMsg::Fit(rect) => {
self.model
.nav
.fit(self.scene.bounds().expand(60.0), rect, 0.08);
self.model.fitted = true;
Vec::new()
}
BrokkrViewMsg::FocusStep { step, now } => {
let i = self.model.dag.nodes.iter().position(|n| n.step == step);
let p = i.and_then(|i| self.scene.graph().nodes.get(i)).map(|n| egui::Pos2::new(n.position[0], n.position[1]));
if let (Some(p), Some(seen)) = (p, self.track.seen()) {
let scale = self.model.nav.scale.max(1.0).min(self.model.nav.max_scale);
self.model.flight = Some(facett_core::nav::NavFlight::to(&self.model.nav, self.model.flight, p, scale, seen, now));
}
Vec::new()
}
BrokkrViewMsg::FitFly(now) => {
if let Some(seen) = self.track.seen() {
let bb = self.scene.bounds().expand(60.0);
self.model.flight = Some(facett_core::nav::NavFlight::fit(&self.model.nav, self.model.flight, bb, seen, 0.08, now));
self.model.fitted = true;
}
Vec::new()
}
BrokkrViewMsg::FlyTick(now) => {
if let Some(f) = self.model.flight {
if !f.tick(&mut self.model.nav, now) {
self.model.flight = None;
}
}
Vec::new()
}
BrokkrViewMsg::RequestRefit => {
self.model.fitted = false;
Vec::new()
}
BrokkrViewMsg::SelectRun(id) => {
if self.model.runs.iter().any(|r| r.run_id == id) {
self.model.selected = Some(id);
}
Vec::new()
}
BrokkrViewMsg::SelectPipeline(name) => {
self.model.log.push(format!("focus pipeline '{name}'"));
Vec::new()
}
BrokkrViewMsg::Run(name) => {
if !self.model.pipelines.contains(&name) {
self.model
.log
.push(format!("refused: unknown pipeline '{name}'"));
return Vec::new();
}
self.model.log.push(format!("running '{name}'…"));
vec![BrokkrViewEffect::RunPipeline(name)]
}
BrokkrViewMsg::SetView(view) => {
self.model.view = view;
Vec::new()
}
BrokkrViewMsg::RunFinished(f) => {
self.model.log.push(format!(
"run '{}' {} — {}/{} steps",
f.pipeline, f.status, f.passed, f.total
));
let row = f.to_run_row();
self.model.selected = Some(row.run_id.clone());
self.model.runs.insert(0, row);
self.fold_into_tree(&f);
self.fold_into_dag(&f);
Self::apply_status(&self.model.dag, std::sync::Arc::make_mut(&mut self.scene));
Vec::new()
}
}
}
fn view(&self, ui: &mut Ui) -> Vec<BrokkrViewMsg> {
let mut msgs = Vec::new();
// Surface toggles.
ui.horizontal(|ui| {
for (mode, label) in [
(ViewMode::Roster, "▤ Roster"),
(ViewMode::Tree, "▸ Tree"),
(ViewMode::Dag, "◈ DAG"),
] {
if ui
.selectable_label(self.model.view == mode, label)
.clicked()
{
msgs.push(BrokkrViewMsg::SetView(mode));
}
}
});
ui.separator();
match self.model.view {
ViewMode::Roster => self.roster_ui(ui, &mut msgs),
ViewMode::Tree => self.tree_ui(ui),
ViewMode::Dag => self.dag_ui(ui, &mut msgs),
}
msgs
}
}
// The Elm→Facet bridge, with a RICHER `state_json` (the model plus derived
// introspection keys the deck/matrix/robot read) and the `update_json` mutation
// path routed into the Elm `update` (so `Panel`/headless drive works through JSON).
facett_core::impl_facet_via_elm!(BrokkrView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"view": self.model.view,
"pipelines": self.model.pipelines,
"runs": self.model.runs,
"selected": self.model.selected,
"log": self.model.log,
"tree": self.model.tree,
"dag": {
"nodes": self.model.dag.nodes,
"edges": self.model.dag.edges,
"all_green": self.model.dag.all_green(),
},
"green_count": self.green_count(),
"run_count": self.model.runs.len(),
"nav": self.model.nav, "fitted": self.model.fitted, "selected_step": self.model.selected_step,
})
}
fn selection_json(&self) -> serde_json::Value {
serde_json::to_value(if self.model.view == ViewMode::Dag {
&self.model.selected_step
} else {
&self.model.selected
})
.unwrap_or_default()
}
fn caps(&self) -> facett_core::FacetCaps {
facett_core::FacetCaps::NONE
.themeable()
.resizable()
.selectable()
.navigable()
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<BrokkrViewMsg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn demo_is_green_and_well_formed() {
let v = BrokkrView::demo("Brokkr");
assert_eq!(v.model().tree.stages.len(), 3);
assert_eq!(v.model().dag.nodes.len(), 4);
assert_eq!(v.model().dag.edges.len(), 4);
assert!(v.model().dag.all_green(), "the demo DAG is all green");
assert_eq!(v.green_count(), 2, "two passed runs in the demo roster");
}
#[test]
fn run_emits_run_pipeline_effect() {
let mut v = BrokkrView::demo("Brokkr");
let fx = v.update(BrokkrViewMsg::Run("nornir-ci".into()));
assert_eq!(fx, vec![BrokkrViewEffect::RunPipeline("nornir-ci".into())]);
// an unknown pipeline is refused (no effect), logged.
let none = v.update(BrokkrViewMsg::Run("ghost".into()));
assert!(none.is_empty());
assert!(v.model().log.iter().any(|l| l.contains("refused")));
}
#[test]
fn run_finished_grows_roster_and_folds_status_into_tree_and_dag() {
// Start from a fresh (non-demo) view where the tree/dag are pending.
let tree = TreeView {
project: "p".into(),
pipeline: "ci".into(),
stages: vec![TreeStage {
name: "build".into(),
steps: vec![
TreeStep {
name: "compile".into(),
status: "idle".into(),
disposition: None,
cached: false,
},
TreeStep {
name: "test".into(),
status: "idle".into(),
disposition: None,
cached: false,
},
],
}],
};
let dag = DagView {
nodes: vec![
DagNode {
step: "compile".into(),
stage: "build".into(),
status: "pending".into(),
disposition: None,
},
DagNode {
step: "test".into(),
stage: "build".into(),
status: "pending".into(),
disposition: None,
},
],
edges: vec![("compile".into(), "test".into())],
};
let mut v = BrokkrView::new("Brokkr", vec!["ci".into()], tree, dag);
assert!(!v.model().dag.all_green());
let mut steps = BTreeMap::new();
steps.insert("compile".to_string(), "passed".to_string());
steps.insert("test".to_string(), "passed".to_string());
let mut dispositions = BTreeMap::new();
dispositions.insert("compile".to_string(), "ran".to_string());
dispositions.insert("test".to_string(), "cache-hit".to_string());
v.update(BrokkrViewMsg::RunFinished(FinishedRun {
run_id: "run-1".into(),
pipeline: "ci".into(),
status: "passed".into(),
trigger: "manual".into(),
sha: Some("cafebabe1234".into()),
passed: 2,
total: 2,
duration_ms: 1200,
steps,
dispositions,
cached: vec!["test".into()],
}));
assert_eq!(v.model().runs.len(), 1);
assert_eq!(v.model().runs[0].steps, "2/2");
assert_eq!(v.model().runs[0].duration, "1.2s");
assert_eq!(
v.model().runs[0].sha.as_deref(),
Some("cafebabe"),
"sha truncated to 8"
);
assert_eq!(v.model().selected.as_deref(), Some("run-1"));
// status folded into the tree
assert_eq!(v.model().tree.stages[0].steps[0].status, "passed");
assert_eq!(
v.model().tree.stages[0].steps[1].disposition.as_deref(),
Some("cache-hit")
);
assert!(
v.model().tree.stages[0].steps[1].cached,
"test marked cached"
);
// and into the DAG → all green now
assert!(
v.model().dag.all_green(),
"DAG greens after the run folds in"
);
}
#[test]
fn set_view_switches_the_surface() {
let mut v = BrokkrView::demo("Brokkr");
assert_eq!(v.model().view, ViewMode::Roster);
v.update(BrokkrViewMsg::SetView(ViewMode::Dag));
assert_eq!(v.model().view, ViewMode::Dag);
}
#[test]
fn select_run_only_accepts_a_known_run() {
let mut v = BrokkrView::demo("Brokkr");
v.update(BrokkrViewMsg::SelectRun("run-1".into()));
assert_eq!(v.model().selected.as_deref(), Some("run-1"));
v.update(BrokkrViewMsg::SelectRun("ghost".into()));
assert_eq!(
v.model().selected.as_deref(),
Some("run-1"),
"unknown run is ignored"
);
}
#[test]
fn from_brokkr_state_adopts_the_roster_wire() {
// The exact shape dwarves' `Brokkr::state_json()` emits.
let wire = serde_json::json!({
"pipelines": ["build", "test"],
"runs": [
{ "run_id": "r2", "pipeline": "build", "status": "passed", "trigger": "push", "sha": "abcd1234", "steps": "4/4", "duration": "2.0s" },
{ "run_id": "r1", "pipeline": "test", "status": "failed", "trigger": "manual", "steps": "1/3", "duration": "0.5s" }
],
"selected": "r2",
"log": ["run 'build' passed — 4/4 steps"]
});
let v = BrokkrView::from_brokkr_state("Brokkr", &wire);
assert_eq!(
v.model().pipelines,
vec!["build".to_string(), "test".to_string()]
);
assert_eq!(v.model().runs.len(), 2);
assert_eq!(v.model().runs[1].sha, None, "missing sha stays None");
assert_eq!(v.model().selected.as_deref(), Some("r2"));
assert_eq!(v.green_count(), 1);
}
#[test]
fn from_brokkr_state_is_lenient_about_missing_fields() {
// An empty payload must not panic — everything defaults empty.
let v = BrokkrView::from_brokkr_state("Brokkr", &serde_json::json!({}));
assert!(v.model().pipelines.is_empty());
assert!(v.model().runs.is_empty());
assert!(v.model().selected.is_none());
}
}