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

nordisk/facett

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