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

//! **Pure model** behind the fleet coverage board — egui-free, `std` + `serde`
//! only, so the whole board is inject-and-assert testable (feed a
//! [`GateReport`] + a slice of [`TestResultRow`]s → assert the laid-out
//! [`FleetBoard`]) and folds straight into a `state_json` (the "see what the
//! user sees as data" LAW).
//!
//! The board is the fleet autotest run rendered as a grid:
//!   * **rows = fleet members** (the apps/containers: `nornir`, `dwarves`,
//!     `holger`, `korp`, …), taken from each member's *latest run* (newest
//!     `ts_micros`, via [`summarize_runs`] — the SAME latest-run rule the
//!     `facett-matrix` grid uses).
//!   * **columns = driven surfaces/tabs** — the surface keys (`viz_tab:chat@thin`
//!     …), the union of the aspects present in the rows and the surfaces the
//!     [`GateReport`]'s gap references, so a never-driven surface still shows as a
//!     grey column instead of silently vanishing.
//!   * each **cell** = green (passed) / red (failed) / grey (not-yet-driven),
//!     sourced from the [`TestResultRow`]s. A **dead member** (health
//!     [`MemberHealth::Failed`]) reads as a **red row** — its not-yet-driven cells
//!     go red, never a false blank.
//!
//! The verdict banner is derived from the [`GateReport`] directly:
//! `gate_green ⟺ report.is_green()` (Gap == ∅ AND no stale allowlist), and the
//! `missing` list is the honest `gap.missing` surface keys — the exact
//! `missing == [viz_tab:chat@thin]` semantics the fleet tests assert.
//!
//! All coverage/verdict types are **consumed** from `nornir-testmatrix`; this
//! module invents no parallel coverage model.

use std::collections::BTreeSet;

use nornir_testmatrix::{status, summarize_runs, GateReport, TestResultRow};
use serde::{Deserialize, Serialize};

/// The spin/health of one fleet member (the container/app itself, distinct from
/// the surfaces it drives). A member that failed to come up is [`Failed`] — the
/// board paints its whole row red so a dead member never reads as a false blank.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemberHealth {
    /// The member spun up and is serving (Ready).
    Ready,
    /// The member failed to spin / crashed — a RED row, not a blank one.
    Failed,
    /// No health signal carried in the input (the default). Does NOT force red.
    #[default]
    Unknown,
}

impl MemberHealth {
    pub fn label(self) -> &'static str {
        match self {
            MemberHealth::Ready => "ready",
            MemberHealth::Failed => "failed",
            MemberHealth::Unknown => "unknown",
        }
    }
    pub fn is_failed(self) -> bool {
        matches!(self, MemberHealth::Failed)
    }
}

/// One declared fleet member + its optional spin/health. Declaring a member with
/// no result rows still gives it a board row (a dead/never-driven member is
/// visible, not missing).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FleetMember {
    pub name: String,
    #[serde(default)]
    pub health: MemberHealth,
}

impl FleetMember {
    pub fn new(name: impl Into<String>, health: MemberHealth) -> Self {
        Self { name: name.into(), health }
    }
    /// A member with no health signal carried.
    pub fn plain(name: impl Into<String>) -> Self {
        Self::new(name, MemberHealth::Unknown)
    }
}

/// One board cell's verdict: a member × surface leg in the member's latest run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CellVerdict {
    /// The member drove this surface and it passed (green).
    Passed,
    /// The member drove this surface and it failed — OR the member is dead (red).
    Failed,
    /// No (non-listed) result row for this member × surface yet (grey).
    NotDriven,
}

impl CellVerdict {
    pub fn label(self) -> &'static str {
        match self {
            CellVerdict::Passed => "passed",
            CellVerdict::Failed => "failed",
            CellVerdict::NotDriven => "not_driven",
        }
    }
    pub fn is_green(self) -> bool {
        matches!(self, CellVerdict::Passed)
    }
    pub fn is_red(self) -> bool {
        matches!(self, CellVerdict::Failed)
    }
    pub fn driven(self) -> bool {
        !matches!(self, CellVerdict::NotDriven)
    }
}

/// One board row: a fleet member, its spin/health, whether the whole row reads
/// red (a dead member or any failed cell), and its per-surface cells (parallel to
/// [`FleetBoard::surfaces`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MemberRow {
    pub name: String,
    pub health: MemberHealth,
    pub row_red: bool,
    pub cells: Vec<CellVerdict>,
}

