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

nordisk/holger-egui

src/data.rs

//! Transport-agnostic view-model for the holger UI.
//!
//! Everything the UI renders is produced here by calling the core
//! [`HolgerObject`] API. That trait is satisfied by `LocalHolger` (direct
//! in-process rust calls into holger core) or `RemoteHolger` (gRPC) — the UI
//! does not care which. There is **no egui in this module**: every view is plain
//! data with a `state_json()` method, so the UI's state can be asserted
//! headlessly (the nordisk "see what the user sees as data" law).
//!
//! The single [`UiData`] handle owns a private current-thread tokio runtime and
//! runs exactly one core call per UI action (the nornir-viz idiom), so an egui
//! `update` loop can stay synchronous.

use std::sync::Arc;

use serde::Serialize;
use serde_json::{json, Value};
use traits::{
    ArtifactId, Health, HolgerObject, PropertyKv, RepositoryInfo, SearchArtifactHit,
    SearchPathHit, SearchQuery, ServerProfile,
};

/// Operator-tool config/result types (Tools#1–#5) — split into a submodule and
/// re-exported so `crate::data::{DsConfig, MigrateRun, DeployConfig, …}` is unchanged.
mod tools;
pub use tools::*;

/// The live artifact-lineage view-model (the "proxy-cache-filling" facet) — a
/// transport-agnostic feed of cache arrivals maintained as a growing node/edge/
/// time-event graph. Re-exported so `crate::data::{LineageView, LineageEvent, …}`.
mod lineage;
pub use lineage::*;

/// Archive browse view — backs the Archive tab (`ArchiveService`). Holds the raw
/// file paths inside the znippy archive backing `repository` plus its stats
/// (file count, total uncompressed bytes, archive/repo name). `error` carries a
/// core/transport failure from either the `archive_info` or `list_archive_files`
/// call that fed it.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArchiveView {
    pub repository: String,
    pub files: Vec<String>,
    pub file_count: u64,
    pub total_uncompressed_bytes: u64,
    pub archive_path: String,
    pub error: Option<String>,
}

impl ArchiveView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// One repository row in the picker/sidebar — mirrors [`RepositoryInfo`].
///
/// `writable` is exactly the flag the Upload control reads; `has_archive` gates
/// the Archive tab.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RepoRow {
    pub name: String,
    pub repo_type: String,
    pub writable: bool,
    pub has_archive: bool,
}

impl From<RepositoryInfo> for RepoRow {
    fn from(r: RepositoryInfo) -> Self {
        Self {
            name: r.name,
            repo_type: r.repo_type,
            writable: r.writable,
            has_archive: r.has_archive,
        }
    }
}

/// Status / About view — backs the Status tab (`Health`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct StatusView {
    pub status: String,
    pub version: String,
    pub uptime_seconds: i64,
    /// `true` once a successful `health()` has populated this view.
    pub loaded: bool,
    /// Last error from `health()`, if any.
    pub error: Option<String>,
}

impl StatusView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// Server-profile view — server truth backing the static "silent running"
/// chrome (`AdminService.ServerProfile`). The UI drives `static_mode` and the
/// badge off this, not off a local toggle.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ProfileView {
    /// `"static"` (sealed / read-only) or `"dynamic"` (writable / proxy).
    pub profile: String,
    pub read_only: bool,
    /// Human badge text (e.g. the red "silent running" strip).
    pub label: String,
    pub writable_repo_count: i32,
    /// `true` once a successful `server_profile()` has populated this view.
    pub loaded: bool,
    pub error: Option<String>,
}

impl Default for ProfileView {
    fn default() -> Self {
        let p = ServerProfile::default();
        Self {
            profile: p.profile,
            read_only: p.read_only,
            label: p.label,
            writable_repo_count: p.writable_repo_count,
            loaded: false,
            error: None,
        }
    }
}

impl ProfileView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// Repo picker view — backs the Repos tab (`ListRepositories`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ReposView {
    pub repos: Vec<RepoRow>,
    /// Index into `repos` of the active selection, if any.
    pub selected: Option<usize>,
    pub error: Option<String>,
}

impl ReposView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }

    /// The currently selected repo row, if any.
    pub fn selected_repo(&self) -> Option<&RepoRow> {
        self.selected.and_then(|i| self.repos.get(i))
    }

    /// Whether the Upload control should be enabled: a writable repo is selected.
    /// Exactly the `RepositoryInfo.writable` gate from the UI brief.
    pub fn upload_enabled(&self) -> bool {
        self.selected_repo().map(|r| r.writable).unwrap_or(false)
    }
}

/// Artifact fetch view — backs the Artifact tab (`FetchArtifact`).
///
/// The raw bytes are kept on the view but **never** serialized into
/// `state_json()` (only `size_bytes`), so headless snapshots stay small and
/// don't leak payloads.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArtifactView {
    pub repository: String,
    pub namespace: Option<String>,
    pub name: String,
    pub version: String,
    pub size_bytes: u64,
    pub content_type: String,
    /// `true` if the last fetch returned bytes; `false` for a clean `NOT_FOUND`.
    pub found: bool,
    /// Transport/server error (distinct from a clean not-found).
    pub error: Option<String>,
    /// Custom properties on the fetched coordinate (`PropertyService`), loaded
    /// alongside a successful fetch. Empty when none (or the store is absent).
    pub properties: Vec<PropertyKv>,
    /// The hosted SBOM's metadata + a short text preview, when fetched on demand
    /// (`SbomService.FetchSbom`). `None` until requested / when none is attached.
    pub sbom: Option<SbomView>,
    /// Transport/server error from the on-demand SBOM fetch.
    pub sbom_error: Option<String>,
    /// Where the last ⬇ save wrote the artifact bytes, when one succeeded.
    pub saved_to: Option<String>,
    /// Why the last ⬇ save failed, when one did.
    pub save_error: Option<String>,
    #[serde(skip)]
    pub data: Vec<u8>,
}

/// The hosted SBOM document's observable summary (bytes are NOT serialized —
/// only size, content address, and a short preview for the inspector).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SbomView {
    pub size_bytes: i64,
    /// blake3 hex of the document (its content address).
    pub content_address: String,
    /// The document's first ~2 KB as lossy UTF-8 (a human-readable teaser).
    pub preview: String,
}

impl ArtifactView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// Cross-repo search view — backs the Search tab (`SearchService.Search`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SearchView {
    /// The name-axis query the last search ran with.
    pub query: String,
    pub artifacts: Vec<SearchArtifactHit>,
    pub paths: Vec<SearchPathHit>,
    /// True when the server capped the result set (more hits exist).
    pub truncated: bool,
    /// True once a search has run (distinguishes "no hits" from "not searched").
    pub searched: bool,
    pub error: Option<String>,
}

impl SearchView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// One artifact row in a repository listing — mirrors [`traits::ArtifactEntry`]
/// (the proto `ArtifactEntry`), with the id flattened into the columns the
/// Browse table renders.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseRow {
    pub namespace: Option<String>,
    pub name: String,
    pub version: String,
    pub size_bytes: i64,
    pub content_type: String,
}

/// Browse view — backs the Browse tab (`ListArtifacts`). Holds one page of
/// entries for the active `repository` plus the opaque `next_page_token` the
/// core returned (empty when the listing is exhausted).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseView {
    pub repository: String,
    pub entries: Vec<BrowseRow>,
    pub next_page_token: String,
    pub error: Option<String>,
}

impl BrowseView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// One scanned artifact's verdict row in the Security pane — plain, serializable
/// data lifted from a `modgunn::core::Verdict` so this view compiles in the
/// default (non-gui) build with no modgunn types leaking in.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SecFinding {
    pub name: String,
    pub version: String,
    /// `"pass"` / `"warn"` / `"block"`.
    pub decision: String,
    /// Advisory / finding ids (e.g. a CVE, `MODGUNN-LICENSE-DENIED:…`).
    pub ids: Vec<String>,
    /// Human summary of the strongest finding (for the row tooltip).
    pub summary: String,
}

/// Security scan view — backs the Tools#3 (Móðguðr) pane. Holds the aggregate
/// tallies + the overall decision for the scanned repository plus the per-artifact
/// findings (clean artifacts are tallied but not listed). `error` carries a
/// core/transport failure from the listing call that fed it.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SecurityView {
    pub repository: String,
    pub ecosystem: String,
    pub scanned: usize,
    pub skipped: usize,
    pub pass: usize,
    pub warn: usize,
    pub block: usize,
    /// `"pass"` / `"warn"` / `"block"` — strongest across all artifacts.
    pub decision: String,
    pub findings: Vec<SecFinding>,
    /// `true` once a scan has populated this view.
    pub loaded: bool,
    /// Where the advisory corpus came from: `"demo"` or `"warehouse rev '<rev>'"`.
    pub source: String,
    pub error: Option<String>,
}

