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/room.rs

//! **`GET /api/account/room` — "My room" and "Per repository" on You → Account
//! → Storage.**
//!
//! Owner, 2026-10-02: storage is split by whose it is — personal under
//! Account, the machine under Administration. A signed-in person sees THEIR
//! namespace: used, cap and free, and a row per repository with its stored
//! size and its share of their room. Nothing about the box, and nobody else's
//! repositories.
//!
//! **Whose namespace is decided here and nowhere else**: the session's user,
//! bound to its principal (`account::principal_of`, the same binding every
//! `/api/account/*` route acts on). The request carries no name, so there is
//! nothing in it to change to read somebody else's. Everything — meters, cap,
//! `remaining` and the per-repository breakdown — is ONE `Usage.Read` asked
//! with the CALLER's credential ([`crate::api::Reader`]), so gunnar decides and
//! answers (thin layer, owner 2026-10-04; until then the sizes were cut out of
//! a whole-box `Admin.StorageInfo` read with the console's own key).
//!
//! **A row only when gunnar reports a value**: no cap is no cap (never a 0 and
//! never "unlimited" invented here); a last push gunnar did not record is
//! absent. gunnar keeps the last push per NAMESPACE, not per repository, so it
//! is a "My room" figure and the per-repository table has no such column.

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

use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// One person's room.
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct Room {
    pub namespace: String,
    /// What the namespace stores and is charged for (`pack_bytes`).
    pub used_bytes: u64,
    /// The cap on it, when there is one.
    pub cap_bytes: Option<u64>,
    /// Cap minus used, when there is a cap.
    pub free_bytes: Option<u64>,
    pub last_push_unix_ms: Option<i64>,
    /// Largest first.
    pub repos: Vec<RepoRoom>,
    /// **The ACCOUNT's room, shown to its member** (2026-10-07): a member has
    /// no room of his own — he uses the account's, reads it and buys none.
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub read_only: bool,
}

/// One repository of that namespace.
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct RepoRoom {
    /// The repository's name inside the namespace.
    pub repo: String,
    /// Its stored bytes: the archive minus the re-derivable read cache, plus
    /// the cold tier — the meter `pack_bytes` counts.
    pub stored_bytes: u64,
    /// Its share of the room (of the cap when there is one, else of what the
    /// namespace uses), per mille.
    pub share_permille: Option<u32>,
}

fn refuse(code: &str, message: &str) -> ApiError {
    ApiError(Fail { code: code.to_owned(), message: message.to_owned() })
}

/// **The room of `namespace`**, out of gunnar's answer and nothing else
/// (thin layer, owner 2026-10-04): `Usage.Read`'s meters, its cap, its
/// `remaining` and its per-repository breakdown — all asked AS THE PERSON,
/// under the namespace's own Read. Until then the table was cut out of a
/// whole-box `Admin.StorageInfo` read with the console's own key, and "stored"
/// and "free" were this console's arithmetic. The share is presentation (the
/// bar's width), never a number gunnar is asked to agree with.
pub fn room_of(
    namespace: &str,
    usage: &pb::NamespaceUsage,
    cap: Option<u64>,
    remaining: Option<u64>,
    stored: &[pb::RepoStored],
) -> Room {
    let used = usage.pack_bytes;
    let whole = cap.unwrap_or(used);
    let share = |bytes: u64| (whole > 0).then(|| ((u128::from(bytes) * 1000) / u128::from(whole)).min(u128::from(u32::MAX)) as u32);
    let mut repos: Vec<RepoRoom> = stored
        .iter()
        .filter_map(|r| r.repo.as_ref().map(|p| (p, r)))
        .map(|(path, r)| RepoRoom {
            repo: path.repo.clone(),
            stored_bytes: r.stored_bytes,
            share_permille: share(r.stored_bytes),
        })
        .collect();
    repos.sort_by(|a, b| b.stored_bytes.cmp(&a.stored_bytes).then_with(|| a.repo.cmp(&b.repo)));
    Room {
        namespace: namespace.to_owned(),
        used_bytes: used,
        cap_bytes: cap,
        free_bytes: remaining,
        last_push_unix_ms: (usage.last_push_unix_ms > 0).then_some(usage.last_push_unix_ms),
        repos,
        read_only: false,
    }
}