/// The whole laid-out board + the coverage verdict, derived purely from a
/// [`GateReport`] and the result rows. The renderer ([`crate::FleetView`]) only
/// paints what this computes, and its `state_json` is [`FleetBoard::state_json`].
#[derive(Clone, Debug, PartialEq)]
pub struct FleetBoard {
    /// The gate run identity (from the [`GateReport`]).
    pub run_id: String,
    /// The workspace the surface belongs to (from the [`GateReport`]).
    pub workspace: String,
    /// Surface/tab column keys (`kind:id@mode`), sorted.
    pub surfaces: Vec<String>,
    /// One row per fleet member, sorted by name.
    pub members: Vec<MemberRow>,
    /// Covered surface nodes (from `gap.covered`).
    pub covered: usize,
    /// Total discovered surface nodes (from `gap.total`).
    pub total: usize,
    /// `Gap == ∅` — no missing surface.
    pub gap_clean: bool,
    /// The HARD-zero gate: `Gap == ∅` AND no stale allowlist entry.
    pub gate_green: bool,
    /// Stale (no-longer-needed) allowlist entries.
    pub stale: usize,
    /// The missing surface keys (the actionable burn-down list), sorted.
    pub missing: Vec<String>,
    /// The allowlisted (excused) surface keys.
    pub allowlisted: Vec<String>,
    /// Cell tallies for the summary strip.
    pub cells_green: usize,
    pub cells_red: usize,
    pub cells_not_driven: usize,
}

impl FleetBoard {
    /// Build the board by differencing the member rows against the surface
    /// columns, under the [`GateReport`]'s verdict. Pure: feed the report + rows +
    /// declared members, get the laid-out board back.
    pub fn build(report: &GateReport, rows: &[TestResultRow], members: &[FleetMember]) -> FleetBoard {
        // ── columns: the driven surfaces (row aspects) unioned with every surface
        //    the gap references, so a never-driven surface is still a visible col.
        let mut surf: BTreeSet<String> = BTreeSet::new();
        for r in rows {
            if !status::is_listed(&r.status) {
                surf.insert(r.aspect.clone());
            }
        }
        for n in &report.gap.missing {
            surf.insert(n.key_str());
        }
        for n in &report.gap.allowlisted {
            surf.insert(n.key_str());
        }
        let surfaces: Vec<String> = surf.into_iter().collect();

        // ── rows: every member that produced a row, unioned with declared members.
        let mut names: BTreeSet<String> = BTreeSet::new();
        for r in rows {
            names.insert(r.repo.clone());
        }
        for m in members {
            names.insert(m.name.clone());
        }
        let health_of = |name: &str| members.iter().find(|m| m.name == name).map(|m| m.health).unwrap_or_default();

        let mut member_rows = Vec::with_capacity(names.len());
        let (mut cg, mut cr, mut cn) = (0usize, 0usize, 0usize);
        for name in &names {
            let health = health_of(name);
            let mrows: Vec<&TestResultRow> = rows.iter().filter(|r| &r.repo == name).collect();
            // Latest run for this member — the SAME rule the facett-matrix grid uses.
            let latest_id = {
                let owned: Vec<TestResultRow> = mrows.iter().map(|r| (*r).clone()).collect();
                summarize_runs(&owned).first().map(|s| s.run_id.clone()).unwrap_or_default()
            };
            let mut cells = Vec::with_capacity(surfaces.len());
            let mut row_red = health.is_failed();
            for s in &surfaces {
                let hits: Vec<&&TestResultRow> = mrows
                    .iter()
                    .filter(|r| r.run_id == latest_id && &r.aspect == s && !status::is_listed(&r.status))
                    .collect();
                let base = if hits.is_empty() {
                    CellVerdict::NotDriven
                } else if hits.iter().any(|r| status::is_red(&r.status)) {
                    CellVerdict::Failed
                } else if hits.iter().any(|r| status::is_green(&r.status)) {
                    CellVerdict::Passed
                } else {
                    // Only skip/neutral rows → nothing to prove yet.
                    CellVerdict::NotDriven
                };
                // A dead member's not-yet-driven cell reads RED, never a false blank.
                let eff = if health.is_failed() && base == CellVerdict::NotDriven { CellVerdict::Failed } else { base };
                match eff {
                    CellVerdict::Passed => cg += 1,
                    CellVerdict::Failed => cr += 1,
                    CellVerdict::NotDriven => cn += 1,
                }
                if eff.is_red() {
                    row_red = true;
                }
                cells.push(eff);
            }
            member_rows.push(MemberRow { name: name.clone(), health, row_red, cells });
        }

        FleetBoard {
            run_id: report.run_id.clone(),
            workspace: report.workspace.clone(),
            surfaces,
            members: member_rows,
            covered: report.gap.covered,
            total: report.gap.total,
            gap_clean: report.gap.is_clean(),
            gate_green: report.is_green(),
            stale: report.stale.len(),
            missing: report.gap.missing.iter().map(|n| n.key_str()).collect(),
            allowlisted: report.gap.allowlisted.iter().map(|n| n.key_str()).collect(),
            cells_green: cg,
            cells_red: cr,
            cells_not_driven: cn,
        }
    }