impl SecurityView {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// One node in the Móðguðr dependency-graph view: a scanned artifact with its
/// verdict decision and any advisory ids (the graph node's sublabel).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphNode {
    pub name: String,
    pub version: String,
    /// `"pass"` / `"warn"` / `"block"` — drives the node colour in the graph.
    pub decision: String,
    pub ids: Vec<String>,
}

/// The dependency-graph view backing the Móðguðr tool's graph mode: **every**
/// scanned artifact as a node coloured by verdict (block=red, warn=yellow,
/// pass=green) — unlike [`SecurityView::findings`], which lists only the non-clean
/// ones. Inter-dependency `edges` are sourced from the shared warehouse WHEN it has
/// a captured snapshot for this subject (`has_edges`); a served archive usually has
/// none, so the view shows the coloured node set with an honest "no resolved graph"
/// note. `state_json` lets the robot assert the colour tallies + edge count.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphData {
    pub repository: String,
    pub nodes: Vec<DepGraphNode>,
    /// `(from, to)` node-index pairs; empty unless `has_edges`.
    pub edges: Vec<(usize, usize)>,
    /// `true` when real dependency edges were sourced from the warehouse.
    pub has_edges: bool,
    pub pass: usize,
    pub warn: usize,
    pub block: usize,
    /// `true` once a scan has populated this view.
    pub loaded: bool,
    pub error: Option<String>,
}

impl DepGraphData {
    pub fn state_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// Build the Engine A scanner for `ecosystem`. Under `gui`, if `HOLGER_WAREHOUSE`
/// points at a catalog root, load the advisory corpus from modgunn's warehouse
/// (`cve_advisories`) — the real ingested feeds — falling back to the built-in
/// demo corpus when it isn't available. The (non-gui) test build always uses demo
/// (no skade/warehouse dependency). Returns the scanner + a human source label.
#[cfg(any(feature = "tools", test))]
fn scanner_for(ecosystem: &str) -> (server_lib::security::OsvScanner, String) {
    use server_lib::security;
    let _ = ecosystem; // used only by the gui (warehouse) path below
    #[cfg(feature = "warehouse-scan")]
    if let Some(root) = std::env::var("HOLGER_WAREHOUSE").ok().filter(|s| !s.is_empty()) {
        let loaded = modgunn::warehouse::Warehouse::open(std::path::Path::new(&root))
            .ok()
            .and_then(|wh| {
                wh.ensure_modgunn_tables().ok()?;
                wh.load_advisory_db(Some(ecosystem)).ok()
            });
        return match loaded {
            Some(db) => {
                let rev = db.rev.clone();
                (security::OsvScanner::new(security::demo_policy(), db), format!("warehouse rev '{rev}'"))
            }
            None => (security::demo_scanner(), "demo (warehouse unavailable)".to_string()),
        };
    }
    (security::demo_scanner(), "demo".to_string())
}


/// The UI's data layer: a holger handle + a private current-thread runtime so the
/// async core calls can be driven synchronously (one blocking call per UI
/// action — the nornir-viz idiom).
pub struct UiData {
    holger: Arc<dyn HolgerObject>,
    rt: tokio::runtime::Runtime,
    pub status: StatusView,
    /// Server operating profile (server truth for the static/silent-running mode).
    pub profile: ProfileView,
    pub repos: ReposView,
    pub artifact: ArtifactView,
    pub browse: BrowseView,
    pub archive: ArchiveView,
    /// Cross-repo search view (`SearchService.Search`) — backs the Search tab.
    pub search: SearchView,
    /// The endpoint this data layer is connected to (empty for in-process /
    /// embedded handles) — shown in the chrome so an operator can tell WHICH
    /// server the window is looking at.
    pub endpoint: String,
    pub security: SecurityView,
    /// Móðguðr dependency-graph view (all scanned artifacts coloured by verdict),
    /// populated alongside [`UiData::security`] by `run_security_scan`.
    pub dep_graph: DepGraphData,
    /// Tool #5 Airgap populate configuration (typed, pure-Rust plan). The Tools#5
    /// pane edits this; `plan()`/`state_json()` are computed with no I/O.
    pub airgap: holger_airgap::PopulateArgs,
    /// The last Tool #5 Airgap populate run result, if any.
    pub airgap_run: Option<AirgapRun>,
    /// Tool #1 holger-ds benchmark configuration.
    pub holger_ds: DsConfig,
    /// Tool #2 Migrate / suck configuration.
    pub migrate: MigrateConfig,
    /// Tool #4 Deploy (Skíðblaðnir) configuration + last run.
    pub deploy: DeployConfig,
    /// Tool #6 Demo appliance — the last jera-dispatched demo-container result, if
    /// any (the shared "Tools starts containers THROUGH jera" surface). `None` until
    /// the operator runs the demo.
    pub demo_appliance: Option<crate::infra::DemoDispatch>,
    /// The live artifact-lineage graph (the proxy-cache-filling facet): cache
    /// arrivals folded into nodes/edges/time-events, grown live (via a connected
    /// feed channel) or by replaying the audit log. Backs the Lineage tab.
    pub lineage: LineageView,
}

impl UiData {
    /// Build a data layer around any `HolgerObject` (embedded or remote).
    pub fn new(holger: Arc<dyn HolgerObject>) -> anyhow::Result<Self> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;
        Ok(Self {
            holger,
            rt,
            status: StatusView::default(),
            profile: ProfileView::default(),
            repos: ReposView::default(),
            artifact: ArtifactView::default(),
            browse: BrowseView::default(),
            archive: ArchiveView::default(),
            search: SearchView::default(),
            endpoint: String::new(),
            security: SecurityView::default(),
            dep_graph: DepGraphData::default(),
            airgap: holger_airgap::PopulateArgs::default(),
            airgap_run: None,
            holger_ds: DsConfig::default(),
            migrate: MigrateConfig::default(),
            deploy: DeployConfig::default(),
            demo_appliance: None,
            lineage: LineageView::new(),
        })
    }

    /// Build a data layer from a handle plus a caller-provided runtime. Use this
    /// when the handle's transport (e.g. a tonic channel) must be created on the
    /// **same** runtime that later drives its calls — the single-runtime tonic
    /// idiom. [`UiData::new`] is the common case (it makes its own current-thread
    /// runtime).
    pub fn with_runtime(holger: Arc<dyn HolgerObject>, rt: tokio::runtime::Runtime) -> Self {
        Self {
            holger,
            rt,
            status: StatusView::default(),
            profile: ProfileView::default(),
            repos: ReposView::default(),
            artifact: ArtifactView::default(),
            browse: BrowseView::default(),
            archive: ArchiveView::default(),
            search: SearchView::default(),
            endpoint: String::new(),
            security: SecurityView::default(),
            dep_graph: DepGraphData::default(),
            airgap: holger_airgap::PopulateArgs::default(),
            airgap_run: None,
            holger_ds: DsConfig::default(),
            migrate: MigrateConfig::default(),
            deploy: DeployConfig::default(),
            demo_appliance: None,
            lineage: LineageView::new(),
        }
    }

