nornir 0.4.34

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 🧬 **`nornir` root pane** — the server/root operations that are *not*
//! workspace-scoped data views: workspace **lifecycle** (Add / Kill), **Populate**
//! and **Refresh/Poll-now**, plus the server **status / version**.
//!
//! Every other pane is a *view onto one workspace's data*; this one is where a
//! workspace is born and where it dies. (The user notes it could equally have
//! been called "server" — it holds the root, server-level operations.)
//!
//! # Operation layout (decision: ops live in the pane they belong to)
//! * **Add workspace** — form (name + descriptor path/URL + mode + poll) →
//!   `Workspaces.Register` (eager populate). CLI: `nornir workspace add`.
//! * **Kill workspace** — a confirm-gated de-register → `Workspaces.Remove`.
//!   CLI: `nornir workspace rm`.
//! * **Populate** — clone members now, build async → `Workspaces.Fetch{background}`.
//!   CLI: `nornir workspace populate`.
//! * **Refresh / Poll-now** — force a fetch+rebuild now → `Workspaces.Fetch{force}`.
//!   CLI: `nornir workspace fetch`.
//! * **Server status** — `Health.Ping` (version + repo count + status).
//!
//! All are **remote-only** (they drive a running `nornir-server`); in local mode
//! the pane shows the same one-line hint as the other server-backed panes.
//!
//! Follows the facett discovery contract the test matrix walks: a `*View`-named
//! type with `local()`/`remote()` ctors, a [`state_json`](NornirRootView::state_json)
//! that reports every button + form field + last result (LAW #6 — "see what the
//! user sees" as data), so the headless matrix mechanically discovers the pane and
//! its operations.

use eframe::egui::{self};

use super::action_log::{ActionLog, Kind};
use super::facett_theme::{Theme, GREEN, RED};
use super::ops_tabs::Server;
use super::remote;

/// 🧬 The `nornir` root pane state. Remote-only; holds the Add-workspace form,
/// the kill-confirm latch, and the last result of each root operation so the
/// rendered outcome survives between frames and is reported in `state_json`.
pub struct NornirRootView {
    /// Add-workspace form fields.
    new_name: String,
    new_descriptor: String,
    new_mode: String, // "monitored" | "pushed" | "external"
    new_poll: String,
    /// The workspace selected for the (confirm-gated) Kill button.
    kill_target: String,
    /// Two-step confirm latch for Kill (a destructive root op).
    kill_armed: bool,
    /// Last result of each operation (rendered + reported), `Ok(msg)`/`Err(msg)`.
    last_add: Option<Result<String, String>>,
    last_kill: Option<Result<String, String>>,
    last_populate: Option<Result<String, String>>,
    last_refresh: Option<Result<String, String>>,
    /// Cached `Health.Ping` server identity, fetched on demand.
    server_info: Option<Result<remote::ServerInfo, String>>,
    theme: Theme,
}

impl Default for NornirRootView {
    fn default() -> Self {
        Self {
            new_name: String::new(),
            new_descriptor: String::new(),
            new_mode: "monitored".into(),
            new_poll: "60s".into(),
            kill_target: String::new(),
            kill_armed: false,
            last_add: None,
            last_kill: None,
            last_populate: None,
            last_refresh: None,
            server_info: None,
            theme: Theme::default(),
        }
    }
}

impl NornirRootView {
    /// Local-warehouse ctor (no server → the pane shows the server-backed hint).
    pub fn local() -> Self {
        Self::default()
    }

    /// Remote (thin-client) ctor — identical state; the live `Server` handle is
    /// passed per-frame to [`draw`](Self::draw), mirroring the other ops panes.
    pub fn remote() -> Self {
        Self::default()
    }

