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

nordisk/facett

facett-zoneui/src/lib.rs

//! **facett-zoneui** — the TWO shared zone/jera framework widgets every suite app
//! (korp · dwarves · nornir · holger · modgunn · ordning) DROPS IN. Built ONCE
//! here, consumed everywhere (L5 — no app hand-rolls its own):
//!
//! 1. [`ZonePane`] — the reusable Facet a Tools-menu entry opens: pick a [`Zone`]
//!    (demo/test/prod, default demo), see the DEFAULT INFRA it will bring up (the 2
//!    default containers: FalkorDB + Spark), press **Generate**, and watch **live
//!    inline per-container status** (start → health-check → fill → done, fail-loud
//!    in the pane). Generate calls a caller-provided closure/trait
//!    ([`ZoneGenerator`]) the app wires to jera `submit_container_job` — nothing
//!    korp-specific is baked in.
//! 2. [`JeraCentral`] — a thin CENTRAL-PANEL mount over the existing
//!    [`facett_jobview::JobList`] (the jera ledger) that adds only the per-job
//!    **zone column**. It OWNS a `JobList` and delegates the whole render + drive to
//!    it (no fork, no duplication — L5), stamping each job's `zone` into the row's
//!    `meta` (so it shows through jobview's existing meta line) and publishing a
//!    `zones` map + per-zone summary in its own `state_json`.
//!
//! ## The `Zone` type (the edda leaf — CONSUMED, never twinned, L5)
//! [`Zone`] is re-exported straight from the **edda `nornir_zone`** leaf (the ONE
//! constellation-wide definition, seeded from `korp::zone::Zone`): `Zone{Demo,Test,
//! Prod}`, `Demo` default, per-zone `key`/`from_key`/`port_offset`/`port_on(base)`/
//! `container_name(base)`/`namespace(app)`. These widgets are parameterised by it,
//! so every product drives them over the SAME zone model. Because `nornir_zone::Zone`
//! carries no serde (it is a std-only, dep-light leaf) and is `Copy`, the pane's
//! serializable Model stores the zone as its **key string** and converts at the
//! boundary via [`ZonePane::selected_zone`] / [`Zone::from_key`].

use facett_core::{Elm, FacetCaps, Theme, theme};
use serde::{Deserialize, Serialize};

/// The ONE shared [`Zone`](nornir_zone::Zone) primitive (edda leaf), re-exported so
/// an app can name it through this crate without a second dependency edge.
pub use nornir_zone::Zone;
// Re-export the canonical Facet trait so a host can drive these widgets (call
// `.ui(..)` / `.state_json()`) without taking its own facett-core dependency.
pub use facett_core::Facet;
// Re-export the jera ledger row + list so an app wires jobs without a second dep.
pub use facett_jobview::{JobEntry, JobList, JobStatus};

pub mod jera_central;
pub use jera_central::{JERA_MENU_NAME, JeraCentral, ZonedJob};

/// One lifecycle step of a zone container, in bring-up order. The pane colours each
/// container by this and derives the [`overall`](ZonePane::overall) phase from them.
/// The sequence a live populate walks: `Pending → Starting → HealthCheck → Fill →
/// Done` (or `Failed` at any point — surfaced fail-loud in the pane).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Step {
    /// Not started yet (the pre-Generate preview state).
    Pending,
    /// Container start requested (podman run).
    Starting,
    /// Started; waiting for the health check to pass.
    HealthCheck,
    /// Healthy; filling its data (the Iceberg→FalkorDB parallel load, etc.).
    Fill,
    /// Up, healthy, and filled — ready.
    Done,
    /// Bring-up failed at some step (fail-loud).
    Failed,
}

