facett-fleet-view/src/lib.rs
//! **facett-fleet-view** — the **fleet coverage board**: a full-system-autotest
//! run rendered as a live board. **Rows = fleet members** (the apps/containers:
//! `nornir`, `dwarves`, `holger`, `korp`, `ordning`, …); **columns = driven
//! surfaces/tabs** (the viz tabs × {fat/egui, thin/web} legs); each cell green
//! (passed) / red (failed) / grey (not-yet-driven), under a prominent
//! **`Gap == ∅` verdict banner**.
//!
//! ## A PURE VIEW over the shared coverage model
//!
//! It takes a [`FleetRunView`] — a minimal input struct over
//! `nornir-testmatrix`'s own [`GateReport`] + [`TestResultRow`]s — and renders. It
//! does **not** run containers, drive robots, or depend on the fleet EXECUTOR
//! (no jera / draupnir here); types flow one way, exactly the engine-agnostic
//! `facett-releaseview` pattern. The coverage/verdict types are **consumed** from
//! `nornir-testmatrix`; this crate invents no parallel coverage model, and the
//! per-member × per-surface grid is the fleet analogue of the `facett-matrix`
//! per-repo × per-aspect grid (same [`Facet`] + `state_json` idiom).
//!
//! ## The verdict banner (honest `Gap == ∅`)
//!
//! The banner is derived from the [`GateReport`] directly: **GREEN** "fleet fully
//! covered" when `report.is_green()` (Gap == ∅ AND no stale allowlist), **RED**
//! listing `gap.missing` when not — the exact `missing == [viz_tab:chat@thin]`
//! semantics the fleet tests assert.
//!
//! ## FC contract (the canonical Elm split)
//! - **[`FleetRunView`]** — the complete observable, serializable,
//! round-trippable state (the report + rows + declared members + the drilled-in
//! selection). The live [`model::FleetBoard`] is *derived* from it.
//! - **[`Msg`] + [`FleetView::update`]** — the single mutation path (drill into /
//! clear a member), the same gesture the canvas row-click drives.
//! - **[`FleetView::view`]** — a **pure** paint that returns the [`Msg`]s the
//! interactions produced.
//! - **Rich `state_json`** — the board dims + gap verdict + per-cell verdicts a
//! driver reads are *derived* from [`FleetView::board`], so the macro is
//! invoked in **form 3** (`custom_state_json`).
pub mod model;
pub use model::{CellVerdict, FleetBoard, FleetMember, MemberHealth, MemberRow};
pub use nornir_testmatrix::{status, GateReport, TestResultRow};
use facett_core::{FacetCaps, Semantics};
use nornir_testmatrix::coverage::Allowlist;
use nornir_testmatrix::discover::{viz_tabs, Surface};
use serde::{Deserialize, Serialize};
/// Red used for a failing cell / the RED verdict banner (matches `facett-matrix`).
const RED: egui::Color32 = facett_core::look::RED;
/// A robot-/CLI-addressable control message — the named boundary a headless
/// driver drives the board through, the same effect a member-row click produces.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Drill into a member's row by name (the member-row click).
SelectMember(String),
/// Clear the drill-in selection.
ClearSelection,
}
/// Side work as data — the board does no I/O, so this is uninhabited on purpose:
/// the type-checked statement that [`FleetView::update`] never asks the host to
/// do anything (a PURE view — no executor).
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable state**: the fleet-run inputs (a [`GateReport`] +
/// the result rows + the declared members with their spin/health) plus the
/// drilled-in selection. The live [`FleetBoard`] is *derived* from this.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct FleetRunView {
/// The coverage gate verdict — the source of the `Gap == ∅` banner.
pub report: GateReport,
/// The per-drive result rows (member = `repo`, surface = `aspect`).
pub rows: Vec<TestResultRow>,
/// Declared members + spin/health (so a dead/never-driven member still rows).
#[serde(default)]
pub members: Vec<FleetMember>,
/// The drilled-in member, if any.
#[serde(default)]
pub selected: Option<String>,
}
/// The fleet coverage-board Facet: holds a [`FleetRunView`], derives the
/// [`FleetBoard`], and paints the `Gap == ∅` banner + the coloured member ×
/// surface grid. The whole board is in `state_json`.
pub struct FleetView {
title: String,
state: FleetRunView,
}
impl FleetView {
/// An empty board titled `title`.
pub fn new(title: impl Into<String>) -> Self {
Self { title: title.into(), state: FleetRunView::default() }
}
/// Build over a report + rows + declared members.
pub fn with_run(mut self, report: GateReport, rows: Vec<TestResultRow>, members: Vec<FleetMember>) -> Self {
self.state.report = report;
self.state.rows = rows;
self.state.members = members;
self
}
/// Re-title (the deck keys facets off [`facett_core::Facet::title`]).
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
/// Read the complete observable state at any frame boundary.
pub fn state(&self) -> &FleetRunView {
&self.state
}
/// Drill into a member's row (or `None` to clear).
pub fn select(&mut self, member: Option<String>) {
let _ = match member {
Some(m) => self.update(Msg::SelectMember(m)),
None => self.update(Msg::ClearSelection),
};
}
/// The single mutation path. Apply one [`Msg`]; returns the (always empty)
/// [`Effect`]s the host should run.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SelectMember(m) => self.state.selected = Some(m),
Msg::ClearSelection => self.state.selected = None,
}
Vec::new()
}
/// Derive the live board from the current inputs — the single source of truth
/// for the painter and `state_json`.
pub fn board(&self) -> FleetBoard {
FleetBoard::build(&self.state.report, &self.state.rows, &self.state.members)
}
/// **Discovery-contract `local()`** — a realistic 3-tab (× {fat, thin}) fleet
/// board with no host attached: three members drive the surfaces green, one
/// member (`nornir`) fails the `chat@thin` leg (so it's the missing surface →
/// a RED banner + a red cell), and `korp` is a dead member (a red row). Paints
/// real coloured cells + a RED `Gap == ∅` banner offline.
pub fn local() -> Self {
let hole = "viz_tab:chat@thin";
let mut surface = Surface::new();
surface.extend(viz_tabs(["chat", "board", "graph"]));
let all: Vec<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
let covered: std::collections::BTreeSet<String> =
all.iter().filter(|k| k.as_str() != hole).cloned().collect();
let report = GateReport::compute("run-fleet-1", "nordisk", &surface, &covered, &Allowlist::new());
let row = |member: &str, surface: &str, st: &str| TestResultRow {
run_id: "run-fleet-1".into(),
repo: member.into(),
suite: member.into(),
test_name: format!("{member}::{surface}"),
status: st.into(),
duration_ms: 1.0,
ts_micros: 100,
message: if status::is_red(st) { format!("{member} {surface} failed") } else { String::new() },
aspect: surface.into(),
metric: 0.0,
};
let mut rows = Vec::new();
for member in ["nornir", "dwarves", "holger"] {
for s in &all {
let st = if member == "nornir" && s == hole { status::FAIL } else { status::PASS };
rows.push(row(member, s, st));
}
}
let members = vec![
FleetMember::new("nornir", MemberHealth::Ready),
FleetMember::new("dwarves", MemberHealth::Ready),
FleetMember::new("holger", MemberHealth::Ready),
// korp declared but never spun — a dead red row (no rows for it).
FleetMember::new("korp", MemberHealth::Failed),
];
Self::new("Fleet").with_run(report, rows, members)
}
/// The cell fill for a verdict on the active theme.
fn cell_color(th: &facett_core::theme::Theme, v: CellVerdict) -> egui::Color32 {
match v {
CellVerdict::Passed => th.point,
CellVerdict::Failed => RED,
CellVerdict::NotDriven => th.text_dim.linear_multiply(0.25),
}
}
/// The glyph for a verdict.
fn cell_glyph(v: CellVerdict) -> &'static str {
match v {
CellVerdict::Passed => "●",
CellVerdict::Failed => "✗",
CellVerdict::NotDriven => "·",
}
}
}
impl FleetView {
/// **Pure** FC-9 render: paint the `Gap == ∅` banner + the summary strip + the
/// coloured member × surface grid, and *return* the [`Msg`]s the interactions
/// produced. MUST NOT mutate the state; the Elm bridge applies the returned
/// messages through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = facett_core::theme(ui);
let board = self.board();
// ── the Gap == ∅ verdict banner ────────────────────────────────────
let (banner_col, banner_txt) = if board.gate_green {
(th.point, format!("✓ fleet fully covered — Gap == ∅ ({}/{} surface nodes)", board.covered, board.total))
} else {
let mut t = format!(
"✗ GAP NOT EMPTY — {}/{} covered · {} MISSING",
board.covered,
board.total,
board.missing.len()
);
if !board.missing.is_empty() {
t.push_str(": [");
t.push_str(&board.missing.join(", "));
t.push(']');
}
if board.stale > 0 {
t.push_str(&format!(" · {} stale allowlist", board.stale));
}
(RED, t)
};
egui::Frame::new()
.fill(banner_col.linear_multiply(0.18))
.stroke(egui::Stroke::new(1.5_f32, banner_col))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(banner_txt).color(th.text).strong());
});
});
ui.add_space(4.0);
// ── summary strip ──────────────────────────────────────────────────
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(format!("{} member(s)", board.members.len())).color(th.text).strong());
ui.separator();
ui.label(egui::RichText::new(format!("{} surface(s)", board.surfaces.len())).color(th.text_dim));
ui.separator();
ui.label(egui::RichText::new(format!("✓ {} green", board.cells_green)).color(th.point));
ui.label(egui::RichText::new(format!("✗ {} red", board.cells_red)).color(if board.cells_red > 0 { RED } else { th.text_dim }));
ui.label(egui::RichText::new(format!("· {} not-driven", board.cells_not_driven)).color(th.text_dim));
});
ui.separator();
if board.members.is_empty() {
ui.label(egui::RichText::new("no fleet members").color(th.text_dim));
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-fleet-view::FleetView::view",
"ui_render",
true,
"members=0 drew=empty_hint",
);
return msgs;
}
// ── the member × surface grid ──────────────────────────────────────
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new(ui.id().with("fleet-board")).striped(true).spacing([8.0, 4.0]).show(ui, |ui| {
// header row: surface column labels.
ui.label(egui::RichText::new("member").color(th.text_dim).strong());
for s in &board.surfaces {
ui.label(egui::RichText::new(s).color(th.text_dim).strong());
}
ui.label(egui::RichText::new("health").color(th.text_dim).strong());
ui.end_row();
for row in &board.members {
let is_sel = self.state.selected.as_deref() == Some(row.name.as_str());
// A dead / red row draws its name in red so it reads at a glance.
let name_col = if row.row_red { RED } else { th.text };
let name_resp = ui.selectable_label(is_sel, egui::RichText::new(&row.name).color(name_col));
name_resp.widget_info({
let r = row.name.clone();
move || Semantics::list_item(r.clone(), is_sel).widget_info()
});
if name_resp.clicked() {
msgs.push(Msg::SelectMember(row.name.clone()));
}
for cell in &row.cells {
let col = FleetView::cell_color(&th, *cell);
ui.label(egui::RichText::new(FleetView::cell_glyph(*cell)).color(col).monospace());
}
let (hcol, hlbl) = match row.health {
MemberHealth::Ready => (th.point, "ready"),
MemberHealth::Failed => (RED, "failed"),
MemberHealth::Unknown => (th.text_dim, "—"),
};
ui.label(egui::RichText::new(hlbl).color(hcol));
ui.end_row();
}
});
});
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-fleet-view::FleetView::view",
"ui_render",
!board.members.is_empty(),
&format!(
"members={} surfaces={} green={} red={} gate_green={}",
board.members.len(),
board.surfaces.len(),
board.cells_green,
board.cells_red,
board.gate_green
),
);
msgs
}
}
// ── the canonical Elm split ───────────────────────────────────────────────────
impl facett_core::Elm for FleetView {
type Model = FleetRunView;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &FleetRunView {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
FleetView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
FleetView::view(self, ui)
}
}
// Form 3 (`custom_state_json`): the board publishes a RICHER `state_json` than a
// plain `serde(state())` — the derived gap verdict + per-cell verdicts a driver
// reads come from `board()`, not from the raw inputs.
facett_core::impl_facet_via_elm!(FleetView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let mut j = self.board().state_json();
if let Some(obj) = j.as_object_mut() {
obj.insert("title".into(), serde_json::json!(self.title));
obj.insert("selected".into(), serde_json::json!(self.state.selected));
}
j
}
/// The robot / headless-drive seam: parse one serde-tagged [`Msg`] and apply
/// it through the single [`Elm::update`](facett_core::Elm::update) path.
/// Malformed JSON is ignored (never a panic).
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn selection_json(&self) -> serde_json::Value {
match &self.state.selected {
Some(m) => serde_json::json!({ "member": m }),
None => serde_json::Value::Null,
}
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().selectable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::Facet;
#[test]
fn local_board_paints_banner_and_cells() {
let v = FleetView::local();
let j = Facet::state_json(&v);
assert_eq!(<FleetView as Facet>::title(&v), "Fleet");
// 4 members (3 live + korp dead), the missing chat@thin surface → RED gate.
assert_eq!(j["member_count"], 4);
assert_eq!(j["gate_green"], false);
assert_eq!(j["missing"][0], "viz_tab:chat@thin");
// korp is a dead red row.
let members = j["members"].as_array().unwrap();
let korp = members.iter().find(|m| m["name"] == "korp").unwrap();
assert_eq!(korp["health"], "failed");
assert_eq!(korp["row_red"], true);
// real runs → non-zero green + red (the false-empty guard).
assert!(j["cells_green"].as_u64().unwrap() > 0);
assert!(j["cells_red"].as_u64().unwrap() > 0);
}
#[test]
fn select_drills_into_a_member() {
let mut v = FleetView::local();
assert_eq!(Facet::selection_json(&v), serde_json::Value::Null);
v.select(Some("nornir".into()));
assert_eq!(Facet::selection_json(&v)["member"], "nornir");
assert_eq!(Facet::state_json(&v)["selected"], "nornir");
}
#[test]
fn empty_board_is_sane() {
let v = FleetView::new("Fleet");
let j = Facet::state_json(&v);
assert_eq!(j["member_count"], 0);
assert_eq!(j["cells_red"], 0);
// An empty gate report is trivially green (no missing surface).
assert_eq!(j["gate_green"], true);
}
#[test]
fn harness_snapshot_drives_select_and_clear() {
use facett_core::harness;
let mut v = FleetView::local();
let snap = harness::snapshot(&mut v, [Msg::SelectMember("dwarves".into())]);
assert_eq!(snap.selected.as_deref(), Some("dwarves"));
let snap = harness::snapshot(&mut v, [Msg::SelectMember("holger".into()), Msg::ClearSelection]);
assert_eq!(snap.selected, None);
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = FleetView::local();
let effects = harness::drive(&mut v, [Msg::SelectMember("nornir".into())]);
assert!(effects.is_empty(), "a pure view emits no Effects");
let json = serde_json::to_value(v.state()).unwrap();
let back: FleetRunView = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
}
}