    /// Run one async core call to completion on the private runtime.
    fn block<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
        self.rt.block_on(fut)
    }

    /// Refresh the Status tab from `health()`.
    pub fn refresh_status(&mut self) {
        match self.block(self.holger.health()) {
            Ok(Health {
                status,
                version,
                uptime_seconds,
            }) => {
                self.status = StatusView {
                    status,
                    version,
                    uptime_seconds,
                    loaded: true,
                    error: None,
                };
            }
            Err(e) => {
                self.status.loaded = false;
                self.status.error = Some(e.to_string());
            }
        }
    }

    /// Refresh the server profile from `server_profile()` (server truth for the
    /// static / "silent running" mode). On error the view is marked unloaded and
    /// the error is surfaced, leaving the prior values in place.
    pub fn refresh_profile(&mut self) {
        match self.block(self.holger.server_profile()) {
            Ok(ServerProfile {
                profile,
                read_only,
                label,
                writable_repo_count,
            }) => {
                self.profile = ProfileView {
                    profile,
                    read_only,
                    label,
                    writable_repo_count,
                    loaded: true,
                    error: None,
                };
            }
            Err(e) => {
                self.profile.loaded = false;
                self.profile.error = Some(e.to_string());
            }
        }
    }

    /// Refresh the repo picker from `list_repositories()`. Selection is preserved
    /// by name across refreshes; a first load selects the first repo.
    pub fn refresh_repos(&mut self) {
        let prev = self.repos.selected_repo().map(|r| r.name.clone());
        match self.block(self.holger.list_repositories()) {
            Ok(list) => {
                let repos: Vec<RepoRow> = list.into_iter().map(RepoRow::from).collect();
                let selected = prev
                    .and_then(|name| repos.iter().position(|r| r.name == name))
                    .or(if repos.is_empty() { None } else { Some(0) });
                self.repos = ReposView {
                    repos,
                    selected,
                    error: None,
                };
            }
            Err(e) => self.repos.error = Some(e.to_string()),
        }
    }

    /// Select a repo by index (a click handler / headless-test entry point).
    pub fn select_repo(&mut self, idx: usize) {
        if idx < self.repos.repos.len() {
            self.repos.selected = Some(idx);
        }
    }

    /// Fetch an artifact into the Artifact tab. A clean `NOT_FOUND` is rendered
    /// as `found = false` (not an error); transport/server failures land in
    /// `error`.
    pub fn fetch_artifact(&mut self, repository: &str, id: ArtifactId) {
        let res = self.block(self.holger.fetch(repository, &id));
        let mut view = ArtifactView {
            repository: repository.to_string(),
            namespace: id.namespace.clone(),
            name: id.name.clone(),
            version: id.version.clone(),
            ..Default::default()
        };
        match res {
            Ok(Some(bytes)) => {
                view.size_bytes = bytes.len() as u64;
                view.content_type = "application/octet-stream".into();
                view.found = true;
                view.data = bytes;
                // Custom properties ride along with a successful fetch (an open,
                // cheap read; empty when the server has no property store).
                view.properties = self
                    .block(self.holger.get_properties(repository, &id))
                    .unwrap_or_default();
            }
            Ok(None) => view.found = false,
            Err(e) => view.error = Some(e.to_string()),
        }
        self.artifact = view;
    }

    /// Fetch the hosted SBOM for the artifact currently in the view (on demand —
    /// a document can be large, so it is not loaded with the fetch). `None`
    /// (nothing attached) clears the pane; errors land in `sbom_error`.
    pub fn fetch_sbom_current(&mut self) {
        let id = ArtifactId {
            namespace: self.artifact.namespace.clone(),
            name: self.artifact.name.clone(),
            version: self.artifact.version.clone(),
        };
        let repository = self.artifact.repository.clone();
        match self.block(self.holger.fetch_sbom(&repository, &id)) {
            Ok(Some(doc)) => {
                let head = &doc.document[..doc.document.len().min(2048)];
                self.artifact.sbom = Some(SbomView {
                    size_bytes: doc.size_bytes,
                    content_address: doc.content_address,
                    preview: String::from_utf8_lossy(head).into_owned(),
                });
                self.artifact.sbom_error = None;
            }
            Ok(None) => {
                self.artifact.sbom = None;
                self.artifact.sbom_error = Some("no SBOM attached".into());
            }
            Err(e) => {
                self.artifact.sbom = None;
                self.artifact.sbom_error = Some(e.to_string());
            }
        }
    }

    /// Save the fetched artifact's bytes to `path` (the ⬇ download). The bytes
    /// are already in memory from the fetch — this is a plain local write; the
    /// outcome lands on `saved_to` / `save_error` (both observable).
    pub fn save_artifact(&mut self, path: &str) {
        self.artifact.saved_to = None;
        self.artifact.save_error = None;
        if !self.artifact.found || self.artifact.data.is_empty() {
            self.artifact.save_error = Some("nothing fetched to save".into());
            return;
        }
        match std::fs::write(path, &self.artifact.data) {
            Ok(()) => self.artifact.saved_to = Some(path.to_string()),
            Err(e) => self.artifact.save_error = Some(format!("{path}: {e}")),
        }
    }

    /// Run a cross-repo search on the name axis (`SearchService.Search`) into
    /// the Search view. An empty query clears the view instead of asking the
    /// server to enumerate everything.
    pub fn refresh_search(&mut self, name_query: &str) {
        let q = name_query.trim();
        if q.is_empty() {
            self.search = SearchView::default();
            return;
        }
        let query = SearchQuery {
            name: q.to_string(),
            limit: 200,
            ..Default::default()
        };
        match self.block(self.holger.search(&query)) {
            Ok(res) => {
                self.search = SearchView {
                    query: q.to_string(),
                    artifacts: res.artifacts,
                    paths: res.paths,
                    truncated: res.truncated,
                    searched: true,
                    error: None,
                };
            }
            Err(e) => {
                self.search = SearchView {
                    query: q.to_string(),
                    searched: true,
                    error: Some(e.to_string()),
                    ..Default::default()
                };
            }
        }
    }

    /// Page size for the Browse tab.
    const BROWSE_PAGE_SIZE: u32 = 100;

    /// Refresh the Browse tab from `list_artifacts()` for `repository`,
    /// optionally narrowed by a `name_filter` substring — loads the FIRST page
    /// (up to [`Self::BROWSE_PAGE_SIZE`]). The continuation token is kept on the
    /// view; call [`UiData::load_more_browse`] to append the next page. A core
    /// error lands in `error`.
    pub fn refresh_browse(&mut self, repository: &str, name_filter: Option<String>) {
        let res = self.block(self.holger.list_artifacts(
            repository,
            name_filter,
            Self::BROWSE_PAGE_SIZE,
            None,
        ));
        let mut view = BrowseView {
            repository: repository.to_string(),
            ..Default::default()
        };
        match res {
            Ok((entries, next_page_token)) => {
                view.entries = entries
                    .into_iter()
                    .map(|e| BrowseRow {
                        namespace: e.id.namespace,
                        name: e.id.name,
                        version: e.id.version,
                        size_bytes: e.size_bytes,
                        content_type: e.content_type,
                    })
                    .collect();
                view.next_page_token = next_page_token;
            }
            Err(e) => view.error = Some(e.to_string()),
        }
        self.browse = view;
    }

    /// Append the next page of the current Browse listing using the stored
    /// continuation token. No-op when the listing is exhausted (empty token).
    pub fn load_more_browse(&mut self) {
        if self.browse.next_page_token.is_empty() {
            return;
        }
        let repository = self.browse.repository.clone();
        let token = self.browse.next_page_token.clone();
        let res = self.block(self.holger.list_artifacts(
            &repository,
            None,
            Self::BROWSE_PAGE_SIZE,
            Some(token),
        ));
        match res {
            Ok((entries, next_page_token)) => {
                self.browse
                    .entries
                    .extend(entries.into_iter().map(|e| BrowseRow {
                        namespace: e.id.namespace,
                        name: e.id.name,
                        version: e.id.version,
                        size_bytes: e.size_bytes,
                        content_type: e.content_type,
                    }));
                self.browse.next_page_token = next_page_token;
                self.browse.error = None;
            }
            Err(e) => self.browse.error = Some(e.to_string()),
        }
    }

    /// Refresh the Archive tab for `repository`: load the archive stats
    /// (`archive_info`) and the raw file paths (`list_archive_files`, optionally
    /// narrowed by a path `prefix`). Two core calls feed one view; an error from
    /// **either** lands in `error` (and leaves the rest at its default). The
    /// `repository` is always recorded so the UI shows which repo it tried.
    pub fn refresh_archive(&mut self, repository: &str, prefix: Option<String>) {
        let mut view = ArchiveView {
            repository: repository.to_string(),
            ..Default::default()
        };
        match self.block(self.holger.archive_info(repository)) {
            Ok(info) => {
                view.file_count = info.file_count;
                view.total_uncompressed_bytes = info.total_uncompressed_bytes;
                view.archive_path = info.archive_path;
            }
            Err(e) => view.error = Some(e.to_string()),
        }
        if view.error.is_none() {
            match self.block(self.holger.list_archive_files(repository, prefix)) {
                Ok(files) => view.files = files,
                Err(e) => view.error = Some(e.to_string()),
            }
        }
        self.archive = view;
    }

    /// Upload bytes to a (writable) repo. The core error is returned verbatim so
    /// the UI can map it to an affordance (PERMISSION_DENIED → "read-only", …).
    pub fn put_artifact(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        self.block(self.holger.put(repository, id, data))
    }

    /// Run a Móðguðr (modgunn) security scan over `repository` (whose ecosystem
    /// is `repo_type`): list the backing archive over the core API, map each file
    /// to a `modgunn::scan::Artifact`, and fold the per-artifact verdicts into the
    /// [`SecurityView`]. One core call (`list_archive_files`); the verdict itself
    /// is pure + offline. Gated to where `server-lib` (hence modgunn) is present:
    /// the `gui` build and the test build (dev-dependency) — the default
    /// transport-only build doesn't link modgunn.
    #[cfg(any(feature = "tools", test))]
    pub fn run_security_scan(&mut self, repository: &str, repo_type: &str) {
        use server_lib::security;
        let ecosystem = security::ecosystem_for(repo_type);
        let mut view = SecurityView {
            repository: repository.to_string(),
            ecosystem: ecosystem.clone(),
            ..Default::default()
        };
        match self.block(self.holger.list_archive_files(repository, None)) {
            Ok(files) => {
                let (scanner, source) = scanner_for(&ecosystem);
                view.source = source;
                let refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
                let report = security::scan_listing(&ecosystem, refs, &scanner);
                view.scanned = report.verdicts.len();
                view.skipped = report.skipped;
                view.pass = report.pass;
                view.warn = report.warn;
                view.block = report.block;
                view.decision = match report.decision() {
                    security::Decision::Pass => "pass",
                    security::Decision::Warn => "warn",
                    security::Decision::Block => "block",
                }
                .to_string();
                view.findings = report
                    .verdicts
                    .iter()
                    .filter(|v| !v.findings.is_empty())
                    .map(|v| SecFinding {
                        name: v.artifact.name.clone(),
                        version: v.artifact.version.clone(),
                        decision: match v.decision {
                            security::Decision::Pass => "pass",
                            security::Decision::Warn => "warn",
                            security::Decision::Block => "block",
                        }
                        .to_string(),
                        ids: v.findings.iter().map(|f| f.id.clone()).collect(),
                        summary: v.findings.first().map(|f| f.summary.clone()).unwrap_or_default(),
                    })
                    .collect();
                view.loaded = true;

                // Dependency-graph view: ALL scanned artifacts as nodes coloured by
                // verdict (the table above lists only the non-clean ones). Edges
                // come from the warehouse when a captured snapshot exists for this
                // subject; a served archive has none, so the view shows the coloured
                // node set (has_edges = false). The warehouse-edge plumbing slot is
                // here — `read_dep_edges` + `read_sbom_components` when wired.
                let mut graph = DepGraphData {
                    repository: repository.to_string(),
                    loaded: true,
                    ..Default::default()
                };
                for v in &report.verdicts {
                    let decision = match v.decision {
                        security::Decision::Pass => {
                            graph.pass += 1;
                            "pass"
                        }
                        security::Decision::Warn => {
                            graph.warn += 1;
                            "warn"
                        }
                        security::Decision::Block => {
                            graph.block += 1;
                            "block"
                        }
                    };
                    graph.nodes.push(DepGraphNode {
                        name: v.artifact.name.clone(),
                        version: v.artifact.version.clone(),
                        decision: decision.to_string(),
                        ids: v.findings.iter().map(|f| f.id.clone()).collect(),
                    });
                }
                self.dep_graph = graph;
            }
            Err(e) => {
                view.error = Some(e.to_string());
                self.dep_graph = DepGraphData {
                    repository: repository.to_string(),
                    error: Some(e.to_string()),
                    ..Default::default()
                };
            }
        }
        self.security = view;
    }

    /// Tool #1 — actually RUN the `holger-ds` artifact-delivery benchmark at the
    /// selected scale, **over the package protocol** (Holger's native HTTP door),
    /// and fold the throughput (MB/s · ops/s) into [`DsConfig::result`]. Reuses the
    /// exact bench-scenarios path the CLI verb + the head-to-head comparison use:
    /// build a synthetic crate corpus → stand up the real HTTP gateway (or drive an
    /// EXTERNAL `target` base URL) → gatling-style parallel GET pool.
    ///
    /// Inline + CI-safe: corpus budget + bombard duration are clamped per scale so
    /// `micro` completes in ~1s. The gatling pool blocks OS threads and the gateway
    /// `tokio::spawn`s its accept loop, so this drives a dedicated **multi-thread**
    /// runtime (not the data layer's current-thread one). Gated to where
    /// `bench-scenarios`/`server-lib` are linked (the `gui` build + the test build).
    #[cfg(any(feature = "bench", test))]
    pub fn run_holger_ds(&mut self) {
        use bench_scenarios::{build_crate_corpus, cores, free_disk_bytes, served};
        use std::time::Duration;

        let scale = self.holger_ds.scale.clone();
        let target = self.holger_ds.target.trim().to_string();
        // Inline-safe corpus budget (MiB) + bombard seconds per scale. micro is
        // tiny so the UI run returns quickly; small/large grow but stay bounded.
        let (cap_mib, secs): (u64, u64) = match scale.as_str() {
            "large" => (96, 3),
            "small" => (48, 2),
            _ => (16, 1), // micro
        };
        let mut res = DsResult {
            scale: scale.clone(),
            target: if target.is_empty() { "self-host".to_string() } else { target.clone() },
            ..Default::default()
        };

        let run = (|| -> anyhow::Result<()> {
            let data_dir = std::env::temp_dir().join(format!("holger-ds-ui-{scale}"));
            std::fs::create_dir_all(&data_dir)?;
            // Cap to ≤25% of free disk so a tight filesystem still runs.
            let cap = cap_mib.saturating_mul(1024 * 1024);
            let budget = match free_disk_bytes(&data_dir) {
                Some(f) => cap.min(f / 4).max(8 * 1024 * 1024),
                None => cap,
            };
            let (archive, crates) = build_crate_corpus(&data_dir, budget)?;
            res.crates = crates;
            let concurrency = cores();
            let rt = tokio::runtime::Builder::new_multi_thread()
                .worker_threads(concurrency.clamp(2, 8))
                .enable_all()
                .build()?;
            let tgt = if target.is_empty() { None } else { Some(target.as_str()) };
            let (files, outcome) = rt.block_on(served::run_crate_fetch(
                &archive,
                "rust",
                tgt,
                concurrency,
                Duration::from_secs(secs),
            ))?;
            res.files = files;
            res.ops = outcome.ops;
            res.bytes = outcome.bytes;
            res.mbs = outcome.mbs;
            res.ops_per_sec = outcome.ops_per_sec;
            Ok(())
        })();

        match run {
            Ok(()) => {
                res.ran = true;
                res.status = format!(
                    "ran {} [{}] · {} files · {:.1} MB/s · {:.0} ops/s · {} ops",
                    scale, res.target, res.files, res.mbs, res.ops_per_sec, res.ops
                );
            }
            Err(e) => {
                res.ran = false;
                res.status = format!("holger-ds failed: {e}");
                res.error = Some(e.to_string());
            }
        }
        self.holger_ds.status = res.status.clone();
        self.holger_ds.result = Some(res);
    }

    /// Tool #6 — dispatch the **demo appliance** container THROUGH the shared job
    /// manager `jera`: [`crate::infra::dispatch_demo`] declares the pinned demo
    /// registry [`ContainerSpec`](jera::container::ContainerSpec), records its
    /// bring-up as a durable [`jera::jobs`] `BOOT_CONTAINER` job (returning the job
    /// id), and — with holger-server-ui's `containers` feature — boots it live through
    /// jera→draupnir. The result (job id + spec + status) lands in
    /// [`Self::demo_appliance`] for the pane + `state_json`. The ledger lives under
    /// `HOLGER_DATA/holger-server-ui-jobs` when set, else a temp dir (daemon-free default).
    pub fn run_demo(&mut self) {
        let jobs_root = match std::env::var_os("HOLGER_DATA") {
            Some(d) => std::path::PathBuf::from(d).join("holger-server-ui-jobs"),
            None => std::env::temp_dir().join("holger-server-ui-demo-jobs"),
        };
        self.demo_appliance = Some(crate::infra::dispatch_demo(&jobs_root));
    }

    /// Tool #2 — actually RUN the Migrate ("suck"): build the agent connector for
    /// the chosen source (Nexus / Artifactory), list its repositories (optionally
    /// filtered), and dump them into the `output` znippy archive via
    /// `agent_lib::operations::dump_all_repos_to_znippy`. The connector's HTTP work
    /// is driven on the data layer's runtime. A missing URL or an unreachable
    /// registry is surfaced as a CLEAR result error — never a silent no-op. Gated
    /// to where `agent-lib` is linked (the `gui` build + the test build).
    #[cfg(any(feature = "tools", test))]
    pub fn run_migrate(&mut self) {
        use agent_lib::{operations, ArtifactoryConnector, ConnectorTrait, NexusConnector};

        let m = self.migrate.clone();
        let mut run = MigrateRun {
            source: m.source.clone(),
            url: m.url.clone(),
            output: m.output.clone(),
            ..Default::default()
        };

        if m.url.trim().is_empty() {
            run.status = "registry url is required — enter the Nexus/Artifactory base URL".to_string();
            run.error = Some("registry url is required".to_string());
            self.migrate.status = run.status.clone();
            self.migrate.result = Some(run);
            return;
        }

        let res = self.block(async {
            let connector: Arc<dyn ConnectorTrait> = match m.source.as_str() {
                "artifactory" => Arc::new(ArtifactoryConnector::new(m.url.clone(), None, None)?),
                _ => Arc::new(NexusConnector::new(m.url.clone(), None, None)?),
            };
            let repos = connector.list_repositories().await?;
            let repos: Vec<_> = if m.repository.trim().is_empty() {
                repos
            } else {
                repos.into_iter().filter(|r| r.name == m.repository).collect()
            };
            let n = repos.len();
            operations::dump_all_repos_to_znippy(
                connector,
                repos,
                std::path::PathBuf::from(&m.output),
                true,
            )
            .await?;
            Ok::<usize, anyhow::Error>(n)
        });

        match res {
            Ok(n) => {
                run.ran = true;
                run.repositories = n;
                run.status = format!(
                    "migrated {n} repositorie(s) from {} {} → {}",
                    m.source, m.url, m.output
                );
            }
            Err(e) => {
                run.status = format!("migrate failed: {e}");
                run.error = Some(e.to_string());
            }
        }
        self.migrate.status = run.status.clone();
        self.migrate.result = Some(run);
    }

    /// Tool #5 — produce a real Airgap populate plan/result. Validates the typed
    /// [`PopulateArgs`] first (so a missing mTLS cert/key surfaces a CLEAR message,
    /// never a crash); a `--dry-run` returns the pure-Rust plan only, while a real
    /// run delegates to the `distribution/airgap/populate` driver (located by
    /// walking up from the cwd) and captures its log + exit status. The outcome
    /// lands in [`UiData::airgap_run`].
    pub fn run_airgap(&mut self) {
        let mut run = AirgapRun {
            dry_run: self.airgap.dry_run,
            plan: self.airgap.plan(),
            ..Default::default()
        };
        match self.airgap.validate() {
            Err(e) => {
                run.status = format!("cannot run: {e}");
                run.error = Some(e.to_string());
            }
            Ok(()) => {
                if self.airgap.dry_run {
                    run.ran = true;
                    run.success = true;
                    run.status = format!(
                        "dry-run plan ready · {} stack(s) · target {}",
                        self.airgap.stacks().len(),
                        self.airgap.target.as_arg()
                    );
                } else {
                    match find_populate_driver() {
                        Some(driver) => match holger_airgap::execute(&self.airgap, &driver) {
                            Ok(report) => {
                                run.ran = true;
                                run.log = report.log;
                                run.exit_code = report.exit_code;
                                run.success = report.success;
                                run.status = if report.success {
                                    "populate succeeded".to_string()
                                } else {
                                    format!("populate exited {}", report.exit_code)
                                };
                            }
                            Err(e) => {
                                run.status = format!("populate failed: {e}");
                                run.error = Some(e.to_string());
                            }
                        },
                        None => {
                            run.status = "populate driver not found (distribution/airgap/populate) — use dry-run or run from the holger repo".to_string();
                            run.error = Some("populate driver not found".to_string());
                        }
                    }
                }
            }
        }
        self.airgap_run = Some(run);
    }

    /// Tool #4 — actually RUN the Deploy (Skíðblaðnir): read the selected repo's
    /// **sealed znippy bundle** over the core API (`archive_info` — a real gRPC /
    /// in-process call, the same one the Archive tab uses) to confirm there is an
    /// artifact to deploy and capture its size, then produce a real airgap-engine
    /// deploy plan (push-only carry of the already-sealed bundle to `target`). The
    /// outcome lands in [`UiData::deploy`]. An empty / missing archive surfaces a
    /// CLEAR message — never a silent no-op (this is the "** being implemented"
    /// no-op-button fix). The actual carry/push half is the inherently-external
    /// airgap driver (Tool #5's `execute` path); here the deploy is **planned** so
    /// it can run with no creds against a test target, mirroring the airgap
    /// dry-run default.
    pub fn run_deploy(&mut self, repository: &str, target: holger_airgap::Target) {
        let mut run = DeployRun {
            repository: repository.to_string(),
            target: target.as_arg().to_string(),
            ..Default::default()
        };
        match self.block(self.holger.archive_info(repository)) {
            Ok(info) if info.file_count == 0 => {
                run.status = format!(
                    "repo '{repository}' has no sealed znippy bundle to deploy (empty archive)"
                );
                run.error = Some("no sealed bundle (empty archive)".to_string());
            }
            Ok(info) => {
                run.file_count = info.file_count;
                run.bytes = info.total_uncompressed_bytes;
                run.archive_path = info.archive_path.clone();
                // Real airgap-engine deploy plan: carry the already-sealed bundle to
                // the chosen target (push-only). Validated + pure — no creds, no I/O,
                // no shell — so it runs against a test target without loosening auth.
                let args = holger_airgap::PopulateArgs {
                    target,
                    push_only: true,
                    dry_run: true,
                    repository: Some(repository.to_string()),
                    ..Default::default()
                };
                run.plan = args.plan();
                run.ran = true;
                run.status = format!(
                    "deploy planned · {} files / {} bytes → {}",
                    info.file_count,
                    info.total_uncompressed_bytes,
                    target.as_arg()
                );
            }
            Err(e) => {
                run.status = format!("deploy failed: {e}");
                run.error = Some(e.to_string());
            }
        }
        self.deploy.status = run.status.clone();
        self.deploy.result = Some(run);
    }

    /// The combined Airgap state — the typed args plus the last run result — for
    /// `state_json` (the Tools#5 pane as DATA).
    pub fn airgap_state_json(&self) -> Value {
        let mut v = self.airgap.state_json();
        if let Value::Object(ref mut map) = v {
            map.insert(
                "run".to_string(),
                self.airgap_run
                    .as_ref()
                    .map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
                    .unwrap_or(Value::Null),
            );
            // Surface validate() as a pure state pointer: `can_run` gates the
            // Run button (the red "cannot run" branch is `can_run == false`),
            // with the validation error message when it fails.
            match self.airgap.validate() {
                Ok(()) => {
                    map.insert("can_run".to_string(), Value::Bool(true));
                    map.insert("validate_error".to_string(), Value::Null);
                }
                Err(e) => {
                    map.insert("can_run".to_string(), Value::Bool(false));
                    map.insert("validate_error".to_string(), Value::String(e.to_string()));
                }
            }
        }
        v
    }

    /// One combined snapshot of every view — the headless `$APP_STATE` a robot
    /// test asserts against.
    pub fn state_json(&self) -> Value {
        json!({
            "status": self.status.state_json(),
            "profile": self.profile.state_json(),
            "repos": self.repos.state_json(),
            "artifact": self.artifact.state_json(),
            "browse": self.browse.state_json(),
            "archive": self.archive.state_json(),
            "security": self.security.state_json(),
            "airgap": self.airgap_state_json(),
            "holger_ds": self.holger_ds.state_json(),
            "migrate": self.migrate.state_json(),
            "deploy": self.deploy.state_json(),
            "lineage": self.lineage.state_json(),
        })
    }
}

