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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/admin.rs

//! Health, counters and storage.

use axum::extract::State;
use axum::Json;
use serde::Serialize;

use crate::api::{hash_kind_name, ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::pb;

/// Whether gunnar is serving, and how it is configured.
#[derive(Debug, Clone, Serialize)]
pub struct Health {
    pub serving: bool,
    pub agent: String,
    /// gunnar's CRATE version (`1.5.0`) — pinned in a manifest, so it does not
    /// move between builds of the same release.
    pub version: String,
    /// **What is actually running there**: gunnar's `git describe` stamp
    /// (`v1.4.0-228-gb86f88f`), empty on a build with no git to ask.
    ///
    /// Carried because the two were confused for real on this console: the
    /// header band showed `version`, an operator read `1.5.0` and concluded the
    /// appliance was on the release, while it was 228 commits past the tag.
    #[serde(default)]
    pub build_ref: String,
    pub uptime_seconds: u64,
    /// False means gunnar is running with its control-plane authentication
    /// disabled. The console shows this as a warning: gunnar reports it on the
    /// wire precisely so an operator can see the hole.
    pub grpc_authentication_enabled: bool,
    pub grpc_bind: String,
    pub ssh_bind: String,
}

/// The CONSOLE's own identity — not gunnar's, which [`Health::version`]
/// carries. `version` is the `--version` line verbatim; the parts are beside
/// it so a rig can compare a field, not parse a sentence.
#[derive(Debug, Clone, Serialize)]
pub struct Version {
    pub version: String,
    pub crate_version: &'static str,
    pub build_ref: &'static str,
    pub build_date: &'static str,
}

/// `GET /api/version` — this server's build identity. No gunnar round trip.
pub async fn version() -> Json<Version> {
    Json(Version {
        version: crate::VERSION_LINE.to_string(),
        crate_version: crate::VERSION,
        build_ref: crate::BUILD_REF,
        build_date: crate::BUILD_DATE,
    })
}

/// gunnar's counters.
#[derive(Debug, Clone, Serialize)]
pub struct Stats {
    pub namespaces: u64,
    pub repos: u64,
    pub principals: u64,
    pub keys: u64,
    pub grants: u64,
    pub event_subscribers: u64,
    pub events_published: u64,
    /// Events gunnar dropped for subscribers that fell behind.
    pub events_dropped: u64,
    pub event_buffer_capacity: u64,
    /// Background index work in flight (one row per repository whose pushed
    /// pack is still being resolved). Empty when idle. Additive, 2026-08-30.
    #[serde(default)]
    pub index: Vec<IndexProgress>,

    /// Soft-deleted repositories, and the bytes they are still holding. A
    /// tombstone is storage the account is USING.
    pub tombstoned_repos: u64,
    pub tombstoned_bytes: u64,

    /// Replication, on the wire. Empty `standbys` with `configured` false means
    /// nothing is set up; empty with it TRUE would be a bug.
    pub replication_configured: bool,
    pub replication_synced: u64,
    pub replication_failed: u64,
    pub replication_enqueued: u64,
    pub replication_bus_gaps: u64,
    pub standbys: Vec<StandbyStatus>,
    /// **Every ref on the server, and how many of them no standby carries.**
    ///
    /// Replication copies `refs/heads/*` and `refs/tags/*` and nothing else, so
    /// a green standby row means "every branch and tag is on it" and not "my
    /// repositories are on it". Measured on the appliance 2026-08-31: 1292
    /// refs, 567 of them outside those two prefixes — `refs/remotes/origin/*`
    /// mostly, but also `refs/superseded/*` and `refs/salvage/*`, which
    /// somebody parked work in on purpose.
    pub replication_refs_total: u64,
    pub replication_refs_unmirrored: u64,
}

/// One replication standby.
#[derive(Debug, Clone, Serialize)]
pub struct StandbyStatus {
    pub name: String,
    pub remote: String,
    /// Repositories with work outstanding for this standby.
    pub pending: u64,
    pub consecutive_failures: u32,
    pub last_error: String,
    /// **`None` means NEVER**, and it is the single most important value here:
    /// a standby that has never taken a byte is a durability promise that does
    /// not exist. gunnar sends 0 for never, which would render as 1970.
    pub last_success_unix_ms: Option<i64>,
    /// A hole retrying will not close; `gunnar replication resync` is the answer.
    pub needs_full_resync: bool,
    /// **Work is stuck AND nothing has landed for 10x the retry ceiling.** Not
    /// "the last success is old", which is what it meant until 2026-08-31 and
    /// what made an idle appliance print STALE all weekend.
    pub stale: bool,
    /// **The one word for this standby**, decided by gunnar:
    /// `NEVER`, `STALE`, `BEHIND`, `syncing` or `idle`. Upper case is an alarm.
    ///
    /// The console renders this rather than deriving a word of its own from the
    /// booleans, so the badge, `gunnar control replication list` and the
    /// appliance's own band cannot disagree about the same standby.
    /// Empty from a gunnar older than 2026-08-31, and the console falls back to
    /// the booleans for exactly that case.
    pub verdict: String,
}

/// One repository's post-ack index work — the appliance dashboard's
/// `index alice/linux 34% 4.7M/13.9M resolve` row, as JSON.
#[derive(Debug, Clone, Serialize)]
pub struct IndexProgress {
    pub repo: String,
    pub pack_id: u64,
    pub objects_done: u64,
    pub objects_total: u64,
    pub bytes_done: u64,
    pub bytes_total: u64,
    pub phase: String,
    pub started_at_unix_ms: i64,
    pub resumed: bool,
    pub percent: u32,
    pub reinflations: u64,
    pub pauses: u64,
}

/// What the object store holds.
#[derive(Debug, Clone, Serialize)]
pub struct Storage {
    pub object_backend: String,
    pub ref_backend: String,
    /// **The engine in parts** — name, description, source repository — for
    /// the Storage panel's name + tooltip + link (gunnar
    /// `StorageInfoResponse.engine`, 2026-10-02). `None` from an older gunnar,
    /// which then has only the prose line above.
    pub engine: Option<Engine>,
    pub total_hot_bytes: u64,
    pub total_cold_bytes: u64,
    /// **Is `total_cold_bytes` a reading?** False from the znippy engine (one
    /// archive per repository, no cold tier) and from an older gunnar: the 0
    /// is then not a measurement and the page shows N/A (gunnar
    /// StorageInfoResponse field 11).
    pub cold_measured: bool,
    pub repos: Vec<RepoStorage>,
    /// The exploded read cache, server-wide, and the sum of the per-repository
    /// budgets. NOT storage in the sense the other totals are: every object in
    /// it is re-derivable from the pack, and the server unlinks it when the
    /// budget says so.
    pub total_explode_bytes: u64,
    pub total_explode_budget_bytes: u64,
    /// **Bytes held by soft-deleted repositories that have not been purged.**
    ///
    /// Not inside `total_hot_bytes`: a tombstoned repository has no catalog
    /// row, so it appears in none of `repos` above. It is charged to the
    /// account all the same, which is exactly why it is reported — a customer
    /// who deletes a repository and sees no change in their usage is looking
    /// at the retention window, not at a bug, and this is where they see it.
    pub total_tombstoned_bytes: u64,
    pub tombstoned: Vec<TombstonedRepo>,
}

/// One storage engine, as gunnar names it.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Engine {
    /// `znippy-plugin-git`.
    pub name: String,
    /// `Arrow-IPC archive; objects + refs`.
    pub description: String,
    /// `https://git.gunnar.rs/nordisk/znippy`.
    pub repo_url: String,
}