impl Step {
    /// Stable lowercase tag (matches `state_json`).
    pub fn as_str(self) -> &'static str {
        match self {
            Step::Pending => "pending",
            Step::Starting => "starting",
            Step::HealthCheck => "health_check",
            Step::Fill => "fill",
            Step::Done => "done",
            Step::Failed => "failed",
        }
    }

    /// A short status glyph for the row.
    pub fn glyph(self) -> &'static str {
        match self {
            Step::Pending => "…",
            Step::Starting => "▶",
            Step::HealthCheck => "♥",
            Step::Fill => "⇊",
            Step::Done => "✓",
            Step::Failed => "✗",
        }
    }

    /// `true` while the container is still being brought up (not terminal).
    pub fn is_active(self) -> bool {
        matches!(self, Step::Starting | Step::HealthCheck | Step::Fill)
    }

    /// Row colour — running amber, done green, failed red, pending dim.
    pub fn color(self, th: &Theme) -> egui::Color32 {
        match self {
            Step::Pending => th.text_dim,
            Step::Starting | Step::HealthCheck | Step::Fill => egui::Color32::from_rgb(232, 193, 42),
            Step::Done => th.point,
            Step::Failed => facett_core::look::RED,
        }
    }
}

/// The live status of ONE default-infra container in a zone: what it is (`name` +
/// container `image` + published `port`) and where it is in its bring-up
/// ([`step`](ContainerStatus::step)), plus a one-line human `detail` (health note /
/// row count / error). Serializable so it round-trips through the pane's Model and
/// `state_json` — the per-container status the design requires the pane to expose.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ContainerStatus {
    /// Display name (`"FalkorDB"`, `"Spark"`).
    pub name: String,
    /// The container image reference (`docker.io/falkordb/falkordb:v4.20.0`).
    pub image: String,
    /// The host-published port (base + the zone's [`port_offset`](Zone::port_offset)).
    pub port: u16,
    /// Where this container is in its bring-up.
    pub step: Step,
    /// One-line detail (health note / fill count / error). Empty is fine.
    pub detail: String,
}

impl ContainerStatus {
    /// A new container status at [`Step::Pending`] with no detail.
    pub fn new(name: impl Into<String>, image: impl Into<String>, port: u16) -> Self {
        Self { name: name.into(), image: image.into(), port, step: Step::Pending, detail: String::new() }
    }

    /// Builder: set the current step.
    pub fn step(mut self, step: Step) -> Self {
        self.step = step;
        self
    }

    /// Builder: set the one-line detail.
    pub fn detail(mut self, detail: impl Into<String>) -> Self {
        self.detail = detail.into();
        self
    }
}

/// The base FalkorDB port (graph DB) before the zone [`port_offset`](Zone::port_offset).
pub const FALKORDB_PORT: u16 = 6379;
/// The base Spark-Connect / iceberg port before the zone offset.
pub const SPARK_PORT: u16 = 15002;
/// The FalkorDB container image (the design's default-infra graph DB).
pub const FALKORDB_IMAGE: &str = "docker.io/falkordb/falkordb:v4.20.0";
/// The Spark container image (Spark-Connect / iceberg).
pub const SPARK_IMAGE: &str = "localhost/knut-spark-iceberg:latest";

/// The **default infrastructure** a zone brings up when an app doesn't set its own:
/// exactly 2 containers (FalkorDB + Spark), ports offset for the zone so populating
/// `test` never collides with `demo`/`prod`. Both are [`Step::Pending`] (the
/// pre-Generate preview). This is the constellation FALLBACK (korp's infra) — every
/// app that doesn't call [`ZonePane::with_default_infra`] shows these two.
pub fn default_containers(zone: Zone) -> Vec<ContainerStatus> {
    vec![
        ContainerStatus::new("FalkorDB", FALKORDB_IMAGE, zone.port_on(FALKORDB_PORT)),
        ContainerStatus::new("Spark", SPARK_IMAGE, zone.port_on(SPARK_PORT)),
    ]
}