    /// The introspection blob a host folds into a `state_json` (LAW 6): the gap
    /// verdict, the missing list, the surface columns, and every member row's
    /// per-cell verdicts — so a robot reads exactly what is painted without a
    /// screenshot.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "run_id": self.run_id,
            "workspace": self.workspace,
            "member_count": self.members.len(),
            "surface_count": self.surfaces.len(),
            "covered": self.covered,
            "total": self.total,
            "gap_clean": self.gap_clean,
            "gate_green": self.gate_green,
            "stale": self.stale,
            "missing": self.missing,
            "allowlisted": self.allowlisted,
            "surfaces": self.surfaces,
            "cells_green": self.cells_green,
            "cells_red": self.cells_red,
            "cells_not_driven": self.cells_not_driven,
            "members": self.members.iter().map(|m| serde_json::json!({
                "name": m.name,
                "health": m.health.label(),
                "row_red": m.row_red,
                "cells": m.cells.iter().zip(self.surfaces.iter()).map(|(c, s)| serde_json::json!({
                    "surface": s,
                    "verdict": c.label(),
                    "green": c.is_green(),
                    "red": c.is_red(),
                    "driven": c.driven(),
                })).collect::<Vec<_>>(),
            })).collect::<Vec<_>>(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_testmatrix::coverage::Allowlist;
    use nornir_testmatrix::discover::{viz_tabs, Surface};

    /// A pass/fail row for `member` driving `surface` (the `aspect` = surface key).
    fn row(run: &str, member: &str, surface: &str, st: &str, ts: i64) -> TestResultRow {
        TestResultRow {
            run_id: run.into(),
            repo: member.into(),
            suite: member.into(),
            test_name: format!("{member}::{surface}"),
            status: st.into(),
            duration_ms: 1.0,
            ts_micros: ts,
            message: if status::is_red(st) { "boom".into() } else { String::new() },
            aspect: surface.into(),
            metric: 0.0,
        }
    }

    /// The surface = two tabs × {fat, thin}; `covered` = all keys minus `hole`.
    fn report_missing(hole: Option<&str>) -> GateReport {
        let mut surface = Surface::new();
        surface.extend(viz_tabs(["chat", "board"]));
        let covered: BTreeSet<String> = surface
            .nodes
            .iter()
            .map(|n| n.key_str())
            .filter(|k| hole != Some(k.as_str()))
            .collect();
        GateReport::compute("run-1", "nordisk", &surface, &covered, &Allowlist::new())
    }

    #[test]
    fn fully_covered_board_is_all_green() {
        let report = report_missing(None);
        let surfaces = ["viz_tab:chat@fat", "viz_tab:chat@thin", "viz_tab:board@fat", "viz_tab:board@thin"];
        let mut rows = Vec::new();
        for m in ["nornir", "dwarves"] {
            for s in surfaces {
                rows.push(row("run-1", m, s, status::PASS, 100));
            }
        }
        let board = FleetBoard::build(&report, &rows, &[]);
        assert!(board.gate_green, "gate is green when the gap is empty");
        assert!(board.gap_clean);
        assert!(board.missing.is_empty());
        assert_eq!(board.cells_red, 0);
        assert_eq!(board.cells_not_driven, 0);
        assert!(board.cells_green >= 8, "every driven cell is green");
    }

    #[test]
    fn missing_surface_makes_a_red_banner_and_red_cell() {
        let hole = "viz_tab:chat@thin";
        let report = report_missing(Some(hole));
        // The failing drive is WHY the surface is uncovered: nornir's chat@thin red.
        let rows = vec![
            row("run-1", "nornir", "viz_tab:chat@fat", status::PASS, 100),
            row("run-1", "nornir", hole, status::FAIL, 100),
        ];
        let board = FleetBoard::build(&report, &rows, &[]);
        assert!(!board.gate_green, "gate is red when a surface is missing");
        assert!(!board.gap_clean);
        assert_eq!(board.missing, vec![hole.to_string()], "the exact missing node is named");
        // The chat@thin cell for nornir is red.
        let nornir = board.members.iter().find(|m| m.name == "nornir").unwrap();
        let idx = board.surfaces.iter().position(|s| s == hole).unwrap();
        assert!(nornir.cells[idx].is_red(), "the missing surface's cell is red");
        assert!(nornir.row_red);
    }

    #[test]
    fn dead_member_is_a_red_row_not_a_blank() {
        let report = report_missing(None);
        let rows = vec![row("run-1", "nornir", "viz_tab:chat@fat", status::PASS, 100)];
        // korp declared but never spun (Failed) and produced no rows.
        let members = vec![FleetMember::new("korp", MemberHealth::Failed)];
        let board = FleetBoard::build(&report, &rows, &members);
        let korp = board.members.iter().find(|m| m.name == "korp").unwrap();
        assert!(korp.row_red, "a dead member's row is red");
        assert!(korp.cells.iter().all(|c| c.is_red()), "its cells are red, not blank");
        assert_eq!(korp.health, MemberHealth::Failed);
    }
}