/// gunnar's engine report → the console's, or `None` when it named none (an
/// older gunnar) or a URL that is not https (never linked).
pub fn engine_of(e: Option<pb::StorageEngine>) -> Option<Engine> {
    let e = e?;
    let name = e.name.trim();
    if name.is_empty() {
        return None;
    }
    let url = e.repo_url.trim();
    Some(Engine {
        name: name.to_owned(),
        description: e.description.trim().to_owned(),
        repo_url: if url.starts_with("https://") { url.to_owned() } else { String::new() },
    })
}

/// One soft-deleted repository's held bytes.
#[derive(Debug, Clone, Serialize)]
pub struct TombstonedRepo {
    pub repo: String,
    pub bytes: u64,
    pub deleted_at_unix_ms: i64,
    pub purge_after_unix_ms: i64,
}

/// One repository's footprint.
#[derive(Debug, Clone, Serialize)]
pub struct RepoStorage {
    pub repo: String,
    pub hash_kind: &'static str,
    pub loose_object_count: u64,
    pub hot_bytes: u64,
    pub cold_bytes: u64,
    pub cold_object_count: u64,
    /// See [`Storage::cold_measured`].
    pub cold_measured: bool,
    /// `None` when this repository has never been repacked.
    pub last_repack_at_unix_ms: Option<i64>,
    pub ref_count: u64,
    /// The exploded read cache for this repository. Inside `hot_bytes`, which
    /// counts the whole directory — so it is a SLICE of that number, not an
    /// addition to it.
    pub explode_bytes: u64,
    /// The cap. 0 is unlimited. A bigger quota buys faster first reads; a
    /// smaller one costs re-inflation, never an error and never a refusal.
    pub explode_budget_bytes: u64,
    /// What the budget has already reclaimed here since the store was opened.
    pub explode_evicted_bytes: u64,
}