/// **The default-infra preview seam (app-agnostic).** Given the selected [`Zone`], a
/// provider returns the containers the pane previews BEFORE Generate (and re-seeds on
/// zone-switch / Reset). Each app wires its OWN (korp → FalkorDB + Spark, holger →
/// registry + replica, dwarves → runner, ordning → event-store, nornir → none) so the
/// pane's pre-Generate preview is that app's infra, not a hardcoded FalkorDB + Spark.
///
/// A blanket impl makes any `Fn(Zone) -> Vec<ContainerStatus>` a `DefaultInfra`, so an
/// app passes a plain closure. The crate default is [`default_containers`] (korp's two).
pub trait DefaultInfra {
    /// The pre-Generate preview containers for `zone`.
    fn preview(&self, zone: Zone) -> Vec<ContainerStatus>;
}

impl<F: Fn(Zone) -> Vec<ContainerStatus>> DefaultInfra for F {
    fn preview(&self, zone: Zone) -> Vec<ContainerStatus> {
        self(zone)
    }
}

/// **The Generate action seam (app-agnostic).** When the user presses **Generate**
/// in a [`ZonePane`], the pane calls this with the SELECTED [`Zone`]; the app wires
/// it to jera `submit_container_job` (bring up FalkorDB + Spark in that zone as jera
/// jobs, then fill). It returns the INITIAL per-container [`ContainerStatus`] list
/// the pane then shows live (the app pushes later transitions via
/// [`Msg::StepContainer`] / [`Msg::SetContainers`]).
///
/// A blanket impl makes any `FnMut(Zone) -> Vec<ContainerStatus>` a `ZoneGenerator`,
/// so an app can pass a plain closure — nothing korp-specific is required here.
pub trait ZoneGenerator {
    /// Bring up (submit) the zone's default infra; return the initial statuses.
    fn generate(&mut self, zone: Zone) -> Vec<ContainerStatus>;
}

impl<F: FnMut(Zone) -> Vec<ContainerStatus>> ZoneGenerator for F {
    fn generate(&mut self, zone: Zone) -> Vec<ContainerStatus> {
        self(zone)
    }
}

/// **The complete observable state (FC-1 / FC-3)** of a [`ZonePane`], in one
/// serializable, round-trippable struct: the selected zone, the per-container
/// statuses, and whether Generate has been pressed. The **only** thing that mutates
/// it is [`ZonePane::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZonePaneState {
    /// The selected zone, stored as its [`Zone::key`] string (the leaf `Zone` is not
    /// serde, so the serializable Model holds the key; read it as a [`Zone`] via
    /// [`ZonePane::selected_zone`]). Default `"demo"`.
    pub selected: String,
    /// The default-infra containers + their live bring-up status.
    pub containers: Vec<ContainerStatus>,
    /// `true` once Generate has been pressed at least once this session.
    pub generated: bool,
}