    /// Re-skin with a facett palette (broadcast from the top-bar picker).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Render the root pane. `srv` is `None` in local mode (shows the hint).
    /// `workspaces` is the current picker list (the kill-target choices).
    /// Returns `true` when an operation mutated the server's workspace set (Add /
    /// Kill / Populate / Refresh succeeded) so the caller can re-list workspaces +
    /// reload the view immediately, the same contract `WorkspacePanel::draw_controls`
    /// uses for ⟳ Sync now.
    #[must_use]
    pub fn draw(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspaces: &[String],
        log: &ActionLog,
    ) -> bool {
        let theme = self.theme;
        ui.heading("🧬 nornir — server & workspace operations");
        let Some(srv) = srv else {
            ui.add_space(20.0);
            ui.label(
                "The nornir root pane is server-backed — launch the viz against a running \
                 nornir-server (NORNIR_SERVER=…) to add/kill/populate workspaces.",
            );
            return false;
        };
        let mut mutated = false;

        // ── Server status (Health.Ping) ──────────────────────────────────────
        ui.separator();
        ui.horizontal(|ui| {
            ui.strong("server:");
            if ui.button("⟳ status").on_hover_text("Health.Ping — version + repo count").clicked() {
                log.push(Kind::Rpc, "Health.Ping".to_string());
                self.server_info =
                    Some(remote::ping(&srv.endpoint, &srv.token).map_err(|e| format!("{e:#}")));
            }
            match &self.server_info {
                Some(Ok(i)) => {
                    ui.colored_label(
                        GREEN,
                        format!("{} · nornir {} · {} repo(s)", i.status, i.version, i.repo_count),
                    );
                }
                Some(Err(e)) => { ui.colored_label(RED, e); }
                None => { ui.colored_label(theme.text_dim, format!("{}", srv.endpoint)); }
            }
        });

        // ── Add workspace (Workspaces.Register, eager populate) ───────────────
        ui.separator();
        ui.strong("âž• add workspace");
        egui::Grid::new("nornir_add_ws").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
            ui.label("name:");
            ui.add(egui::TextEdit::singleline(&mut self.new_name).desired_width(220.0).hint_text("workspace name"));
            ui.end_row();
            ui.label("descriptor:");
            ui.add(
                egui::TextEdit::singleline(&mut self.new_descriptor)
                    .desired_width(360.0)
                    .hint_text("server-readable nornir-workspace.toml path/URL"),
            );
            ui.end_row();
            ui.label("mode:");
            egui::ComboBox::from_id_salt("nornir_add_mode")
                .selected_text(self.new_mode.clone())
                .show_ui(ui, |ui| {
                    for m in ["monitored", "pushed", "external"] {
                        ui.selectable_value(&mut self.new_mode, m.to_string(), m);
                    }
                });
            ui.end_row();
            ui.label("poll:");
            ui.add(egui::TextEdit::singleline(&mut self.new_poll).desired_width(100.0).hint_text("60s"));
            ui.end_row();
        });
        if ui
            .button("âž• Add workspace")
            .on_hover_text("Workspaces.Register (eager populate) — CLI: nornir workspace add")
            .clicked()
            && !self.new_name.trim().is_empty()
        {
            log.push(Kind::Rpc, format!("Workspaces.Register name={}", self.new_name));
            self.last_add = Some(
                remote::register_workspace(
                    &srv.endpoint,
                    &srv.token,
                    self.new_name.trim(),
                    self.new_descriptor.trim(),
                    &self.new_mode,
                    self.new_poll.trim(),
                )
                .map(|(name, mode, members)| format!("registered `{name}` ({mode}, {members} member(s))"))
                .map_err(|e| format!("{e:#}")),
            );
            mutated |= matches!(self.last_add, Some(Ok(_)));
        }
        result_line(ui, theme, &self.last_add);

        // ── Kill workspace (Workspaces.Remove, confirm-gated) ─────────────────
        ui.separator();
        ui.strong("🗑 kill workspace");
        if self.kill_target.is_empty() {
            self.kill_target = workspaces.first().cloned().unwrap_or_default();
        }
        ui.horizontal(|ui| {
            egui::ComboBox::from_id_salt("nornir_kill_target")
                .selected_text(if self.kill_target.is_empty() { "—".into() } else { self.kill_target.clone() })
                .show_ui(ui, |ui| {
                    for w in workspaces {
                        ui.selectable_value(&mut self.kill_target, w.clone(), w);
                    }
                });
            if !self.kill_armed {
                if ui.button("🗑 Kill…").clicked() && !self.kill_target.trim().is_empty() {
                    self.kill_armed = true;
                }
            } else {
                ui.colored_label(RED, format!("âš  remove `{}`?", self.kill_target));
                if ui.button("✓ confirm kill").clicked() {
                    log.push(Kind::Rpc, format!("Workspaces.Remove name={}", self.kill_target));
                    self.last_kill = Some(
                        remote::remove_workspace(&srv.endpoint, &srv.token, self.kill_target.trim())
                            .map(|()| format!("removed `{}`", self.kill_target))
                            .map_err(|e| format!("{e:#}")),
                    );
                    mutated |= matches!(self.last_kill, Some(Ok(_)));
                    self.kill_armed = false;
                }
                if ui.button("✕ cancel").clicked() {
                    self.kill_armed = false;
                }
            }
        });
        result_line(ui, theme, &self.last_kill);

        // ── Populate + Refresh/Poll-now (Workspaces.Fetch) ────────────────────
        ui.separator();
        ui.strong("🔄 populate / refresh");
        ui.horizontal(|ui| {
            let target = if self.kill_target.is_empty() {
                workspaces.first().cloned().unwrap_or_default()
            } else {
                self.kill_target.clone()
            };
            if ui
                .button("⬇ Populate")
                .on_hover_text("Workspaces.Fetch{background} — clone members now, build async (CLI: nornir workspace populate)")
                .clicked()
                && !target.trim().is_empty()
            {
                log.push(Kind::Rpc, format!("Workspaces.Fetch(background) name={target}"));
                self.last_populate = Some(
                    fetch(&srv.endpoint, &srv.token, &target, false, true)
                        .map_err(|e| format!("{e:#}")),
                );
                mutated |= matches!(self.last_populate, Some(Ok(_)));
            }
            if ui
                .button("⟳ Refresh / poll-now")
                .on_hover_text("Workspaces.Fetch{force} — poll + rebuild this workspace now (CLI: nornir workspace fetch)")
                .clicked()
                && !target.trim().is_empty()
            {
                log.push(Kind::Rpc, format!("Workspaces.Fetch(force) name={target}"));
                self.last_refresh = Some(
                    fetch(&srv.endpoint, &srv.token, &target, true, false)
                        .map_err(|e| format!("{e:#}")),
                );
                mutated |= matches!(self.last_refresh, Some(Ok(_)));
            }
        });
        result_line(ui, theme, &self.last_populate);
        result_line(ui, theme, &self.last_refresh);

        mutated
    }

    /// The `nornir` pane's slice of `state_json` (LAW #6): every button this pane
    /// hosts (so the matrix can mechanically discover the new operation surface),
    /// the Add-workspace form's current field values, the kill-confirm state, and
    /// the last result of each root operation. No "TODO"/"not wired" sentinels.
    pub fn state_json(&self) -> serde_json::Value {
        let res = |r: &Option<Result<String, String>>| match r {
            None => serde_json::json!({ "ran": false }),
            Some(Ok(m)) => serde_json::json!({ "ran": true, "ok": true, "message": m }),
            Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
        };
        serde_json::json!({
            "palette": self.theme.name,
            // The operation buttons this pane exposes — id ⇒ the gRPC RPC each
            // fires (CLI parity in the doc). The matrix walks this to prove every
            // root op is wired.
            "buttons": [
                { "id": "server_status",     "rpc": "Health.Ping",          "heavy": false },
                { "id": "add_workspace",     "rpc": "Workspaces.Register",  "heavy": false },
                { "id": "kill_workspace",    "rpc": "Workspaces.Remove",    "heavy": false, "confirm": true },
                { "id": "populate",          "rpc": "Workspaces.Fetch",     "heavy": false },
                { "id": "refresh",           "rpc": "Workspaces.Fetch",     "heavy": false },
            ],
            "add_form": {
                "name": self.new_name,
                "descriptor": self.new_descriptor,
                "mode": self.new_mode,
                "poll": self.new_poll,
            },
            "kill": { "target": self.kill_target, "armed": self.kill_armed },
            "results": {
                "add": res(&self.last_add),
                "kill": res(&self.last_kill),
                "populate": res(&self.last_populate),
                "refresh": res(&self.last_refresh),
            },
            "server": match &self.server_info {
                None => serde_json::json!({ "pinged": false }),
                Some(Ok(i)) => serde_json::json!({
                    "pinged": true, "ok": true,
                    "status": i.status, "version": i.version, "repo_count": i.repo_count,
                }),
                Some(Err(e)) => serde_json::json!({ "pinged": true, "ok": false, "error": e }),
            },
        })
    }
}