/// `GET /api/health`
///
/// **Asked as the reader** (thin layer, owner 2026-10-04). gunnar answers
/// `Admin.Health` to a server-wide Read grant only — it names both listeners'
/// binds and whether authentication is on, which is reconnaissance — so a
/// tenant's session is refused by gunnar on their own key. The console's
/// cache holds the console key's answer and serves only the sessions that
/// already sign with that key ([`crate::api::Reader::may_cache`]).
pub async fn health(reader: Reader, State(state): State<AppState>) -> ApiResult<Health> {
    let health = state
        .caches
        .health
        .get_for(reader.may_cache(), async {
            let response = reader
                .gunnar
                .admin()
                .health(pb::HealthRequest {})
                .await
                .map_err(ApiError::from)?
                .into_inner();
            Ok::<_, ApiError>(Health {
                serving: response.serving,
                agent: response.agent,
                version: response.version,
                build_ref: response.build_ref,
                uptime_seconds: response.uptime_seconds,
                grpc_authentication_enabled: response.grpc_authentication_enabled,
                grpc_bind: response.grpc_bind,
                ssh_bind: response.ssh_bind,
            })
        })
        .await?;
    Ok(Json(health))
}

/// `GET /api/stats` — the whole box's counters, standby remotes and the
/// names of the repositories being indexed. Asked as the reader: gunnar
/// answers `Admin.Stats` to a server-wide Read grant only.
pub async fn stats(reader: Reader, State(state): State<AppState>) -> ApiResult<Stats> {
    let stats = state
        .caches
        .stats
        .get_for(reader.may_cache(), async {
            let response = reader
                .gunnar
                .admin()
                .stats(pb::StatsRequest {})
                .await
                .map_err(ApiError::from)?
                .into_inner();
            Ok::<_, ApiError>(Stats {
                namespaces: response.namespaces,
                repos: response.repos,
                principals: response.principals,
                keys: response.keys,
                grants: response.grants,
                event_subscribers: response.event_subscribers,
                events_published: response.events_published,
                events_dropped: response.events_dropped,
                event_buffer_capacity: response.event_buffer_capacity,
                tombstoned_repos: response.tombstoned_repos,
                tombstoned_bytes: response.tombstoned_bytes,
                replication_configured: response.replication_configured,
                replication_synced: response.replication_synced,
                replication_failed: response.replication_failed,
                replication_enqueued: response.replication_enqueued,
                replication_bus_gaps: response.replication_bus_gaps,
                replication_refs_total: response.replication_refs_total,
                replication_refs_unmirrored: response.replication_refs_unmirrored,
                standbys: response
                    .standbys
                    .into_iter()
                    .map(|s| StandbyStatus {
                        name: s.name,
                        remote: s.remote,
                        pending: s.pending,
                        consecutive_failures: s.consecutive_failures,
                        last_error: s.last_error,
                        // 0 is NEVER, and rendering it as 1970 would turn the
                        // most important value in this message into a joke.
                        last_success_unix_ms: (s.last_success_unix_ms > 0)
                            .then_some(s.last_success_unix_ms),
                        needs_full_resync: s.needs_full_resync,
                        stale: s.stale,
                        verdict: s.verdict,
                    })
                    .collect(),
                index: response
                    .index
                    .into_iter()
                    .map(|p| IndexProgress {
                        repo: p
                            .repo
                            .map(|r| format!("{}/{}", r.namespace, r.repo))
                            .unwrap_or_default(),
                        pack_id: p.pack_id,
                        objects_done: p.objects_done,
                        objects_total: p.objects_total,
                        bytes_done: p.bytes_done,
                        bytes_total: p.bytes_total,
                        phase: p.phase,
                        started_at_unix_ms: p.started_at_unix_ms,
                        resumed: p.resumed,
                        percent: p.percent,
                        reinflations: p.reinflations,
                        pauses: p.pauses,
                    })
                    .collect(),
            })
        })
        .await?;
    Ok(Json(stats))
}