/// Walk up from the current working directory looking for the documented
/// `distribution/airgap/populate` driver. `None` when not inside a holger repo
/// (e.g. an installed binary) — the caller then steers the operator to `--dry-run`.
fn find_populate_driver() -> Option<std::path::PathBuf> {
    let mut dir = std::env::current_dir().ok();
    while let Some(d) = dir {
        if let Some(p) = holger_airgap::driver_in(&d) {
            return Some(p);
        }
        dir = d.parent().map(|p| p.to_path_buf());
    }
    None
}

#[cfg(feature = "gui-core")]
impl UiData {
    /// Connect to a remote holger gRPC endpoint (no credentials).
    pub fn connect_remote(endpoint: &str) -> anyhow::Result<Self> {
        Self::connect_remote_with_token(endpoint, None)
    }

    /// Connect to a remote holger gRPC endpoint, optionally with an OIDC bearer
    /// token injected on every request (needed for write access on an
    /// auth-enabled server).
    ///
    /// The data layer's private runtime owns the channel — so the connect and
    /// every later call share one current-thread runtime (the single-runtime
    /// tonic idiom the nornir viz uses; avoids a channel outliving the runtime
    /// that spawned it).
    pub fn connect_remote_with_token(endpoint: &str, token: Option<&str>) -> anyhow::Result<Self> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;
        let holger: Arc<dyn HolgerObject> = match token {
            Some(t) => Arc::new(
                rt.block_on(server_lib::RemoteHolger::connect_with_token(endpoint.to_string(), t))?,
            ),
            None => {
                Arc::new(rt.block_on(server_lib::RemoteHolger::connect(endpoint.to_string()))?)
            }
        };
        let mut data = Self::with_runtime(holger, rt);
        data.endpoint = endpoint.to_string();
        Ok(data)
    }

    /// Connect to a remote holger gRPC endpoint over TLS (and mTLS when a
    /// `client_identity` cert+key is supplied), optionally with a bearer token.
    ///
    /// Same single-runtime pattern as [`UiData::connect_remote_with_token`]: the
    /// data layer's private current-thread runtime owns the TLS channel, so the
    /// connect and every later call share one runtime.
    ///
    /// NOTE: only the client TLS path is built here; there is no end-to-end mTLS
    /// integration test (it needs generated certs + a TLS server — a manual /
    /// mannequin step). This path is verified to *compile* only.
    pub fn connect_remote_with_tls(
        endpoint: &str,
        ca: Option<Vec<u8>>,
        client_identity: Option<(Vec<u8>, Vec<u8>)>,
        token: Option<&str>,
    ) -> anyhow::Result<Self> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;
        let holger: Arc<dyn HolgerObject> = Arc::new(rt.block_on(
            server_lib::RemoteHolger::connect_with_tls(
                endpoint.to_string(),
                ca,
                client_identity,
                token.map(|t| t.to_string()),
            ),
        )?);
        let mut data = Self::with_runtime(holger, rt);
        data.endpoint = endpoint.to_string();
        Ok(data)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use std::collections::HashMap;
    use std::sync::Mutex;

    /// A configurable in-memory `HolgerObject` for driving the view-model with no
    /// server. `fail` flips every call to an error (transport-failure path).
    #[derive(Default)]
    struct FakeHolger {
        repos: Vec<RepositoryInfo>,
        artifacts: HashMap<(String, String), Vec<u8>>, // (repo, name) -> bytes
        /// Canned listings keyed by repository name — what `list_artifacts`
        /// returns (filtered by `name_filter` substring when one is given).
        listings: HashMap<String, Vec<traits::ArtifactEntry>>,
        puts: Mutex<Vec<(String, String, Vec<u8>)>>,
        fail: bool,
    }

    impl FakeHolger {
        fn key(repo: &str, id: &ArtifactId) -> (String, String) {
            (repo.to_string(), id.name.clone())
        }
    }

    #[async_trait]
    impl HolgerObject for FakeHolger {
        async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
            if self.fail {
                anyhow::bail!("boom");
            }
            Ok(self.artifacts.get(&Self::key(repository, id)).cloned())
        }
        async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
            if self.fail {
                anyhow::bail!("boom");
            }
            // Mirror the server: reject writes to a read-only repo.
            let writable = self
                .repos
                .iter()
                .find(|r| r.name == repository)
                .map(|r| r.writable)
                .unwrap_or(false);
            if !writable {
                anyhow::bail!("Repository is read-only");
            }
            self.puts
                .lock()
                .unwrap()
                .push((repository.to_string(), id.name.clone(), data.to_vec()));
            Ok(())
        }
        async fn list_repositories(&self) -> anyhow::Result<Vec<RepositoryInfo>> {
            if self.fail {
                anyhow::bail!("boom");
            }
            Ok(self.repos.clone())
        }
        async fn list_artifacts(
            &self,
            repository: &str,
            name_filter: Option<String>,
            _limit: u32,
            _page_token: Option<String>,
        ) -> anyhow::Result<(Vec<traits::ArtifactEntry>, String)> {
            if self.fail {
                anyhow::bail!("boom");
            }
            // An unknown / empty repo yields an empty page (mirrors a server
            // returning nothing), not an error.
            let entries = self
                .listings
                .get(repository)
                .cloned()
                .unwrap_or_default()
                .into_iter()
                .filter(|e| match &name_filter {
                    Some(f) => e.id.name.contains(f.as_str()),
                    None => true,
                })
                .collect();
            Ok((entries, String::new()))
        }
        async fn list_archive_files(
            &self,
            repository: &str,
            prefix: Option<String>,
        ) -> anyhow::Result<Vec<String>> {
            if self.fail {
                anyhow::bail!("boom");
            }
            // Canned archive listing for the known repo "rust-arch"; unknown
            // repos yield an empty listing (mirrors a backend with no archive).
            let files: Vec<String> = if repository == "rust-arch" {
                vec![
                    "crates/serde-1.0.0.crate".to_string(),
                    "crates/tokio-1.2.0.crate".to_string(),
                    "index/config.json".to_string(),
                ]
            } else if repository == "rust-vuln" {
                // A corpus carrying the demo High-severity openssl advisory.
                vec![
                    "crates/serde-1.0.0.crate".to_string(),
                    "crates/openssl-0.10.55.crate".to_string(),
                    "index/config.json".to_string(),
                ]
            } else {
                Vec::new()
            };
            Ok(match prefix {
                Some(p) => files.into_iter().filter(|f| f.starts_with(&p)).collect(),
                None => files,
            })
        }
        async fn archive_info(&self, repository: &str) -> anyhow::Result<traits::ArchiveInfo> {
            if self.fail {
                anyhow::bail!("boom");
            }
            if repository == "rust-arch" {
                Ok(traits::ArchiveInfo {
                    file_count: 3,
                    total_uncompressed_bytes: 600,
                    archive_path: "rust-arch".into(),
                })
            } else {
                Ok(traits::ArchiveInfo::default())
            }
        }
        async fn health(&self) -> anyhow::Result<Health> {
            if self.fail {
                anyhow::bail!("boom");
            }
            Ok(Health {
                status: "ok".into(),
                version: "9.9.9".into(),
                uptime_seconds: 42,
            })
        }
    }

    fn repo(name: &str, writable: bool) -> RepositoryInfo {
        RepositoryInfo {
            name: name.into(),
            repo_type: "Rust".into(),
            writable,
            has_archive: true,
        }
    }

    fn ui(fake: FakeHolger) -> UiData {
        UiData::new(Arc::new(fake)).expect("runtime")
    }

    fn entry(name: &str, version: &str, size: i64) -> traits::ArtifactEntry {
        traits::ArtifactEntry {
            id: ArtifactId {
                namespace: None,
                name: name.into(),
                version: version.into(),
            },
            size_bytes: size,
            content_type: "application/octet-stream".into(),
        }
    }

    #[test]
    fn status_ok_populates_view_and_state_json() {
        let mut d = ui(FakeHolger::default());
        d.refresh_status();
        assert!(d.status.loaded);
        assert_eq!(d.status.version, "9.9.9");
        assert_eq!(d.status.uptime_seconds, 42);
        assert!(d.status.error.is_none());

        let s = d.status.state_json();
        assert_eq!(s["loaded"], json!(true));
        assert_eq!(s["version"], json!("9.9.9"));
    }

    #[test]
    fn status_error_is_surfaced_not_loaded() {
        let mut d = ui(FakeHolger {
            fail: true,
            ..Default::default()
        });
        d.refresh_status();
        assert!(!d.status.loaded);
        assert_eq!(d.status.error.as_deref(), Some("boom"));
    }

    #[test]
    fn repos_load_selects_first_and_preserves_selection_by_name() {
        let mut d = ui(FakeHolger {
            repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
            ..Default::default()
        });
        d.refresh_repos();
        assert_eq!(d.repos.repos.len(), 2);
        assert_eq!(d.repos.selected, Some(0));
        assert_eq!(d.repos.selected_repo().unwrap().name, "rust-prod");

        // Select the writable one, then refresh: selection sticks to "rust-dev".
        d.select_repo(1);
        d.refresh_repos();
        assert_eq!(d.repos.selected_repo().unwrap().name, "rust-dev");
    }

    #[test]
    fn upload_enabled_tracks_selected_repo_writable() {
        let mut d = ui(FakeHolger {
            repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
            ..Default::default()
        });
        d.refresh_repos();
        // rust-prod (read-only) selected first.
        assert!(!d.repos.upload_enabled());
        d.select_repo(1); // rust-dev (writable)
        assert!(d.repos.upload_enabled());
    }

    #[test]
    fn fetch_found_sets_size_and_does_not_leak_bytes_into_state_json() {
        let mut arts = HashMap::new();
        arts.insert(("rust-prod".to_string(), "serde".to_string()), vec![1u8; 100]);
        let mut d = ui(FakeHolger {
            artifacts: arts,
            ..Default::default()
        });
        let id = ArtifactId {
            namespace: None,
            name: "serde".into(),
            version: "1.0.0".into(),
        };
        d.fetch_artifact("rust-prod", id);
        assert!(d.artifact.found);
        assert_eq!(d.artifact.size_bytes, 100);
        assert_eq!(d.artifact.data.len(), 100);

        let s = d.artifact.state_json();
        assert_eq!(s["size_bytes"], json!(100));
        assert_eq!(s["found"], json!(true));
        assert!(s.get("data").is_none(), "raw bytes must not appear in state_json");
    }

    #[test]
    fn fetch_missing_is_not_found_not_error() {
        let mut d = ui(FakeHolger::default());
        let id = ArtifactId {
            namespace: None,
            name: "nope".into(),
            version: "0.0.0".into(),
        };
        d.fetch_artifact("rust-prod", id);
        assert!(!d.artifact.found);
        assert!(d.artifact.error.is_none());
    }

    #[test]
    fn fetch_transport_error_is_surfaced() {
        let mut d = ui(FakeHolger {
            fail: true,
            ..Default::default()
        });
        let id = ArtifactId {
            namespace: None,
            name: "serde".into(),
            version: "1.0.0".into(),
        };
        d.fetch_artifact("rust-prod", id);
        assert!(!d.artifact.found);
        assert_eq!(d.artifact.error.as_deref(), Some("boom"));
    }

    #[test]
    fn put_rejected_on_read_only_repo() {
        let d = ui(FakeHolger {
            repos: vec![repo("rust-prod", false)],
            ..Default::default()
        });
        let id = ArtifactId {
            namespace: None,
            name: "mycrate".into(),
            version: "0.1.0".into(),
        };
        let err = d.put_artifact("rust-prod", &id, b"bytes").unwrap_err();
        assert!(err.to_string().contains("read-only"));
    }

    #[test]
    fn put_succeeds_on_writable_repo() {
        let d = ui(FakeHolger {
            repos: vec![repo("rust-dev", true)],
            ..Default::default()
        });
        let id = ArtifactId {
            namespace: None,
            name: "mycrate".into(),
            version: "0.1.0".into(),
        };
        d.put_artifact("rust-dev", &id, b"bytes").unwrap();
    }

    #[test]
    fn combined_state_json_has_all_three_views() {
        // T33: assert REAL content in the combined snapshot, not just key
        // presence — seed known repos/listing/archive, drive each refresh, then
        // check the rendered values (LAW 2: no empty data).
        let mut listings = HashMap::new();
        listings.insert(
            "rust-arch".to_string(),
            vec![entry("serde", "1.0.0", 100), entry("tokio", "1.2.0", 200)],
        );
        let mut d = ui(FakeHolger {
            repos: vec![repo("rust-arch", true), repo("cache", false)],
            listings,
            ..Default::default()
        });
        d.refresh_status();
        d.refresh_profile();
        d.refresh_repos();
        d.refresh_browse("rust-arch", None);
        d.refresh_archive("rust-arch", None);

        let s = d.state_json();
        // Key presence (the original contract).
        for k in ["status", "profile", "repos", "artifact", "browse", "archive",
                  "security", "airgap", "holger_ds", "migrate", "deploy"] {
            assert!(s.get(k).is_some(), "missing key {k}: {s}");
        }
        // Real rendered values.
        assert_eq!(s["status"]["status"], json!("ok"), "status ok: {s}");
        assert_eq!(s["status"]["version"], json!("9.9.9"), "version: {s}");
        let names: Vec<&str> = s["repos"]["repos"].as_array().unwrap()
            .iter().map(|r| r["name"].as_str().unwrap()).collect();
        assert!(names.contains(&"rust-arch") && names.contains(&"cache"), "repos listed: {s}");
        assert_eq!(s["browse"]["entries"].as_array().unwrap().len(), 2, "browse entries: {s}");
        assert_eq!(s["archive"]["file_count"], json!(3), "archive file_count: {s}");
        // A profile is derived (FakeHolger has a writable repo → dynamic).
        assert_eq!(s["profile"]["read_only"], json!(false), "profile derived: {s}");
        // At least one currently-unchecked pane key carries its default shape.
        assert!(s["holger_ds"]["scale"].is_string(), "holger_ds scale present: {s}");
        assert!(s["airgap"].get("can_run").is_some(), "airgap can_run present: {s}");
    }

    #[test]
    fn archive_lists_files_and_stats() {
        let mut d = ui(FakeHolger::default());

        // Known archive-backed repo: stats + file paths populate the view.
        d.refresh_archive("rust-arch", None);
        assert!(d.archive.error.is_none(), "archive error: {:?}", d.archive.error);
        assert_eq!(d.archive.repository, "rust-arch");
        assert_eq!(d.archive.file_count, 3);
        assert_eq!(d.archive.total_uncompressed_bytes, 600);
        assert_eq!(d.archive.archive_path, "rust-arch");
        assert_eq!(d.archive.files.len(), 3);
        assert_eq!(d.archive.files[0], "crates/serde-1.0.0.crate");

        // A path prefix narrows the listing (stats stay whole-archive).
        d.refresh_archive("rust-arch", Some("index/".into()));
        assert_eq!(d.archive.files, vec!["index/config.json".to_string()]);
        assert_eq!(d.archive.file_count, 3);

        // state_json carries the files + stats.
        let s = d.archive.state_json();
        assert_eq!(s["repository"], json!("rust-arch"));
        assert_eq!(s["file_count"], json!(3));

        // An unknown / non-archive repo is empty, not an error.
        d.refresh_archive("does-not-exist", None);
        assert!(d.archive.error.is_none());
        assert!(d.archive.files.is_empty());
        assert_eq!(d.archive.file_count, 0);
    }

    #[test]
    fn archive_transport_error_is_surfaced() {
        let mut d = ui(FakeHolger {
            fail: true,
            ..Default::default()
        });
        d.refresh_archive("rust-arch", None);
        assert_eq!(d.archive.error.as_deref(), Some("boom"));
        assert!(d.archive.files.is_empty());
    }

    #[test]
    fn browse_lists_artifacts_for_repo() {
        let mut listings = HashMap::new();
        listings.insert(
            "rust-prod".to_string(),
            vec![entry("serde", "1.0.0", 100), entry("tokio", "1.2.0", 200)],
        );
        let mut d = ui(FakeHolger {
            listings,
            ..Default::default()
        });

        d.refresh_browse("rust-prod", None);
        assert!(d.browse.error.is_none());
        assert_eq!(d.browse.repository, "rust-prod");
        assert_eq!(d.browse.entries.len(), 2);
        assert_eq!(d.browse.entries[0].name, "serde");
        assert_eq!(d.browse.entries[0].size_bytes, 100);
        assert_eq!(d.browse.entries[1].name, "tokio");

        // name_filter narrows by substring.
        d.refresh_browse("rust-prod", Some("ser".into()));
        assert_eq!(d.browse.entries.len(), 1);
        assert_eq!(d.browse.entries[0].name, "serde");

        // state_json carries the entries.
        let s = d.browse.state_json();
        assert_eq!(s["repository"], json!("rust-prod"));
        assert_eq!(s["entries"][0]["name"], json!("serde"));
    }

    #[test]
    fn security_scan_clean_repo_passes() {
        let mut d = ui(FakeHolger::default());
        d.run_security_scan("rust-arch", "rust");
        assert!(d.security.loaded);
        assert!(d.security.error.is_none());
        assert_eq!(d.security.ecosystem, "cargo");
        assert_eq!(d.security.scanned, 2); // index/config.json skipped
        assert_eq!(d.security.skipped, 1);
        assert_eq!(d.security.pass, 2);
        assert_eq!(d.security.block, 0);
        assert_eq!(d.security.decision, "pass");
        assert!(d.security.findings.is_empty());

        // state_json carries the tallies.
        let s = d.security.state_json();
        assert_eq!(s["decision"], json!("pass"));
        assert_eq!(s["scanned"], json!(2));
    }

    #[test]
    fn security_scan_vulnerable_repo_blocks_with_advisory() {
        let mut d = ui(FakeHolger::default());
        d.run_security_scan("rust-vuln", "rust");
        assert_eq!(d.security.decision, "block");
        assert_eq!(d.security.block, 1);
        assert_eq!(d.security.pass, 1);
        let blocked = d.security.findings.iter().find(|f| f.decision == "block").unwrap();
        assert_eq!(blocked.name, "openssl");
        assert!(blocked.ids.iter().any(|i| i == "RUSTSEC-2023-0044"));
    }

    #[test]
    fn dependency_graph_colours_all_nodes_by_verdict() {
        let mut d = ui(FakeHolger::default());
        d.run_security_scan("rust-vuln", "rust");
        let g = &d.dep_graph;
        assert!(g.loaded);
        assert_eq!(g.repository, "rust-vuln");
        // ALL scanned artifacts are nodes (incl. the clean one the table omits).
        assert_eq!(g.nodes.len(), 2);
        assert_eq!(g.block, 1);
        assert_eq!(g.pass, 1);
        let blocked = g.nodes.iter().find(|n| n.decision == "block").unwrap();
        assert_eq!(blocked.name, "openssl");
        assert!(blocked.ids.iter().any(|i| i == "RUSTSEC-2023-0044"));
        // A served archive has no captured snapshot ⇒ honest "no resolved graph".
        assert!(!g.has_edges);
        assert!(g.edges.is_empty());
        // state_json carries the colour tallies + node set for the robot.
        let s = g.state_json();
        assert_eq!(s["block"], json!(1));
        assert_eq!(s["pass"], json!(1));
        assert_eq!(s["nodes"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn dependency_graph_surfaces_transport_error() {
        let mut d = ui(FakeHolger { fail: true, ..Default::default() });
        d.run_security_scan("rust-arch", "rust");
        assert_eq!(d.dep_graph.error.as_deref(), Some("boom"));
        assert!(!d.dep_graph.loaded);
        assert!(d.dep_graph.nodes.is_empty());
    }

    #[test]
    fn migrate_config_cli_command_switches_source_flag() {
        let mut m = MigrateConfig::default();
        m.url = "http://nexus:8081".into();
        assert!(m.cli_command().contains("--from nexus --nexus-url http://nexus:8081"));
        m.source = "artifactory".into();
        m.url = "https://art".into();
        m.repository = "libs-release".into();
        let c = m.cli_command();
        assert!(c.contains("--from artifactory --artifactory-url https://art"));
        assert!(c.contains("--repository libs-release"));
        assert_eq!(MigrateConfig::default().state_json()["source"], json!("nexus"));
    }

    #[test]
    fn ds_config_cli_command_reflects_scale_and_target() {
        let mut d = ui(FakeHolger::default());
        assert_eq!(d.holger_ds.scale, "micro");
        assert_eq!(d.holger_ds.cli_command(), "holger-server holger-ds --preset micro");
        d.holger_ds.scale = "large".into();
        d.holger_ds.target = "http://nexus:8081".into();
        assert_eq!(
            d.holger_ds.cli_command(),
            "holger-server holger-ds --preset large --target http://nexus:8081"
        );
        // state_json carries the config.
        let s = d.holger_ds.state_json();
        assert_eq!(s["scale"], json!("large"));
        assert_eq!(s["target"], json!("http://nexus:8081"));
    }

    #[test]
    fn security_scan_transport_error_is_surfaced() {
        let mut d = ui(FakeHolger { fail: true, ..Default::default() });
        d.run_security_scan("rust-arch", "rust");
        assert_eq!(d.security.error.as_deref(), Some("boom"));
        assert!(!d.security.loaded);
    }

    #[test]
    fn browse_unknown_repo_is_empty_not_error() {
        let mut d = ui(FakeHolger::default());
        d.refresh_browse("does-not-exist", None);
        assert!(d.browse.error.is_none());
        assert!(d.browse.entries.is_empty());
        assert_eq!(d.browse.next_page_token, "");
    }

    /// Tool #1 — the holger-ds RUN actually drives the package protocol: a micro
    /// self-host run builds a synthetic crate corpus, stands up Holger's real HTTP
    /// gateway, bombards it, and folds a REAL throughput result into the view
    /// (MB/s · ops/s · ops > 0). This is the no-op-button fix asserted as DATA.
    #[test]
    fn holger_ds_micro_run_produces_real_throughput() {
        let mut d = ui(FakeHolger::default());
        assert!(d.holger_ds.result.is_none(), "no result before running");
        d.run_holger_ds();

        let res = d.holger_ds.result.as_ref().expect("a run result");
        assert!(res.error.is_none(), "micro self-host run should not error: {:?}", res.error);
        assert!(res.ran, "the benchmark ran");
        assert_eq!(res.scale, "micro");
        assert_eq!(res.target, "self-host");
        assert!(res.files > 0, "the gateway exposed crate files: {res:?}");
        assert!(res.ops > 0, "GETs completed over the package protocol: {res:?}");
        assert!(res.bytes > 0, "bytes were read off the wire: {res:?}");

        // state_json carries the result + a human status line.
        let s = d.holger_ds.state_json();
        assert_eq!(s["result"]["ran"], json!(true));
        assert!(s["status"].as_str().unwrap_or("").contains("MB/s"), "status shows throughput: {s}");
    }

    /// Tool #2 — Migrate with no URL surfaces a CLEAR validation message (not a
    /// silent no-op, not a crash), and records a result the pane renders.
    #[test]
    fn migrate_without_url_surfaces_clear_validation() {
        let mut d = ui(FakeHolger::default());
        d.run_migrate();
        let r = d.migrate.result.as_ref().expect("a run result");
        assert!(!r.ran);
        assert!(r.error.as_deref().unwrap_or("").contains("url"), "clear url message: {r:?}");
        assert!(d.migrate.status.to_lowercase().contains("url"));
        let s = d.migrate.state_json();
        assert_eq!(s["result"]["ran"], json!(false));
    }

    /// Tool #5 — a dry-run produces a real plan/result with no creds (the runnable
    /// demo default), and the plan lists the scale's stacks + endpoints.
    #[test]
    fn airgap_dry_run_produces_plan_result() {
        let mut d = ui(FakeHolger::default());
        d.airgap.dry_run = true;
        d.run_airgap();
        let run = d.airgap_run.as_ref().expect("an airgap run");
        assert!(run.ran && run.success, "dry-run plan succeeds: {run:?}");
        assert!(run.error.is_none());
        assert!(run.plan.contains("airgap populate"), "plan present: {}", run.plan);
        // The combined airgap state_json carries both the args + the run.
        let s = d.airgap_state_json();
        assert_eq!(s["run"]["ran"], json!(true));
        assert_eq!(s["dry_run"], json!(true));
    }

    /// Tool #4 — Deploy reads the selected repo's REAL sealed znippy bundle
    /// (`archive_info`) and produces a real airgap-engine deploy plan for the
    /// chosen target. This is the no-op "** being implemented" button fix asserted
    /// as DATA: a ran result carrying the bundle's file count + a push-only plan.
    #[test]
    fn deploy_plans_over_real_sealed_bundle() {
        use holger_airgap::Target;
        let mut d = ui(FakeHolger::default());
        assert!(d.deploy.result.is_none(), "no result before running");
        d.run_deploy("rust-arch", Target::Holger);

        let r = d.deploy.result.as_ref().expect("a deploy result");
        assert!(r.error.is_none(), "deploy over a sealed bundle should not error: {r:?}");
        assert!(r.ran, "the deploy was planned");
        assert_eq!(r.repository, "rust-arch");
        assert_eq!(r.target, "holger");
        assert_eq!(r.file_count, 3, "bundle file count from archive_info: {r:?}");
        assert_eq!(r.bytes, 600, "bundle bytes from archive_info: {r:?}");
        // The plan is the real airgap-engine push-only deploy plan.
        assert!(r.plan.contains("airgap populate"), "real airgap plan: {}", r.plan);
        assert!(r.plan.contains("push-only"), "deploy carries the sealed bundle: {}", r.plan);

        // state_json carries the result + a human status line.
        let s = d.deploy.state_json();
        assert_eq!(s["result"]["ran"], json!(true));
        assert_eq!(s["result"]["file_count"], json!(3));
        assert!(s["status"].as_str().unwrap_or("").contains("deploy planned"), "status: {s}");
    }

    /// Tool #4 — deploying a repo with no sealed bundle (empty archive) surfaces a
    /// CLEAR message (not a silent no-op, not a crash).
    #[test]
    fn deploy_empty_archive_surfaces_clear_message() {
        use holger_airgap::Target;
        let mut d = ui(FakeHolger::default());
        d.run_deploy("does-not-exist", Target::Nexus);
        let r = d.deploy.result.as_ref().expect("a deploy result");
        assert!(!r.ran);
        assert!(
            r.error.as_deref().unwrap_or("").contains("sealed bundle"),
            "clear empty-archive message: {r:?}"
        );
        assert!(d.deploy.status.to_lowercase().contains("no sealed"));
    }

    /// Tool #4 — a transport failure on `archive_info` is surfaced as a clear
    /// deploy error (not a crash).
    #[test]
    fn deploy_transport_error_is_surfaced() {
        use holger_airgap::Target;
        let mut d = ui(FakeHolger { fail: true, ..Default::default() });
        d.run_deploy("rust-arch", Target::Holger);
        let r = d.deploy.result.as_ref().expect("a deploy result");
        assert!(!r.ran);
        assert_eq!(r.error.as_deref(), Some("boom"));
    }

    /// Tool #5 — an mTLS push with no client cert/key (and not dry-run) surfaces
    /// the CLEAR "needs --client-cert and --client-key" validation in the run
    /// result — Rickard's exact error, now handled (not a crash/no-op).
    #[test]
    fn airgap_mtls_without_certs_surfaces_clear_error() {
        use holger_airgap::Auth;
        let mut d = ui(FakeHolger::default());
        d.airgap.dry_run = false;
        d.airgap.auth = Auth::Mtls;
        d.airgap.client_cert = None;
        d.airgap.client_key = None;
        d.run_airgap();
        let run = d.airgap_run.as_ref().expect("an airgap run");
        assert!(!run.ran);
        let e = run.error.as_deref().unwrap_or("");
        assert!(e.contains("client-cert") && e.contains("client-key"), "clear mTLS message: {run:?}");
    }
}