/// Render an `Ok`/`Err` result line in the palette colours (shared by the ops).
fn result_line(ui: &mut egui::Ui, theme: Theme, r: &Option<Result<String, String>>) {
    let _ = theme;
    match r {
        Some(Ok(m)) => { ui.colored_label(GREEN, format!("✓ {m}")); }
        Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
        None => {}
    }
}

/// Thin wrapper over `Workspaces.Fetch` that yields a one-line human summary
/// (Populate uses `background=true`; Refresh uses `force=true`).
fn fetch(
    endpoint: &str,
    token: &str,
    name: &str,
    force: bool,
    background: bool,
) -> anyhow::Result<String> {
    // `fetch_workspace` only exposes `force`; the background path is the same
    // RPC with `background=true`, surfaced here via the dedicated helper.
    let (fetched, changed, errors, snapshot) = if background {
        remote::populate_workspace(endpoint, token, name)?
    } else {
        remote::fetch_workspace(endpoint, token, name, force)?
    };
    let snap = if snapshot.is_empty() { String::new() } else { format!(" → snapshot {}", &snapshot[..12.min(snapshot.len())]) };
    let errs = if errors.is_empty() { String::new() } else { format!(", {} error(s)", errors.len()) };
    Ok(format!("{} fetched, {} changed{snap}{errs}", fetched, changed.len()))
}