/// `GET /api/storage` — every repository's path and size on the box. Asked as
/// the reader: gunnar answers a whole-box `Admin.StorageInfo` to a server-wide
/// Read grant only ("a whole-server storage report spans tenants").
pub async fn storage(reader: Reader, State(state): State<AppState>) -> ApiResult<Storage> {
    Ok(Json(storage_as(&reader, &state).await?))
}

/// `Admin.StorageInfo` (the whole box) as [`Storage`], asked as `reader` —
/// through the console's cache only when the reader signs with the console's
/// own key.
pub async fn storage_as(reader: &Reader, state: &AppState) -> Result<Storage, ApiError> {
    let storage = state
        .caches
        .storage
        .get_for(reader.may_cache(), async {
            let response = reader
                .gunnar
                .admin()
                .storage_info(pb::StorageInfoRequest { repo: None })
                .await
                .map_err(ApiError::from)?
                .into_inner();
            Ok::<_, ApiError>(storage_of(response))
        })
        .await?;
    Ok(storage)
}

/// ONE repository's storage row, asked as `reader` — gunnar answers it under
/// Read on that repository. What a person's repository list sizes itself
/// with, instead of the console reading the whole box for them.
pub async fn repo_storage_as(reader: &Reader, path: pb::RepoPath) -> Option<RepoStorage> {
    reader
        .gunnar
        .admin()
        .storage_info(pb::StorageInfoRequest { repo: Some(path) })
        .await
        .ok()
        .and_then(|r| r.into_inner().repos.into_iter().next())
        .map(render_repo_storage)
}

fn storage_of(response: pb::StorageInfoResponse) -> Storage {
    Storage {
        engine: engine_of(response.engine.clone()),
        object_backend: response.object_backend,
        ref_backend: response.ref_backend,
        total_hot_bytes: response.total_hot_bytes,
        total_cold_bytes: response.total_cold_bytes,
        cold_measured: response.cold_measured,
        total_explode_bytes: response.total_explode_bytes,
        total_explode_budget_bytes: response.total_explode_budget_bytes,
        total_tombstoned_bytes: response.total_tombstoned_bytes,
        tombstoned: response
            .tombstoned
            .into_iter()
            .map(|t| TombstonedRepo {
                repo: t
                    .repo
                    .map(|p| format!("{}/{}", p.namespace, p.repo))
                    .unwrap_or_default(),
                bytes: t.bytes,
                deleted_at_unix_ms: t.deleted_at_unix_ms,
                purge_after_unix_ms: t.purge_after_unix_ms,
            })
            .collect(),
        repos: response
            .repos
            .into_iter()
            .map(render_repo_storage)
            .collect(),
    }
}