impl Default for ZonePaneState {
    fn default() -> Self {
        Self {
            selected: Zone::Demo.key().to_string(),
            containers: default_containers(Zone::Demo),
            generated: false,
        }
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the pane through, the same effect a canvas gesture
/// produces. Applied by [`ZonePane::update`]. Serde-tagged (`snake_case`) so the
/// robot `update_json` seam parses one message straight off the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Pick the zone by its **key** (`"demo"`/`"test"`/`"prod"`). Resets the infra
    /// preview to that zone's ports (pending) and clears `generated`. An unknown key
    /// is ignored.
    SelectZone(String),
    /// Press **Generate**: call the injected [`ZoneGenerator`] with the selected
    /// zone (seeding the live statuses) and emit [`Effect::Generate`].
    Generate,
    /// Host push: replace the per-container statuses with a fresh snapshot.
    SetContainers(Vec<ContainerStatus>),
    /// Host push: advance ONE container (matched by name) to a new [`Step`], with an
    /// optional detail line. The live inline transition the pane paints.
    StepContainer { name: String, step: Step, #[serde(default)] detail: Option<String> },
    /// Reset back to the pre-Generate preview for the current zone.
    Reset,
}

/// Side work as data (FC-8): pressing Generate asks the host to bring up the zone's
/// infra. The pane itself does no I/O — the injected [`ZoneGenerator`] closure and
/// this data effect are the two app-agnostic wiring points.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {
    /// Bring up the default infra for this zone (the host submits the jera jobs).
    Generate(Zone),
}

/// **Widget #1** — the reusable zone-populate PANE: pick a zone, see the default
/// infra it brings up, Generate, watch live inline per-container status. A
/// [`Facet`], headless-drivable via `update_json`/`state_json`. App-agnostic: the
/// Generate action is wired by the host through an injected [`ZoneGenerator`].
pub struct ZonePane {
    title: String,
    /// All observable state (FC-3).
    state: ZonePaneState,
    /// The injected Generate action (the app wires it to jera). **Ephemeral input
    /// wiring, not observable state** — a `dyn` closure is not serializable, so it
    /// is deliberately kept OUT of [`ZonePaneState`] (mirrors `JobList`'s `source`).
    generator: Option<Box<dyn ZoneGenerator>>,
    /// The app's default-infra preview provider (pre-Generate). Ephemeral wiring like
    /// `generator` — a `dyn` closure is not serializable, so it is kept OUT of
    /// [`ZonePaneState`]. Defaults to [`default_containers`] (korp's FalkorDB + Spark)
    /// until an app calls [`with_default_infra`](Self::with_default_infra).
    default_infra: Box<dyn DefaultInfra>,
}

impl ZonePane {
    /// A new pane titled `title`, previewing [`Zone::Demo`]'s default infra, with no
    /// generator wired yet (Generate falls back to a built-in demo bring-up until
    /// [`with_generator`](Self::with_generator) is called).
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            state: ZonePaneState::default(),
            generator: None,
            default_infra: Box::new(default_containers),
        }
    }

    /// **Set this app's default-infra preview (builder form).** The app passes a
    /// closure/trait ([`DefaultInfra`]) that, given the selected [`Zone`], returns the
    /// containers the pane previews BEFORE Generate — so korp shows FalkorDB + Spark,
    /// holger shows registry + replica, dwarves a runner, ordning an event-store, and
    /// nornir none. Re-seeds the current preview immediately. If never called, the
    /// pane keeps the constellation default ([`default_containers`]).
    pub fn with_default_infra<D: DefaultInfra + 'static>(mut self, infra: D) -> Self {
        self.state.containers = infra.preview(self.selected_zone());
        self.default_infra = Box::new(infra);
        self
    }

    /// Set (or replace) this app's default-infra preview after construction. Re-seeds
    /// the current pre-Generate preview from the new provider.
    pub fn set_default_infra<D: DefaultInfra + 'static>(&mut self, infra: D) {
        self.state.containers = infra.preview(self.selected_zone());
        self.default_infra = Box::new(infra);
    }

    /// Wire the Generate action (builder form). The app passes a closure/trait that
    /// submits the zone's infra to jera and returns the initial container statuses.
    pub fn with_generator<G: ZoneGenerator + 'static>(mut self, generator: G) -> Self {
        self.generator = Some(Box::new(generator));
        self
    }

    /// Wire (or replace) the Generate action after construction.
    pub fn set_generator<G: ZoneGenerator + 'static>(&mut self, generator: G) {
        self.generator = Some(Box::new(generator));
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &ZonePaneState {
        &self.state
    }

    /// The currently selected [`Zone`] (parsed from the Model's key string; an
    /// unknown key falls back to [`Zone::Demo`]).
    pub fn selected_zone(&self) -> Zone {
        Zone::from_key(&self.state.selected).unwrap_or_default()
    }

    /// The default-infra containers + their live status.
    pub fn containers(&self) -> &[ContainerStatus] {
        &self.state.containers
    }

    /// The derived OVERALL phase across all containers: `Failed` if any failed,
    /// `Done` if all done, `Running` if any is still being brought up, else `Idle`
    /// (the pre-Generate preview). What the pane's status line reads.
    pub fn overall(&self) -> Phase {
        if !self.state.generated {
            return Phase::Idle;
        }
        if self.state.containers.iter().any(|c| c.step == Step::Failed) {
            Phase::Failed
        } else if !self.state.containers.is_empty() && self.state.containers.iter().all(|c| c.step == Step::Done) {
            Phase::Done
        } else {
            Phase::Running
        }
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// [`Effect`]s the host should run (a `Generate` on the Generate press, else
    /// empty). Every canvas gesture routes its [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectZone(key) => {
                if let Some(z) = Zone::from_key(&key) {
                    self.state.selected = z.key().to_string();
                    // Re-seed the preview from THIS app's default-infra provider at the
                    // new zone's ports (populating a zone is isolated — different
                    // ports/namespace per zone).
                    self.state.containers = self.default_infra.preview(z);
                    self.state.generated = false;
                }
                Vec::new()
            }
            Msg::Generate => {
                let zone = self.selected_zone();
                // Seed the live statuses from the injected generator (the app's jera
                // submit). No generator wired → a built-in demo bring-up so the pane
                // is alive on its own (Starting), never a dead button.
                let seeded = match self.generator.as_mut() {
                    Some(g) => g.generate(zone),
                    None => self.default_infra.preview(zone).into_iter().map(|c| c.step(Step::Starting)).collect(),
                };
                self.state.containers = seeded;
                self.state.generated = true;
                #[cfg(feature = "testmatrix")]
                facett_core::testmatrix::emit(
                    "facett-zoneui::ZonePane",
                    "generate",
                    true,
                    &format!("zone={} containers={}", zone.key(), self.state.containers.len()),
                );
                vec![Effect::Generate(zone)]
            }
            Msg::SetContainers(cs) => {
                self.state.containers = cs;
                Vec::new()
            }
            Msg::StepContainer { name, step, detail } => {
                if let Some(c) = self.state.containers.iter_mut().find(|c| c.name == name) {
                    c.step = step;
                    if let Some(d) = detail {
                        c.detail = d;
                    }
                }
                Vec::new()
            }
            Msg::Reset => {
                let z = self.selected_zone();
                self.state.containers = self.default_infra.preview(z);
                self.state.generated = false;
                Vec::new()
            }
        }
    }

    /// **FC-9 render** — a **pure** function of `&self`: paints the zone picker, the
    /// default-infra list, the Generate button, and the live per-container status,
    /// and *returns* the [`Msg`]s the interactions produced. It MUST NOT mutate the
    /// [`ZonePaneState`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_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 = theme(ui);

        ui.label(egui::RichText::new("Populate a zone").color(th.text).strong().size(15.0));
        ui.label(
            egui::RichText::new("Pick the environment, then bring up its default infrastructure.")
                .color(th.text_dim)
                .small(),
        );
        ui.add_space(6.0);

        // ── zone picker (demo/test/prod) ──────────────────────────────────────
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new("Zone").color(th.text_dim));
            let selected = self.selected_zone();
            for z in Zone::ALL {
                let sel = selected == z;
                if ui.selectable_label(sel, z.title()).clicked() && !sel {
                    msgs.push(Msg::SelectZone(z.key().to_string()));
                }
            }
        });
        ui.add_space(6.0);

        // ── default infra it will bring up (the 2 containers) ─────────────────
        ui.label(egui::RichText::new("Default infrastructure").color(th.text).strong());
        ui.label(
            egui::RichText::new(format!("2 containers · namespace {}", self.selected_zone().namespace("app")))
                .color(th.text_dim)
                .small(),
        );
        ui.add_space(2.0);
        for c in &self.state.containers {
            ui.horizontal_wrapped(|ui| {
                let col = c.step.color(&th);
                ui.label(egui::RichText::new(c.step.glyph()).color(col).strong());
                ui.label(egui::RichText::new(&c.name).color(th.text).strong());
                ui.label(egui::RichText::new(format!(":{}", c.port)).monospace().color(th.text_dim));
                ui.label(egui::RichText::new(&c.image).color(th.text_dim).small());
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    ui.label(egui::RichText::new(c.step.as_str()).color(col).small());
                    if !c.detail.is_empty() {
                        ui.add_space(8.0);
                        ui.label(egui::RichText::new(&c.detail).color(th.text_dim).italics().small());
                    }
                });
            });
        }
        ui.add_space(8.0);

        // ── Generate + live inline status ─────────────────────────────────────
        ui.horizontal(|ui| {
            if ui.button(egui::RichText::new("⚙ Generate").strong()).clicked() {
                msgs.push(Msg::Generate);
            }
            if self.state.generated && ui.button("Reset").clicked() {
                msgs.push(Msg::Reset);
            }
            // Live overall status (fail-loud).
            let phase = self.overall();
            ui.label(egui::RichText::new(phase.label()).color(phase.color(&th)).strong());
        });

        // Fail-loud banner: name every container that failed.
        let failed: Vec<&ContainerStatus> = self.state.containers.iter().filter(|c| c.step == Step::Failed).collect();
        if !failed.is_empty() {
            ui.add_space(4.0);
            for c in failed {
                let msg = if c.detail.is_empty() { "failed".to_string() } else { c.detail.clone() };
                ui.label(
                    egui::RichText::new(format!("✗ {} — {}", c.name, msg))
                        .color(facett_core::look::RED)
                        .strong(),
                );
            }
        }

        // ── render-lane emit: this view path RAN ──────────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-zoneui::ZonePane::view",
            "ui_render",
            true,
            &format!(
                "zone={} generated={} containers={}",
                self.state.selected,
                self.state.generated,
                self.state.containers.len(),
            ),
        );

        msgs
    }

    /// The rich `state_json` (LAW 4): the selected zone + per-container status a
    /// headless driver reads, plus the derived overall phase and a
    /// `has_demo_button` flag pinned `false` (RED if a bare demo button reappears).
    fn state_json_impl(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "zone": self.state.selected,
            "generated": self.state.generated,
            "overall": self.overall().as_str(),
            // The design's guard: NO bare demo button — the populate is a zone pane.
            "has_demo_button": false,
            "containers": self.state.containers.iter().map(|c| serde_json::json!({
                "name": c.name,
                "image": c.image,
                "port": c.port,
                "step": c.step.as_str(),
                "detail": c.detail,
                "active": c.step.is_active(),
            })).collect::<Vec<_>>(),
        })
    }
}

