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