/// `GET /api/account/room` — one `Usage.Read`, asked as the person.
///
/// **A member's room is the ACCOUNT's** (owner 2026-10-07: a customer is an
/// ACCOUNT that pays; its members use its space). gunnar names the account
/// (`Access.WhoAmI`), and the same `Usage.Read` is asked for it as the member
/// — the Read on the account the enrolment gave him is what lets gunnar
/// answer. Never a namespace of his own: there is none (the walkthrough's
/// "namespace ben is not in the catalog").
pub async fn room(
    me: crate::api::account::Me,
    reader: crate::api::Reader,
    caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
    State(state): State<AppState>,
) -> ApiResult<Room> {
    let member = crate::auth::middleware::member(&state, caller.as_ref().map(|c| &c.0))
        .await
        .map_err(|why| ApiError(Fail { code: "unavailable".to_owned(), message: why }))?;
    let namespace = match &member {
        Some(member) => member.account.clone(),
        None => crate::api::account::principal_of(&state, &me.0).await?,
    };
    let read = reader
        .gunnar
        .usage()
        .read(pb::ReadUsageRequest { namespace: namespace.clone() })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let usage = read.usage.ok_or_else(|| {
        refuse("internal", "Gunnar answered without your account's figures, so there is nothing to show yet; try again in a moment")
    })?;
    // gunnar reads a 0 cap as unlimited, and so does this.
    let cap = read.allowance.as_ref().and_then(|a| a.pack_bytes).filter(|c| *c > 0);
    let remaining = read.remaining.as_ref().and_then(|r| r.pack_bytes);
    Ok(Json(Room { read_only: member.is_some(), ..room_of(&namespace, &usage, cap, remaining, &read.repos) }))
}

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

    const GIB: u64 = 1 << 30;

    fn repo(name: &str, stored: u64) -> pb::RepoStored {
        pb::RepoStored {
            repo: Some(pb::RepoPath { namespace: "anna".into(), repo: name.into() }),
            stored_bytes: stored,
            ..Default::default()
        }
    }

    /// ★ **My room is gunnar's answer for my namespace**: its meters, its cap,
    /// its `remaining` and its per-repository breakdown, largest first —
    /// nothing re-derived from a whole-box storage report.
    #[test]
    fn my_room_is_gunnars_answer_for_my_namespace() {
        let usage = pb::NamespaceUsage { pack_bytes: 3 * GIB, last_push_unix_ms: 1_700_000_000_000, ..Default::default() };
        let stored = [repo("small", GIB), repo("big", 2 * GIB)];
        let room = room_of("anna", &usage, Some(10 * GIB), Some(7 * GIB), &stored);
        assert_eq!(room.namespace, "anna");
        assert_eq!((room.used_bytes, room.cap_bytes, room.free_bytes), (3 * GIB, Some(10 * GIB), Some(7 * GIB)));
        assert_eq!(room.last_push_unix_ms, Some(1_700_000_000_000));
        let names: Vec<&str> = room.repos.iter().map(|r| r.repo.as_str()).collect();
        assert_eq!(names, ["big", "small"], "largest first: {room:?}");
        assert_eq!(room.repos[0].stored_bytes, 2 * GIB, "gunnar's stored bytes, as sent");
        assert_eq!(room.repos[0].share_permille, Some(200), "2 of a 10 GiB room");
    }

    /// No cap: no cap, no free (gunnar sends no `remaining`), and the share is
    /// of what the namespace uses. No last push recorded: absent, not 1970.
    #[test]
    fn without_a_cap_nothing_is_invented() {
        let usage = pb::NamespaceUsage { pack_bytes: 4 * GIB, ..Default::default() };
        let room = room_of("anna", &usage, None, None, &[repo("only", 4 * GIB)]);
        assert_eq!((room.cap_bytes, room.free_bytes, room.last_push_unix_ms), (None, None, None));
        assert_eq!(room.repos[0].share_permille, Some(1000));
        let empty = room_of("anna", &pb::NamespaceUsage::default(), None, None, &[]);
        assert!(empty.repos.is_empty() && empty.used_bytes == 0);
    }
}