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