facett-zoneui/src/jera_central.rs
//! **Widget #2 — Jera as a CENTRAL UI panel.** A thin mount that surfaces the
//! existing [`facett_jobview::JobList`] (the jera ledger: jobs + their zone +
//! container/infra health) as a first-class reusable central panel any suite app
//! drops in. It does **not** duplicate the job tree (L5) — it OWNS a `JobList` and
//! delegates the whole render + headless drive to it, adding ONLY the thin
//! "central mount" plumbing and the per-job **zone column**:
//!
//! * each job is mounted with its [`Zone`], stamped into the row's `meta`
//! (`zone=<key>`) so it shows through jobview's existing selected-row meta line —
//! zero fork of the render path;
//! * a compact **zone summary** header (each zone present · job count) paints above
//! the tree — the visible "zone column";
//! * `state_json` gains a `zones` map (job id → zone key), a `zone_summary`
//! (zone key → count), and the `zones_present` list, so a headless driver reads
//! each job's zone.
//!
//! Everything else — sort, kind filter, fold/unfold, selection, the streaming
//! hierarchy, the motion state — is the unmodified `JobList`.
use std::collections::BTreeMap;
use facett_core::{Facet, FacetCaps, theme};
use facett_jobview::{JobEntry, JobList};
use crate::Zone;
/// A jera job mounted into the central panel WITH the zone its infra/data lives in.
/// The app-agnostic unit an app hands [`JeraCentral::set_jobs`].
#[derive(Clone, Debug, PartialEq)]
pub struct ZonedJob {
/// The job row (reused verbatim from facett-jobview — id · kind · target ·
/// status · hierarchy · meta).
pub entry: JobEntry,
/// Which zone this job's infra/data belongs to.
pub zone: Zone,
}
impl ZonedJob {
/// A job in `zone`.
pub fn new(entry: JobEntry, zone: Zone) -> Self {
Self { entry, zone }
}
}
/// The `meta` key the zone is stamped under on each mounted [`JobEntry`], so it
/// rides along through jobview's existing meta rendering + is discoverable by a host.
pub const ZONE_META_KEY: &str = "zone";
/// **The canonical user-facing name of the jera central panel — `"Jera"`.** The one
/// display string for this shared surface, so wherever the panel is mounted without
/// an explicit host title it reads consistently as `Jera` (the casing dwarves' Jera
/// tab already uses), instead of each call site inventing its own label. The crate/
/// type name (`JeraCentral`) is unchanged — this normalizes only the *menu/label*.
///
/// A host may still pass its own title to [`JeraCentral::new`] (e.g. a product that
/// deliberately brands the surface differently); [`JeraCentral::jera`] /
/// [`JeraCentral::default`] use this canonical name.
pub const JERA_MENU_NAME: &str = "Jera";
/// **Jera as a central panel** — the reusable jobs/zones/infra surface every suite
/// app mounts. A thin [`Facet`] over [`JobList`] that adds the per-job zone column.
pub struct JeraCentral {
/// The reused jera ledger (all rendering + drive delegate here — no fork).
list: JobList,
/// Job id → zone (the zone column, kept beside the reused list).
zones: BTreeMap<String, Zone>,
}
impl Default for JeraCentral {
/// An empty central panel under the canonical [`JERA_MENU_NAME`] (`"Jera"`).
fn default() -> Self {
Self::jera()
}
}
impl JeraCentral {
/// A new, empty central panel titled `title` (e.g. [`JERA_MENU_NAME`]). Prefer
/// [`jera`](Self::jera) for the canonical name; pass an explicit `title` only
/// when a host deliberately brands the surface differently.
pub fn new(title: impl Into<String>) -> Self {
Self { list: JobList::new(title), zones: BTreeMap::new() }
}
/// A new, empty central panel under the **canonical** [`JERA_MENU_NAME`]
/// (`"Jera"`) — the one-liner every host should reach for so the jera surface is
/// named consistently wherever it is mounted.
pub fn jera() -> Self {
Self::new(JERA_MENU_NAME)
}
/// Mount `title`'s jobs (builder form of [`set_jobs`](Self::set_jobs)).
pub fn from_jobs(title: impl Into<String>, jobs: Vec<ZonedJob>) -> Self {
let mut me = Self::new(title);
me.set_jobs(jobs);
me
}
/// **Mount the jobs** — the central-mount entry point an app calls each refresh.
/// Records each job's zone (the zone column) and stamps it into the row's `meta`
/// (`zone=<key>`), then hands the rows to the reused [`JobList`]. Idempotent:
/// replaces the whole set (like [`JobList::set_jobs`]).
pub fn set_jobs(&mut self, jobs: Vec<ZonedJob>) {
self.zones.clear();
let entries: Vec<JobEntry> = jobs
.into_iter()
.map(|zj| {
self.zones.insert(zj.entry.id.clone(), zj.zone);
stamp_zone(zj.entry, zj.zone)
})
.collect();
self.list.set_jobs(entries);
}
/// The reused underlying [`JobList`] (immutable) — a host that wants the full
/// jobview API (kinds, counts, tree, visible order) reaches through here.
pub fn list(&self) -> &JobList {
&self.list
}
/// The reused underlying [`JobList`] (mutable) — for a host that drives it (sort,
/// fold, select) directly.
pub fn list_mut(&mut self) -> &mut JobList {
&mut self.list
}
/// The zone a mounted job is in (`None` if the id isn't mounted).
pub fn zone_of(&self, job_id: &str) -> Option<Zone> {
self.zones.get(job_id).copied()
}
/// The zones present, in canonical order, each with its job count — the data the
/// summary header + `state_json.zone_summary` are built from.
pub fn zone_summary(&self) -> Vec<(Zone, usize)> {
Zone::ALL
.iter()
.filter_map(|z| {
let n = self.zones.values().filter(|v| *v == z).count();
(n > 0).then_some((*z, n))
})
.collect()
}
}
/// Stamp `zone` into `entry.meta` under [`ZONE_META_KEY`] (replacing any prior zone
/// stamp), so the zone shows through jobview's meta line and survives a re-mount.
fn stamp_zone(mut entry: JobEntry, zone: Zone) -> JobEntry {
entry.meta.retain(|(k, _)| k != ZONE_META_KEY);
entry.meta.push((ZONE_META_KEY.to_string(), zone.key().to_string()));
entry
}
impl Facet for JeraCentral {
fn title(&self) -> &str {
Facet::title(&self.list)
}
fn ui(&mut self, ui: &mut egui::Ui) {
// The thin visible "zone column": a compact zone summary above the reused
// job tree. Then delegate the whole tree render to the unmodified JobList.
let th = theme(ui);
let summary = self.zone_summary();
if !summary.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("zones").color(th.text_dim).small());
for (z, n) in summary {
ui.label(
egui::RichText::new(format!("{} ({n})", z.title()))
.color(th.accent)
.strong(),
);
}
});
ui.separator();
}
self.list.ui(ui);
}
fn state_json(&self) -> serde_json::Value {
// Start from the reused JobList's rich state (jobs tree, counts, motion, …)
// and ADD the zone column: id→zone, the per-zone summary, and zones present.
let mut v = self.list.state_json();
let zones: serde_json::Map<String, serde_json::Value> = self
.zones
.iter()
.map(|(id, z)| (id.clone(), serde_json::Value::String(z.key().to_string())))
.collect();
let summary: serde_json::Map<String, serde_json::Value> = self
.zone_summary()
.into_iter()
.map(|(z, n)| (z.key().to_string(), serde_json::Value::from(n)))
.collect();
let present: Vec<&str> = self.zone_summary().into_iter().map(|(z, _)| z.key()).collect();
if let Some(obj) = v.as_object_mut() {
obj.insert("zones".to_string(), serde_json::Value::Object(zones));
obj.insert("zone_summary".to_string(), serde_json::Value::Object(summary));
obj.insert("zones_present".to_string(), serde_json::json!(present));
}
v
}
/// Forward the headless-drive seam to the reused [`JobList`] so the central panel
/// stays fully drivable (sort / fold / select) exactly like jobview.
fn update_json(&mut self, msg_json: &str) {
self.list.update_json(msg_json);
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable()
}
fn kind(&self) -> &'static str {
"jera_central"
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use facett_jobview::JobStatus;
fn seeded() -> JeraCentral {
JeraCentral::from_jobs(
"Jera",
vec![
ZonedJob::new(
JobEntry {
target: "korp".into(),
detail: "populate demo".into(),
..JobEntry::new("pop-demo", "zone_populate", JobStatus::Running)
},
Zone::Demo,
),
ZonedJob::new(
JobEntry::new("falkordb-test", "container", JobStatus::Done),
Zone::Test,
),
],
)
}
#[test]
fn mount_records_the_zone_and_stamps_meta() {
let jc = seeded();
assert_eq!(jc.zone_of("pop-demo"), Some(Zone::Demo));
assert_eq!(jc.zone_of("falkordb-test"), Some(Zone::Test));
// The zone rode into the reused JobList as row meta (no fork of the tree).
let entry = jc.list().jobs().iter().find(|j| j.id == "pop-demo").unwrap();
assert!(entry.meta.iter().any(|(k, v)| k == ZONE_META_KEY && v == "demo"));
}
/// **RED-when-broken: the central panel lists a seeded job WITH its zone.** If
/// the zone column regressed (state lost the id→zone map), this fails.
#[test]
fn state_json_lists_jobs_with_their_zone() {
let jc = seeded();
let j = jc.state_json();
// The reused jobview tree is present (jobs listed)…
let jobs = j["jobs"].as_array().expect("jobview jobs[] present");
assert!(jobs.iter().any(|r| r["id"] == "pop-demo"), "the seeded job is listed");
// …and the added zone column resolves each job's zone.
assert_eq!(j["zones"]["pop-demo"], "demo", "the seeded job carries its zone");
assert_eq!(j["zones"]["falkordb-test"], "test");
assert_eq!(j["zone_summary"]["demo"], 1);
assert_eq!(j["zone_summary"]["test"], 1);
}
#[test]
fn remount_replaces_the_zone_map() {
let mut jc = seeded();
jc.set_jobs(vec![ZonedJob::new(JobEntry::new("only", "x", JobStatus::Queued), Zone::Prod)]);
assert_eq!(jc.zone_of("pop-demo"), None, "old jobs gone");
assert_eq!(jc.zone_of("only"), Some(Zone::Prod));
assert_eq!(jc.zone_summary(), vec![(Zone::Prod, 1)]);
}
/// The canonical menu name normalizes the jera surface's label to `"Jera"`
/// wherever it is mounted without an explicit host title.
#[test]
fn canonical_jera_menu_name_is_consistent() {
assert_eq!(JERA_MENU_NAME, "Jera");
assert_eq!(Facet::title(&JeraCentral::jera()), "Jera", "jera() uses the canonical name");
assert_eq!(Facet::title(&JeraCentral::default()), "Jera", "default() too");
// An explicit host title still overrides (products may brand differently).
assert_eq!(Facet::title(&JeraCentral::new("Jobs")), "Jobs");
}
#[test]
fn stamp_zone_is_idempotent() {
// Re-stamping must not accumulate duplicate zone meta entries.
let e = stamp_zone(stamp_zone(JobEntry::new("a", "k", JobStatus::Done), Zone::Demo), Zone::Test);
let zones: Vec<&String> = e.meta.iter().filter(|(k, _)| k == ZONE_META_KEY).map(|(_, v)| v).collect();
assert_eq!(zones, vec!["test"], "one zone stamp, the latest");
}
}