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