/// The derived OVERALL phase of a [`ZonePane`]'s bring-up (across all containers).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
    /// Pre-Generate preview (nothing brought up yet).
    Idle,
    /// At least one container is still coming up.
    Running,
    /// Every container is up, healthy, and filled.
    Done,
    /// At least one container failed.
    Failed,
}

impl Phase {
    /// Stable lowercase tag (matches `state_json`).
    pub fn as_str(self) -> &'static str {
        match self {
            Phase::Idle => "idle",
            Phase::Running => "running",
            Phase::Done => "done",
            Phase::Failed => "failed",
        }
    }
    /// The human status-line label.
    pub fn label(self) -> &'static str {
        match self {
            Phase::Idle => "ready",
            Phase::Running => "bringing up…",
            Phase::Done => "done ✓",
            Phase::Failed => "failed ✗",
        }
    }
    fn color(self, th: &Theme) -> egui::Color32 {
        match self {
            Phase::Idle => th.text_dim,
            Phase::Running => egui::Color32::from_rgb(232, 193, 42),
            Phase::Done => th.point,
            Phase::Failed => facett_core::look::RED,
        }
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl Elm for ZonePane {
    type Model = ZonePaneState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &ZonePaneState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        ZonePane::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        ZonePane::view(self, ui)
    }
}

