dwarves-gui/src/fleet.rs
//! **The Fleet coverage-board tab** (autotest North Star) — a full-system-autotest
//! run rendered as a board: rows = fleet members (`nornir`/`dwarves`/`holger`/`korp`…),
//! columns = driven surfaces × {fat/egui, thin/web}, each cell green/red/grey, under a
//! prominent `Gap == ∅` verdict banner (green when fully covered, red naming the missing
//! nodes).
//!
//! Thin host wrapper that **MOUNTS the SHARED [`facett_fleet_view::FleetView`]** — the
//! fleet analogue of the `facett-matrix` grid the Benches tab reuses. dwarves invents no
//! second coverage model here: the view is fed a [`FleetRunView`] built from
//! `nornir-testmatrix`'s own [`GateReport`] + [`TestResultRow`]s + declared members
//! (consumed as data), and does its own board layout + banner + `state_json` oracle.
//!
//! ## The feed (offline seam, host-agnostic — the DEFAULT)
//! Like the Release/Benches/Saga tabs, this reads an artifact a fleet run writes rather
//! than requiring a live container run: **`$DWARVES_FLEET_RUN`** — a whole-file JSON
//! [`FleetRunView`], or a tagged JSONL stream (`{"report":…}` / `{"row":…}` /
//! `{"member":…}` / `{"run":…}`) of the edda `crates/nornir-fleet` harness's [`GateReport`] +
//! emitted [`TestResultRow`]s + members.
//!
//! ## The demo board is gone (audit 2026-09-10)
//! Missing/unreadable/empty used to mean `FleetView::local()` — the shared component's
//! *discovery-contract demo*: a full member×surface board with a deliberate
//! `chat@thin` hole, so the tab opened on a RED `Gap == ∅` banner naming a missing node
//! that no run had looked for. Its intent was honesty (better red than falsely green),
//! but a coverage board is a claim about what was DRIVEN, and none of it was. A
//! `source: "demo"` tag rode alongside; nobody reads a tag off a banner.
//!
//! Now: no fleet run ⇒ no board, and the shared
//! [`facett_core::empty::EmptyState`] names `$DWARVES_FLEET_RUN` and says why.
//! `state_json`'s `source` is `file` or `empty`, and `empty` carries the named block.
//!
//! ## The LIVE feed (seam left open — no container run is built here)
//! Both live legs land in the SAME [`FleetTab::with_run`] one-liner the offline seam
//! already exercises:
//! - **fat / in-process**: build the [`FleetRunView`] from an in-process
//! `edda crates/nornir-fleet` `FleetHarness` run (harness → `GateReport` + rows + members).
//! TODO(fat): pull the fleet harness + call its headless run entry here once it
//! exposes one (it currently drives containers/robots — out of scope for a view tab).
//! - **thin / server-streamed**: build the [`FleetRunView`] from `TestResultRow`s
//! streamed off a running fleet server + its final `GateReport`.
//! TODO(thin): wire once the fleet stream RPC lands (mirror `nornir-funnel-embed`).
use facett_core::empty::EmptyState;
use facett_core::Facet;
use facett_fleet_view::{FleetMember, FleetRunView, FleetView, GateReport, TestResultRow};
/// The env var naming a fleet-run output file the offline seam loads.
const FLEET_RUN_ENV: &str = "DWARVES_FLEET_RUN";
/// The fleet feed, named the way an operator would go look it up.
const FLEET_FEED: &str = "$DWARVES_FLEET_RUN (nornir-fleet GateReport + TestResultRows)";
/// One line of a tagged-JSONL fleet-run stream: the harness's `GateReport`, an emitted
/// `TestResultRow`, a declared `FleetMember`, or a whole `FleetRunView` in one object.
/// Externally tagged (`{"report":…}` / `{"row":…}` / `{"member":…}` / `{"run":…}`) so a
/// heterogeneous stream parses unambiguously line by line.
#[derive(serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum FleetLine {
Report(GateReport),
Row(TestResultRow),
Member(FleetMember),
Run(FleetRunView),
}
/// The Fleet coverage-board tab: the SHARED [`FleetView`], fed offline from a fleet-run
/// file (or the never-blank demo board).
pub struct FleetTab {
/// The reused shared board — dwarves mounts it, never re-implements it.
inner: FleetView,
/// Where the board came from: `"file"` (a real run) or `"empty"` (no run).
source: &'static str,
/// Set when no fleet run was found — the NAMED empty state, drawn in place of the
/// board. This is what replaced `FleetView::local()`'s demo grid + red banner.
empty: Option<EmptyState>,
}
impl FleetTab {
/// Load the board from `$DWARVES_FLEET_RUN` if present + parseable, else the NAMED
/// empty state over that env var.
pub fn new() -> Self {
match Self::load_env() {
Some(run) => Self::with_run(run),
None => Self::unrun(),
}
}
/// **Mount the shared view over a concrete run** — the single seam the offline file
/// load AND the (TODO) fat/thin LIVE feeds all funnel through.
pub fn with_run(run: FleetRunView) -> Self {
let empty = (run.rows.is_empty() && run.members.is_empty()).then(|| {
EmptyState::no_rows(
FLEET_FEED,
"the loaded fleet run has no members and no rows",
)
.with_hint("drive the fleet harness so it emits TestResultRows")
});
let view = FleetView::new("Fleet").with_run(run.report, run.rows, run.members);
Self {
inner: view,
source: "file",
empty,
}
}
/// **No fleet run** — an EMPTY board carrying the named reason. A coverage board
/// nobody drove has no verdict to render, so it renders none.
pub fn unrun() -> Self {
let where_ = match std::env::var(FLEET_RUN_ENV) {
Ok(p) if !p.trim().is_empty() => format!("`{p}` is missing, unreadable or empty"),
_ => format!("{FLEET_RUN_ENV} is not set"),
};
Self {
inner: FleetView::new("Fleet"),
source: "empty",
empty: Some(
EmptyState::not_configured(FLEET_FEED, where_)
.with_hint("run the fleet harness and point $DWARVES_FLEET_RUN at its output"),
),
}
}
/// The offline seam: read `$DWARVES_FLEET_RUN` and parse it. `None` on
/// absence / unreadable / empty ⇒ the caller falls back to [`unrun`](Self::unrun).
fn load_env() -> Option<FleetRunView> {
let path = std::env::var_os(FLEET_RUN_ENV)?;
if path.is_empty() {
return None;
}
let text = std::fs::read_to_string(&path).ok()?;
Self::parse(&text)
}
/// Parse a fleet-run file: a whole-file JSON [`FleetRunView`], else a tagged-JSONL
/// stream of the harness's report + rows + members. `None` if nothing usable is
/// found (an empty/garbage file falls back to the demo, never blank).
fn parse(text: &str) -> Option<FleetRunView> {
let t = text.trim();
if t.is_empty() {
return None;
}
// (1) whole-file JSON FleetRunView — the canonical round-trippable form.
if let Ok(run) = serde_json::from_str::<FleetRunView>(t) {
if !run.rows.is_empty() || !run.members.is_empty() {
return Some(run);
}
}
// (2) tagged JSONL — one envelope per line (malformed lines skipped, never a panic).
let mut run = FleetRunView::default();
let mut saw = false;
for line in t.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<FleetLine>(line) {
Ok(FleetLine::Report(r)) => {
run.report = r;
saw = true;
}
Ok(FleetLine::Row(row)) => {
run.rows.push(row);
saw = true;
}
Ok(FleetLine::Member(m)) => {
run.members.push(m);
saw = true;
}
Ok(FleetLine::Run(r)) => {
run = r;
saw = true;
}
Err(_) => {}
}
}
if saw && (!run.rows.is_empty() || !run.members.is_empty()) {
Some(run)
} else {
None
}
}
}
impl Default for FleetTab {
fn default() -> Self {
Self::new()
}
}
impl Facet for FleetTab {
fn title(&self) -> &str {
"Fleet"
}
fn ui(&mut self, ui: &mut egui::Ui) {
if let Some(e) = &self.empty {
// No run ⇒ no grid and no `Gap == ∅` banner in either colour. The banner is
// a verdict on a run; with no run there is nothing to be a verdict about.
e.ui(ui);
return;
}
Facet::ui(&mut self.inner, ui);
}
fn state_json(&self) -> serde_json::Value {
// The shared board's state is already self-complete (gate_green, missing,
// member×cell verdicts, tallies) — pass it through, tagging the component +
// where the run came from (`file`/`demo`) so the gate tells a real run apart.
let mut j = Facet::state_json(&self.inner);
if let Some(obj) = j.as_object_mut() {
obj.insert("component".into(), serde_json::json!("FleetView"));
obj.insert("source".into(), serde_json::json!(self.source));
obj.insert("feed".into(), serde_json::json!(FLEET_FEED));
if let Some(e) = &self.empty {
obj.insert("fleet_empty".into(), e.state_json());
// No run ⇒ no verdict. The inner board over zero members folds
// `gate_green: true` / `gap_clean: true`; a gate nobody ran is not green.
for k in ["gate_green", "gap_clean"] {
obj.insert(k.into(), serde_json::Value::Null);
}
}
}
j
}
fn update_json(&mut self, msg_json: &str) {
// The warehouse selector broadcasts `{"set_workspace":…}` to every tab. The
// fleet board is CONSTELLATION-wide (not bound to one workspace), so swallow it.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
if v.get("set_workspace").is_some() {
return;
}
}
// Everything else (select_member / clear_selection) drives the shared view.
Facet::update_json(&mut self.inner, msg_json);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tabbed::TabbedView;
// `Panel` is only needed to box the tab for the robot drive below — a test-only
// need, so it is imported here rather than at module scope (where it would be an
// unused import in the non-test lib build).
use facett_core::Panel;
use facett_fleet_view::{status, MemberHealth};
/// The two viz tabs × {fat, thin} the fleet tests drive, as surface KEYS.
const SURFACES: [&str; 4] = [
"viz_tab:chat@fat",
"viz_tab:chat@thin",
"viz_tab:board@fat",
"viz_tab:board@thin",
];
/// **The gate report, DERIVED** — `SURFACES` differenced against a covered set with
/// `hole` (if any) knocked out of it, exactly as `GateReport::compute` does at a real
/// gate. Mirrors `facett-fleet-view`'s own `report_missing` helper.
///
/// The holed report used to be hand-forged instead: `report.missing.push(hole)` on top
/// of a `GateReport::default()`. That did not compile (`missing` lives on `gap`, and it
/// holds `SurfaceNode`s, not strings) — and had it compiled it would have been the very
/// thing this whole pane is being cleaned of: a gap NAMING a missing node while the
/// report's own counts (`covered`/`total`) and its `is_clean()` verdict still said the
/// fleet was fully covered. Deriving it keeps the banner, the counts and the cells
/// consistent by construction.
fn report_missing(hole: Option<&str>) -> GateReport {
// `fleet_testmatrix` is `nornir-testmatrix` renamed — deliberately the REGISTRY
// copy `facett-fleet-view` builds its `GateReport` from, not this crate's local
// path copy behind the `testmatrix` feature. See the dev-dependency's note.
use fleet_testmatrix::coverage::Allowlist;
use fleet_testmatrix::discover::{viz_tabs, Surface};
let mut surface = Surface::new();
surface.extend(viz_tabs(["chat", "board"]));
let covered: std::collections::BTreeSet<String> = surface
.nodes
.iter()
.map(|n| n.key_str())
.filter(|k| hole != Some(k.as_str()))
.collect();
GateReport::compute(
"run-fleet",
"nordisk",
&surface,
&covered,
&Allowlist::new(),
)
}
/// A fully-covered run: the gate report is derived over a covered set with no hole
/// (`Gap == ∅`, green), and every member drives every surface PASS → every cell green.
fn covered_run() -> FleetRunView {
let surfaces = SURFACES;
let mut rows = Vec::new();
for member in ["nornir", "dwarves", "holger"] {
for s in surfaces {
rows.push(TestResultRow {
run_id: "run-fleet-green".into(),
repo: member.into(),
suite: member.into(),
test_name: format!("{member}::{s}"),
status: status::PASS.into(),
duration_ms: 1.0,
ts_micros: 100,
message: String::new(),
aspect: s.into(),
metric: 0.0,
});
}
}
let members = vec![
FleetMember::new("nornir", MemberHealth::Ready),
FleetMember::new("dwarves", MemberHealth::Ready),
FleetMember::new("holger", MemberHealth::Ready),
];
FleetRunView {
report: report_missing(None),
rows,
members,
selected: None,
}
}
/// ROBOT SCENARIO (autotest North Star) — the **Fleet** coverage board mounted in the
/// shell and driven over the remote seam (`state_json`/`update_json`), the SAME channel
/// a `nornir-robotui` scenario / thin client use. Asserts the THREE verdicts of the
/// mounted shared `FleetView`, and — the point of splitting (b) from (c) — that the
/// board keeps *coverage* and *passing* apart:
/// (a) a fully-covered, all-passing `FleetRunView` shows the GREEN `Gap == ∅`
/// banner (`gate_green`, `cells_red == 0`, no `missing`) with EVERY
/// member×surface cell green;
/// (b) a surface NOBODY drove (`viz_tab:chat@thin`) shows the RED banner NAMING
/// that missing node, `gap_clean == false`, and every member's cell for it
/// GREY — `driven:false`, and **not red**, because nothing was measured;
/// (c) a surface that WAS driven and FAILED shows that one cell red and the
/// member's row red, while `Gap == ∅` stays CLEAN — every surface was
/// exercised, one of them broke, and those are different facts.
///
/// (b) and (c) are the assertion that changed when the gate report started being
/// DERIVED (see `report_missing`). The old (b) pushed a FAIL row for the undriven
/// surface and asserted a red cell — the board claiming a measured failure on a
/// test that never ran, which is the same class of thing as the demo board this
/// pane just stopped drawing.
///
/// Asserts over `state_json`, not "it rendered". Emits a `nornir-testmatrix` cell.
#[test]
fn robot_fleet_view_over_the_seam() {
// ── (a) fully covered → GREEN Gap==∅ banner + all cells green ──────────────
let green: Box<dyn Panel> = Box::new(FleetTab::with_run(covered_run()));
let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), green)]);
Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
let st = Facet::state_json(&app);
let pane = &st["fleet"];
let all_cells_green = pane["members"].as_array().unwrap().iter().all(|m| {
m["cells"]
.as_array()
.unwrap()
.iter()
.all(|c| c["green"] == true)
});
let green_ok = pane["component"] == "FleetView"
&& pane["source"] == "file"
&& pane["gate_green"] == true
&& pane["cells_red"] == 0
&& pane["cells_green"].as_u64().unwrap_or(0) >= 12
&& pane["missing"].as_array().unwrap().is_empty()
&& all_cells_green;
// ── (b) a surface NOBODY drove → RED banner naming it, and a GREY cell ────────
//
// The run is built HERE (a real `FleetRunView` with a hole punched in it); it
// used to be `FleetTab::demo()`, the shared component's built-in demo board,
// which the SHIPPED tab drew whenever no fleet run was present.
//
// The hole is cut from EVERY member's rows, because the gate's `covered` set is
// global: a surface one member skipped is still covered if another drove it. Cut
// it everywhere and the derived report and the rows agree about the same world.
//
// And the cell for it must be GREY, not red. This is the assertion that changed
// when the report started being derived, and the change is a fix rather than a
// concession. The old version pushed a FAIL row for the hole and asserted
// `cells_red >= 1` — so the board claimed a *measured failure* on a surface no
// test had ever executed. "Nobody looked" and "we looked and it broke" are
// different facts, and a coverage board that renders them the same colour is
// the same class of lie as the demo board this pane just stopped drawing.
let hole = "viz_tab:chat@thin";
let mut holed = covered_run();
holed.rows.retain(|r| r.aspect != hole);
// The gap is DERIVED from the same surface with `hole` knocked out of `covered`
// — so the banner, the `missing` list and the covered/total counts cannot
// disagree with one another. See `report_missing`.
holed.report = report_missing(Some(hole));
let red: Box<dyn Panel> = Box::new(FleetTab::with_run(holed));
let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), red)]);
Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
let st = Facet::state_json(&app);
let pane = &st["fleet"];
let names_missing = pane["missing"]
.as_array()
.unwrap()
.iter()
.any(|m| m == hole);
// EVERY member's cell for the undriven surface is grey: not driven, not green,
// and — the point — not red.
let hole_cells_grey = pane["members"].as_array().unwrap().iter().all(|m| {
m["cells"]
.as_array()
.unwrap()
.iter()
.find(|c| c["surface"] == hole)
.map(|c| c["driven"] == false && c["green"] == false && c["red"] == false)
.unwrap_or(false)
});
let red_ok = pane["source"] == "file"
&& pane["gate_green"] == false
&& pane["gap_clean"] == false
&& names_missing
&& hole_cells_grey
// Nothing was measured as broken, so nothing is red.
&& pane["cells_red"] == 0
&& pane["cells_not_driven"].as_u64().unwrap_or(0) == 3;
// ── (c) a surface that WAS driven and FAILED → a RED cell, gap still clean ────
//
// The other half of the distinction (b) turns on, and the reason a red cell
// means something: coverage and passing are different verdicts. Here every
// surface is driven — so `Gap == ∅` is GREEN — while one member's run failed,
// which is red on the board and red on that member's row.
let mut failed = covered_run();
for r in failed.rows.iter_mut() {
if r.repo == "nornir" && r.aspect == hole {
r.status = status::FAIL.into();
r.message = "the thin chat tab threw".into();
}
}
let failing: Box<dyn Panel> = Box::new(FleetTab::with_run(failed));
let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), failing)]);
Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
let st_f = Facet::state_json(&app);
let pane_f = &st_f["fleet"];
let nornir = pane_f["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "nornir")
.unwrap();
let failed_cell_red = nornir["cells"]
.as_array()
.unwrap()
.iter()
.find(|c| c["surface"] == hole)
.map(|c| c["red"] == true && c["driven"] == true && c["green"] == false)
.unwrap_or(false);
let fail_ok = failed_cell_red
&& nornir["row_red"] == true
&& pane_f["cells_red"].as_u64().unwrap_or(0) == 1
// Every surface WAS driven, so the coverage gate is clean even though a
// run failed — the two verdicts are independent and the board says both.
&& pane_f["gap_clean"] == true
&& pane_f["missing"].as_array().unwrap().is_empty();
let drivable = green_ok && red_ok && fail_ok;
assert!(
drivable,
"the Fleet coverage board drives both verdicts over the remote seam: {st}"
);
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"dwarves-gui",
"fleet_view_drivable",
drivable,
"Fleet coverage-board tab (autotest North Star): MOUNTS the shared facett_fleet_view::FleetView over a FleetRunView (nornir-testmatrix GateReport + TestResultRows + members) whose gate report is DERIVED by GateReport::compute rather than hand-forged — a fully-covered all-passing run shows the GREEN Gap==∅ banner (gate_green, cells_red==0, every member×surface cell green); a surface NOBODY drove (viz_tab:chat@thin) shows the RED banner naming the missing node with gap_clean==false and every member's cell for it GREY (driven:false, NOT red — nothing was measured); and a surface that WAS driven and FAILED shows exactly that cell red plus a red member row while Gap==∅ stays clean — so the board keeps coverage and passing apart instead of painting 'nobody looked' the same colour as 'we looked and it broke' — driven headless over state_json/update_json",
);
let _ = drivable;
}
/// **THE DEMO BOARD IS GONE** (audit 2026-09-10). With no fleet run the tab used to
/// mount `FleetView::local()` — a full member×surface grid with a deliberate
/// `chat@thin` hole and a RED `Gap == ∅` banner naming a missing node no run had
/// looked for. Pins:
///
/// (a) with `$DWARVES_FLEET_RUN` unset the tab has ZERO members and ZERO cells;
/// (b) it carries the NAMED empty state over `$DWARVES_FLEET_RUN`;
/// (c) `source` is `empty`, never `demo`;
/// (d) the demo's own missing-node id appears NOWHERE — no banner, red or green.
///
/// RED-when-broken: restore any fallback board and (a)/(d) fail.
#[test]
fn an_undriven_fleet_names_its_feed_never_a_demo_board() {
// The env var is read at construction; hold the shared lock so a concurrent
// test cannot repoint it mid-read.
let _env = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let had = std::env::var_os(FLEET_RUN_ENV);
unsafe { std::env::remove_var(FLEET_RUN_ENV) };
let tab = FleetTab::new();
let st = Facet::state_json(&tab);
if let Some(v) = had {
unsafe { std::env::set_var(FLEET_RUN_ENV, v) };
}
// (a) + (b) + (c)
assert_eq!(
st["source"], "empty",
"`demo` is not a source any more: {st}"
);
assert_eq!(
st["members"].as_array().map(|m| m.len()).unwrap_or(9),
0,
"no members: {st}"
);
assert_eq!(
st["cells_green"].as_u64().unwrap_or(9),
0,
"no green cells: {st}"
);
assert_eq!(
st["cells_red"].as_u64().unwrap_or(9),
0,
"no red cells either: {st}"
);
assert_eq!(st["fleet_empty"]["empty"], true);
assert_eq!(
st["fleet_empty"]["feed"], FLEET_FEED,
"the empty state NAMES the feed"
);
assert_eq!(st["fleet_empty"]["cause"], "not-configured");
// (d) the demo board's invented hole appears NOWHERE.
let whole = st.to_string();
assert!(
!whole.contains("viz_tab:chat@thin"),
"the demo board's invented missing node is gone: {whole}"
);
}
/// The offline file seam parses BOTH accepted shapes (whole-file JSON `FleetRunView`
/// and tagged JSONL of report + rows + members), and an empty/garbage file yields
/// `None` (the caller falls back to the never-blank demo).
#[test]
fn offline_seam_parses_json_and_jsonl() {
// whole-file JSON round-trip.
let run = covered_run();
let json = serde_json::to_string(&run).unwrap();
let parsed = FleetTab::parse(&json).expect("whole-file FleetRunView parses");
assert_eq!(parsed.rows.len(), run.rows.len());
assert_eq!(parsed.members.len(), 3);
// tagged JSONL: one report + rows + members, one envelope per line.
let mut jsonl = String::new();
jsonl.push_str(&serde_json::json!({ "report": GateReport::default() }).to_string());
jsonl.push('\n');
for row in &run.rows {
jsonl.push_str(&serde_json::json!({ "row": row }).to_string());
jsonl.push('\n');
}
jsonl.push_str(
&serde_json::json!({ "member": FleetMember::new("korp", MemberHealth::Failed) })
.to_string(),
);
jsonl.push('\n');
let parsed = FleetTab::parse(&jsonl).expect("tagged JSONL parses");
assert_eq!(parsed.rows.len(), run.rows.len());
assert!(parsed.members.iter().any(|m| m.name == "korp"));
// empty / garbage ⇒ None (demo fallback).
assert!(FleetTab::parse(" ").is_none());
assert!(FleetTab::parse("not json at all\n{").is_none());
}
}