fn render_repo_storage(row: pb::RepoStorage) -> RepoStorage {
    RepoStorage {
        repo: row
            .repo
            .map(|p| format!("{}/{}", p.namespace, p.repo))
            .unwrap_or_default(),
        hash_kind: hash_kind_name(row.hash_kind),
        loose_object_count: row.loose_object_count,
        hot_bytes: row.hot_bytes,
        cold_bytes: row.cold_bytes,
        cold_object_count: row.cold_object_count,
        cold_measured: row.cold_measured,
        // gunnar sends 0 for "never", which would render as 1970.
        last_repack_at_unix_ms: (row.last_repack_at_unix_ms > 0)
            .then_some(row.last_repack_at_unix_ms),
        ref_count: row.ref_count,
        explode_bytes: row.explode_bytes,
        explode_budget_bytes: row.explode_budget_bytes,
        explode_evicted_bytes: row.explode_evicted_bytes,
    }
}

/// `GET /api/bootstrap`
///
/// The one call that works before this console has an enrolled identity, so it
/// is what the operator sees when the signer is not registered yet.
pub async fn bootstrap(State(state): State<AppState>) -> ApiResult<Bootstrap> {
    let response = state
        .gunnar
        .bootstrap()
        .status(pb::BootstrapStatusRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(Bootstrap {
        window_open: response.window_open,
        bootstrapped: response.bootstrapped,
        bootstrapped_principal: response.bootstrapped_principal,
        bootstrapped_fingerprint: response.bootstrapped_fingerprint,
        principals: response.principals,
        keys: response.keys,
        detail: response.detail,
        console_fingerprint: state.gunnar.fingerprint().to_owned(),
    }))
}

/// Whether gunnar has an administrator yet.
#[derive(Debug, Clone, Serialize)]
pub struct Bootstrap {
    pub window_open: bool,
    pub bootstrapped: bool,
    pub bootstrapped_principal: String,
    pub bootstrapped_fingerprint: String,
    pub principals: u64,
    pub keys: u64,
    pub detail: String,
    /// The fingerprint this console signs with, so an operator can check it
    /// against `gunnar key list` without leaving the page.
    pub console_fingerprint: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **The engine reaches the page in parts** (owner, 2026-10-02): name,
    /// description, source URL; an older gunnar's absent report is absent, and
    /// a URL that is not https is never linked.
    #[test]
    fn the_engine_is_carried_in_parts_and_only_an_https_source_is_linked() {
        let e = engine_of(Some(pb::StorageEngine {
            name: "znippy-plugin-git".into(),
            description: "Arrow-IPC archive; objects + refs".into(),
            repo_url: "https://git.gunnar.rs/nordisk/znippy".into(),
        }))
        .unwrap();
        assert_eq!((e.name.as_str(), e.description.as_str(), e.repo_url.as_str()), ("znippy-plugin-git", "Arrow-IPC archive; objects + refs", "https://git.gunnar.rs/nordisk/znippy"));
        assert_eq!(engine_of(None), None);
        assert_eq!(engine_of(Some(pb::StorageEngine::default())), None);
        let odd = engine_of(Some(pb::StorageEngine { name: "x".into(), description: String::new(), repo_url: "javascript:alert(1)".into() })).unwrap();
        assert_eq!(odd.repo_url, "", "only an https source is linked");
    }

    #[test]
    fn a_repository_that_was_never_repacked_says_so() {
        // gunnar sends 0, which would otherwise render as January 1970.
        let row = render_repo_storage(pb::RepoStorage {
            repo: Some(crate::api::repo_path("vetra", "gunnar")),
            last_repack_at_unix_ms: 0,
            ..Default::default()
        });

        assert_eq!(row.last_repack_at_unix_ms, None);
        assert_eq!(row.repo, "vetra/gunnar");
    }

    #[test]
    fn a_real_repack_time_is_kept() {
        let row = render_repo_storage(pb::RepoStorage {
            last_repack_at_unix_ms: 1_700_000_000_000,
            ..Default::default()
        });

        assert_eq!(row.last_repack_at_unix_ms, Some(1_700_000_000_000));
    }
}