// Form 3 (`custom_state_json`): the pane publishes a RICHER `state_json` than a
// plain `serde(state())` — the derived `overall` phase + the `has_demo_button`
// guard + per-container `active` flag — so the default is suppressed and this one
// supplied. `update_json` routes serde-tagged JSON straight through `update`, so a
// headless robot drives the pane end-to-end.
facett_core::impl_facet_via_elm!(ZonePane, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        self.state_json_impl()
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = <Self as Elm>::update(self, msg);
        }
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }

    fn kind(&self) -> &'static str {
        "zonepane"
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::harness;

    #[test]
    fn default_zone_is_demo_and_previews_two_containers() {
        let p = ZonePane::new("Zone");
        assert_eq!(p.selected_zone(), Zone::Demo);
        assert_eq!(p.containers().len(), 2, "the 2 default containers");
        assert_eq!(p.containers()[0].name, "FalkorDB");
        assert_eq!(p.containers()[1].name, "Spark");
        assert_eq!(p.containers()[0].port, FALKORDB_PORT, "demo has no port offset");
        assert_eq!(p.overall(), Phase::Idle, "nothing generated yet");
    }

    #[test]
    fn zones_do_not_collide_on_ports_or_namespace() {
        use std::collections::BTreeSet;
        let ports: BTreeSet<u16> = Zone::ALL.iter().map(|z| FALKORDB_PORT + z.port_offset()).collect();
        let ns: BTreeSet<String> = Zone::ALL.iter().map(|z| z.namespace("korp")).collect();
        assert_eq!(ports.len(), Zone::ALL.len(), "distinct published ports");
        assert_eq!(ns.len(), Zone::ALL.len(), "distinct namespaces");
        assert_eq!(Zone::Test.namespace("dwarves"), "dwarves_test", "app-agnostic namespace");
    }

    #[test]
    fn selecting_a_zone_reoffsets_the_infra() {
        let mut p = ZonePane::new("Zone");
        let _ = p.update(Msg::SelectZone("prod".into()));
        assert_eq!(p.selected_zone(), Zone::Prod);
        assert_eq!(p.containers()[0].port, FALKORDB_PORT + 2, "prod offsets the FalkorDB port");
        assert_eq!(p.containers()[1].port, SPARK_PORT + 2, "prod offsets the Spark port");
    }

    /// **RED-when-broken: Generate drives the injected closure with the SELECTED
    /// zone.** If Generate stopped calling the wired action (a dead button), the
    /// recorded zone would stay `None` and this fails.
    #[test]
    fn generate_drives_the_injected_generator_with_the_selected_zone() {
        use std::cell::RefCell;
        use std::rc::Rc;
        let seen: Rc<RefCell<Option<Zone>>> = Rc::new(RefCell::new(None));
        let seen2 = seen.clone();
        let mut p = ZonePane::new("Zone").with_generator(move |zone: Zone| {
            *seen2.borrow_mut() = Some(zone);
            vec![ContainerStatus::new("FalkorDB", FALKORDB_IMAGE, 6380).step(Step::HealthCheck)]
        });
        // Pick test, then Generate → the closure must be called with Zone::Test.
        let _ = p.update(Msg::SelectZone("test".into()));
        let fx = p.update(Msg::Generate);
        assert_eq!(*seen.borrow(), Some(Zone::Test), "the wired Generate action saw the selected zone");
        assert_eq!(fx, vec![Effect::Generate(Zone::Test)], "and the FC-8 effect carries the zone");
        assert!(p.state().generated);
        assert_eq!(p.overall(), Phase::Running, "the seeded container is mid bring-up");
    }

    /// **RED-when-broken: an app-set default-infra preview shows THROUGH — the pane
    /// no longer hardcodes FalkorDB + Spark.** holger sets registry + replica; if the
    /// pane reverted to the hardcoded default the first container would be `FalkorDB`
    /// (and the count 2) and every assertion below would fail.
    #[test]
    fn app_set_default_infra_previews_through_not_the_hardcoded_default() {
        // holger's infra: a registry + a replica (dwarves would set a single runner).
        let holger_infra = |zone: Zone| {
            vec![
                ContainerStatus::new("registry", "docker.io/library/registry:2", zone.port_on(5000)),
                ContainerStatus::new("replica", "docker.io/library/registry:2", zone.port_on(5001)),
            ]
        };
        let p = ZonePane::new("Zone").with_default_infra(holger_infra);
        // The PRE-Generate preview is holger's, not the hardcoded FalkorDB + Spark.
        assert_eq!(p.containers().len(), 2);
        assert_eq!(p.containers()[0].name, "registry", "app-set preview shows through");
        assert_eq!(p.containers()[1].name, "replica");
        assert_ne!(p.containers()[0].name, "FalkorDB", "NOT the hardcoded default");
        // Switching zone re-seeds from the SAME app provider (re-offset ports), never
        // reverting to the hardcoded FalkorDB + Spark.
        let mut p = p;
        let _ = p.update(Msg::SelectZone("prod".into()));
        assert_eq!(p.containers()[0].name, "registry", "zone-switch keeps the app infra");
        assert_eq!(p.containers()[0].port, 5000 + 2, "prod offsets the app infra's port");
        // Reset returns to the app preview, not the hardcoded default.
        let _ = p.update(Msg::Reset);
        assert_eq!(p.containers()[0].name, "registry", "reset keeps the app infra");
        // nornir sets NONE (empty preview) — the seam supports "no default infra".
        let none = ZonePane::new("Zone").with_default_infra(|_z: Zone| Vec::new());
        assert!(none.containers().is_empty(), "nornir shows no default infra");
    }

    /// The crate FALLBACK is preserved: an app that never calls `with_default_infra`
    /// still previews FalkorDB + Spark (korp's infra).
    #[test]
    fn unset_default_infra_falls_back_to_falkordb_spark() {
        let p = ZonePane::new("Zone");
        assert_eq!(p.containers()[0].name, "FalkorDB");
        assert_eq!(p.containers()[1].name, "Spark");
    }

    #[test]
    fn step_container_advances_one_and_fail_is_loud_in_state() {
        let mut p = ZonePane::new("Zone");
        let _ = p.update(Msg::Generate); // no generator → both Starting
        let _ = p.update(Msg::StepContainer { name: "FalkorDB".into(), step: Step::Done, detail: Some("healthy".into()) });
        let _ = p.update(Msg::StepContainer { name: "Spark".into(), step: Step::Failed, detail: Some("image missing".into()) });
        assert_eq!(p.overall(), Phase::Failed, "one failed → overall failed (fail-loud)");
        let j = Facet::state_json(&p);
        assert_eq!(j["overall"], "failed");
        assert_eq!(j["has_demo_button"], false, "no bare demo button — the guard");
    }

    #[test]
    fn all_done_is_overall_done_headless() {
        let mut p = ZonePane::new("Zone");
        let snap = harness::snapshot(&mut p, [
            Msg::Generate,
            Msg::StepContainer { name: "FalkorDB".into(), step: Step::Done, detail: None },
            Msg::StepContainer { name: "Spark".into(), step: Step::Done, detail: None },
        ]);
        assert!(snap.generated);
        assert!(snap.containers.iter().all(|c| c.step == Step::Done));
        assert_eq!(p.overall(), Phase::Done);
    }

    #[test]
    fn zone_serde_round_trips_through_the_model() {
        let mut p = ZonePane::new("Zone");
        let _ = p.update(Msg::SelectZone("test".into()));
        let js = serde_json::to_string(p.state()).unwrap();
        let back: ZonePaneState = serde_json::from_str(&js).unwrap();
        assert_eq!(back, *p.state());
        assert_eq!(back.selected, "test");
        assert_eq!(Zone::from_key(&back.selected), Some(Zone::Test));
    }
}