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