crates/gunnar-ui-web/src/pages/usage.rs
//! **What a namespace holds.** gunnar's own meters, one namespace at a time.
//!
//! # Why this is not the Storage page again
//!
//! The Storage page answers a server operator's question — which repositories
//! are big, what is hot, what is cold, what has never been repacked — and it
//! reads `Admin.StorageInfo`, which is every repository on the box and takes a
//! server-wide grant.
//!
//! This page answers a *namespace's own* question: how much of this disk is
//! mine, and which part of it is the part that cannot be thrown away? It reads
//! `Usage.Read`, which names one namespace and takes `Read` **on that
//! namespace** — so it is the page that still works when the credential looking
//! at it is not the server's.
//!
//! Nothing here is a price. gunnar counts these numbers whether or not
//! anything ever reads them, and a person running gunnar on their own disk gets
//! exactly this page.
//!
//! # Room: used, allowed, remaining
//!
//! Since 2026-09-05 the same `Usage.Read` carries the namespace's
//! **allowance** — per meter, the cap the box holds for it, absent when there
//! is none — and the page draws each meter as three numbers: what is used,
//! what is allowed (`no cap` when nothing caps it; the community appliance
//! caps nobody), and what is left. "Left" is the console's one derived number
//! and it has one writer (`api::usage::remaining` on the server): what the cap
//! leaves, and for a byte meter no more than the box can serve. Without a cap
//! it is the box alone; without the box either, there is nothing honest to say
//! and a dash is drawn. A cap is a resource number and nothing else: no plan
//! name, no date, no link, so this panel is exactly as useful to a self-hoster
//! who set no caps as to a tenant who has some.
//!
//! # A cap is not automatically a wall, and the table draws the difference
//!
//! Three of the seven meters are a DOOR and four are a READING, and until
//! gunnar grew `Allowance.enforcement` all seven arrived in one shape under one
//! message called `Allowance`. A tenant whose signed fact carried
//! `caps["repos"]=5` was shown a limit of 5 in the Allowed column, and nothing
//! on the box refused the sixth repository. A number shown as a limit that no
//! code enforces is a promise the product does not keep, and drawing both kinds
//! identically is what made it one.
//!
//! So the row's SHAPE carries it, not only its words:
//!
//! * an **ENFORCED** cap is a WALL — a bar under the meter, filled to how far
//! in the tenant is, ending at the cap. A bar is the only thing on a page
//! that says "and then it stops";
//! * a **MEASURED** cap is a READING — a figure and no bar at all, because a
//! bar IS a ceiling and drawing one where nothing refuses is the defect
//! itself.
//!
//! The sentence beside each is `monetize_product::Enforcement::describe()`
//! verbatim — *"the product refuses when over"*, *"counted and reported;
//! nothing refuses"*. gunnar put monetize's vocabulary on the wire on purpose;
//! a third phrasing invented here would be the one that goes stale, because
//! nothing compares a console's prose to another repository's enum.
//!
//! A gunnar older than the field says nothing, and **nothing is not "nothing is
//! enforced"** ([`Shape::NotSaid`]): that box still cuts a push mid-stream at
//! `pack_bytes`. It gets no bar — this console will not draw a ceiling it was
//! not told about — and no claim that nothing refuses either.
//!
//! The box line under it is `Admin.Capacity`: how much of the disk gunnar can
//! still serve, and whether the set can grow at all — `cannot grow while
//! running` is the appliance's answer, `an operator can grow it` a box with a
//! shell beside gunnar. That RPC takes a server-wide `Read`; a console whose
//! key holds less gets a dash and gunnar's reason, never a figure it did not
//! read.
//!
//! # The meters are not one number
//!
//! They are split because the splits are what a person acts on:
//!
//! * **`pack_bytes`** is what is not re-derivable. Everything else on the disk
//! can be rebuilt from it.
//! * **`cache_bytes`** is the exploded read cache. gunnar will re-derive it; it
//! is here because it is on the disk today, not because it is owed.
//! * **`tombstoned_bytes`** is the answer to *I deleted a repository and my
//! usage did not go down.* It did not, and this is by how much. Those bytes
//! are in none of the others: a tombstoned repository has no catalog row.
//! * **`objects`** is not bytes at all. It is the indexed objects of the stores
//! that are OPEN right now, which is the RAM the server is spending, at
//! roughly 382 B each.
//!
//! `lfs_bytes` is 0 on every gunnar there is — the server has no LFS store yet —
//! and it is drawn as a real zero with the reason attached rather than hidden,
//! because a meter that vanishes reads as a meter nobody counted.
//!
//! # Activity: what MOVED, beside what is held
//!
//! Every meter above is a **stock** meter, and that is the thing this page
//! could not say until 2026-09-10: a namespace pushed to this morning and one
//! whose last transfer was in March draw an identical page. `Usage.Activity`
//! answers the other question — one row per UTC day that moved something — and
//! the panel draws the last thirty of them as two charts, bytes in and out and
//! then pushes and fetches, with the days transfers were turned away on marked.
//!
//! Three things about it are decisions and not drawing, so all three live in
//! [`crate::activity`] where `cargo test` can reach them:
//!
//! * **"never" is not 1970.** gunnar sends 0 for a namespace nothing has
//! touched inside the kept window; the "last seen" line says *never*.
//! * **A day with no row is a gap, not a zero.** gunnar writes no row for a
//! quiet day and the server carries the absence through, so the chart lays
//! the rows it has into a fixed thirty-day axis and leaves the rest empty.
//! * **An empty chart means one of three different things** — nothing moved,
//! this box does not count activity, or this could not be read — and a page
//! with one empty state would tell an operator their busiest namespace is
//! idle when what happened is that gunnar is older than the ledger.
use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;
use serde::Deserialize;
use crate::activity::{
bars, last_seen_line, no_bars, no_bars_after_refusal, no_bars_detail, no_bars_title, Activity,
Bar,
};
use crate::api::{segment, Page};
use crate::fmt;
use crate::pages::{now_ms, resolved, when};
use crate::ui::{Empty, FailBox, Hint, Loading, Metric, PageHeader, Panel, Truncation, Unset};
/// **The Room table's types and its decisions live in [`crate::room`]**, not
/// here, and the reason is that this module is `#[cfg(target_arch = "wasm32")]`
/// — nothing in it is compiled by `cargo test` on the host, so a test written
/// beside this view would never run. What is left here is the drawing.
pub use crate::room::{
box_line, describe, fill_pct, growth_phrase, room_rows, shape_of, Machine, PerMeter, RoomRow,
Shape, Usage,
};
/// The Room panel: used / allowed / remaining per meter, and the box line.
#[component]
fn Room(usage: Usage) -> impl IntoView {
let rows = room_rows(&usage);
let line = box_line(usage.machine.as_ref());
let why_no_box = usage.box_unreadable.clone();
view! {
<Panel title="Room">
<div class="table-wrap">
<table class="room">
<caption class="vh">"Used, allowed and remaining, per meter"</caption>
<thead>
<tr>
<th scope="col">"Meter"</th>
<th scope="col">"Used"</th>
<th scope="col">"Allowed"</th>
<th scope="col">"Remaining"</th>
<th scope="col">"At the cap"</th>
</tr>
</thead>
<tbody>
{rows
.into_iter()
.map(|row| {
let label = row.meter;
view! {
<tr>
<th scope="row">
{label}
// **The wall, drawn.** A bar with a
// ceiling, under the meter it belongs
// to and only where gunnar said it
// refuses. A reading gets no bar at
// all: a bar IS a ceiling, and drawing
// one where nothing stops is the
// defect this table was carrying.
{match row.fill {
Some(pct) => view! {
<div
class="wall"
role="progressbar"
aria-label=format!("{label}, against a cap the box refuses at")
aria-valuenow=pct.to_string()
aria-valuemin="0"
aria-valuemax="100"
>
<span style=format!("width:{pct}%")></span>
</div>
}
.into_any(),
None => ().into_any(),
}}
</th>
<td class="num">{row.used}</td>
<td class="num">
{match row.allowed {
Some(cap) => view! { {cap} }.into_any(),
None => view! { "no cap" }.into_any(),
}}
</td>
<td class="num">
{match row.remaining {
Some(left) => view! { {left} }.into_any(),
None => view! {
<Unset why="nothing this console can read bounds it"/>
}
.into_any(),
}}
</td>
// gunnar's word, in monetize's own
// sentence. Never this console's paraphrase.
<td class="does">
{match row.shape {
Shape::Wall | Shape::Reading => {
view! { <span>{row.phrase}</span> }.into_any()
}
Shape::NotSaid => view! {
<Unset why="this Gunnar does not report what a cap on this meter does; \
it is older than the field that says so, and it still \
refuses on the meters it always refused on"/>
}
.into_any(),
}}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
<div class="panel-body">
{match line {
Some(line) => view! { <p class="meta box-line">{line}</p> }.into_any(),
None => view! {
<p class="meta box-line">
"This box: "
<Unset why=why_no_box.clone()/>
</p>
<p class="hint" title="So \u{201c}remaining\u{201d} is what each cap leaves and nothing more.">
"The box could not be read with this console's key. Gunnar said: " {why_no_box}
</p>
}
.into_any(),
}}
<Hint
line="A row with a bar is a wall Gunnar refuses at; a row without one is a reading."
more="\u{201c}Allowed\u{201d} is the cap this box holds for the meter, or \
\u{201c}no cap\u{201d} when nothing caps it. \u{201c}Remaining\u{201d} is \
what the cap leaves, and for bytes never more than the box can still \
serve; with no cap it is the box alone. \u{201c}At the cap\u{201d} is what \
the box does when the number crosses it, and it is not the same for every \
meter. A row with a BAR is a wall: Gunnar refuses when over, and the bar \
is how full it is. A row with no bar is a reading \u{2014} the number is \
counted and reported, and crossing it is not an event here."
/>
</div>
</Panel>
}
}
/// **One chart: thirty slots, two series, one scale.**
///
/// `first` and `second` pick the two percentages off each slot, so the bytes
/// chart and the counts chart are the same component and cannot drift apart.
/// The percentages were computed against one peak per chart in
/// [`crate::activity::bars`] — two bars scaled independently would show a day
/// that served a gigabyte and accepted a kilobyte as two bars of the same
/// height.
#[component]
fn DayChart(
#[prop(into)] title: String,
rows: Vec<Bar>,
first: fn(&Bar) -> u32,
second: fn(&Bar) -> u32,
) -> impl IntoView {
let (from, to) = (
rows.first().map(|b| b.label.clone()).unwrap_or_default(),
rows.last().map(|b| b.label.clone()).unwrap_or_default(),
);
view! {
<div class="flow">
<h3>{title}</h3>
<ol class="days">
{rows
.into_iter()
.map(|bar| {
// The sentence a screen reader gets, and the one a
// pointer gets, are the SAME sentence: a chart whose
// meaning is only in a tooltip is a chart half the
// operators of this console cannot read.
let title = bar.hint.clone();
let hint = bar.hint.clone();
let marked = if bar.refused > 0 { "refused" } else { "" };
view! {
<li class=marked title=title>
// A day gunnar sent NO row for gets no bar at
// all — not a bar of height zero, which reads
// as a measurement that was taken.
{bar
.moved
.then(|| {
view! {
<span
class="a"
style=format!("height:{}%", first(&bar))
></span>
<span
class="b"
style=format!("height:{}%", second(&bar))
></span>
}
})}
<span class="vh">{hint}</span>
</li>
}
})
.collect_view()}
</ol>
<p class="axis">
<span>{from}</span>
<span>{to}</span>
</p>
</div>
}
}
/// **The Activity panel: the last thirty UTC days of what moved.**
///
/// Its own read, because it is its own RPC: `Usage.Activity` takes the same
/// grant `Usage.Read` does — `Read` on the namespace — but a page that folded
/// the two into one request would make a gunnar too old to answer the series
/// refuse the meters as well.
///
/// The refusal is therefore handled here rather than by [`resolved`]: one
/// refusal, `Unimplemented`, is a fact about the BOX — a gunnar older than the
/// FLOW ledger — and it must not be drawn as an idle tenant. Every other
/// refusal is *this could not be read*, and gunnar's own words go under it in
/// the usual [`FailBox`].
#[component]
fn Flow(
namespace: Signal<String>,
last_push_unix_ms: Option<i64>,
last_fetch_unix_ms: Option<i64>,
) -> impl IntoView {
let series = LocalResource::new(move || {
let url = format!("/api/namespaces/{}/activity", segment(&namespace.get()));
async move { crate::api::get::<Activity>(&url).await }
});
view! {
<Panel title="Activity">
<div class="panel-body">
// The "last seen" pair. It comes off the METERS read, not off
// the series: it looks back over gunnar's whole kept window and
// is true for a namespace whose last push was outside the
// thirty days below.
<p class="meta">{move || last_seen_line(last_push_unix_ms, last_fetch_unix_ms, now_ms())}</p>
</div>
{move || {
match series.get() {
None => view! { <Loading rows=2/> }.into_any(),
Some(Err(fail)) => {
let state = no_bars_after_refusal(fail.status, &fail.code);
view! {
<div class="panel-body">
// 0 retained days: gunnar refused, so it never
// said how far back it remembers, and the two
// sentences reachable from a refusal do not
// quote a window. Only the QUIET one does, and
// it is only reachable from an answer.
<Empty
title=no_bars_title(state)
detail=no_bars_detail(state, 0)
/>
<FailBox fail=fail/>
</div>
}
.into_any()
}
Some(Ok(series)) => {
if series.days.is_empty() {
let state = no_bars(series.retained_days);
return view! {
<div class="panel-body">
<Empty
title=no_bars_title(state)
detail=no_bars_detail(state, series.retained_days)
/>
</div>
}
.into_any();
}
let rows = bars(&series, now_ms());
view! {
<DayChart
title="Bytes in and out"
rows=rows.clone()
first=|b| b.bytes_in_pct
second=|b| b.bytes_out_pct
/>
<DayChart
title="Pushes and fetches"
rows=rows
first=|b| b.pushes_pct
second=|b| b.fetches_pct
/>
<div class="panel-body">
<p class="legend">
<span><i class="a"></i>"in / pushes"</span>
<span><i class="b"></i>"out / fetches"</span>
<span><i class="mark"></i>"transfers refused"</span>
</p>
<Hint
line="One bar per UTC day, oldest on the left. Hover or focus a day for its figures."
more="The two bars in a chart share a scale so they can be compared. \
A day with no bar moved nothing: Gunnar writes no row for a \
quiet day and this console draws none rather than a zero it \
did not measure. A marked day had transfers turned away — \
hover or focus a day for its figures and the reasons."
/>
</div>
}
.into_any()
}
}
}}
</Panel>
}
}
/// **The Admission panel**: `Usage.AdmitPlan` — the budget line, then per
/// kind of work the class gunnar would give the next transfer, its estimate
/// and gunnar's own sentence. Its own read, for [`Flow`]'s reason.
#[component]
fn Admission(namespace: Signal<String>) -> impl IntoView {
let plan = LocalResource::new(move || {
let url = format!("/api/namespaces/{}/admit", segment(&namespace.get()));
async move { crate::api::get::<crate::admit::AdmitPlan>(&url).await }
});
view! {
<Panel title="Admission">
{move || match plan.get() {
None => view! { <Loading rows=2/> }.into_any(),
Some(Err(fail)) => view! {
<div class="panel-body"><FailBox fail=fail/></div>
}
.into_any(),
Some(Ok(plan)) => {
let line = crate::admit::budget_line(&plan);
let rows = plan
.kinds
.iter()
.map(|k| {
let estimate = crate::admit::estimate_line(k);
view! {
<tr>
<td>{k.kind.clone()}</td>
<td>{k.class.clone()}</td>
<td>{estimate}</td>
<td class="meta">{k.why.clone()}</td>
</tr>
}
})
.collect_view();
view! {
<div class="panel-body">
<p>{line}</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>"work"</th><th>"class"</th><th>"estimate"</th><th>"Gunnar says"</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
}
.into_any()
}
}}
</Panel>
}
}
/// A namespace row, from `GET /api/namespaces`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Namespace {
pub name: String,
#[serde(default)]
pub description: String,
/// gunnar's kind of the principal that owns it (`user`, `machine`, …).
#[serde(default)]
pub owner_kind: String,
}
/// `/usage` — pick a namespace.
///
/// A list rather than a dropdown: the names are links, so one can be bookmarked
/// and sent to whoever the namespace belongs to.
#[component]
pub fn UsageIndex() -> impl IntoView {
let namespaces = LocalResource::new(|| async {
crate::api::get::<Page<Namespace>>("/api/namespaces").await
});
// The box's own namespaces — owned by a principal that is not a person,
// by gunnar's `owner_kind` (`nav::is_system_namespace`) — are hidden until
// somebody asks for them.
let show_system = RwSignal::new(false);
view! {
<PageHeader
title="Usage"
>
<label class="toggle">
<input type="checkbox" prop:checked=move || show_system.get() on:change=move |ev| show_system.set(event_target_checked(&ev))/>
" Show system principals"
</label>
</PageHeader>
{move || resolved(namespaces.get(), None, |page: Page<Namespace>| {
let all = show_system.get();
let page = Page {
items: page
.items
.into_iter()
.filter(|n| all || !crate::nav::is_system_namespace(&n.owner_kind))
.collect(),
..page
};
if page.items.is_empty() {
return view! {
<Empty
title="No namespaces yet"
detail="A namespace appears here once one exists to measure."
/>
}
.into_any();
}
let rows = page.items.clone();
let (shown, total) = (rows.len(), page.total);
view! {
<Panel title="Namespaces">
<div class="table-wrap">
<table>
<caption class="vh">"Namespaces"</caption>
<thead>
<tr>
<th scope="col">"Namespace"</th>
<th scope="col">"Description"</th>
</tr>
</thead>
<tbody>
{rows
.into_iter()
.map(|ns| {
let href = format!("/usage/{}", segment(&ns.name));
view! {
<tr>
<td class="mono">
<A href=href>{ns.name.clone()}</A>
</td>
<td>
{if ns.description.is_empty() {
view! { <Unset why="no description"/> }.into_any()
} else {
view! { {ns.description.clone()} }.into_any()
}}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
<Truncation shown=shown total=total/>
</Panel>
}
.into_any()
})}
}
}
/// `/usage/:namespace` — one namespace's meters.
///
/// A refusal is rendered as a refusal. gunnar answers `PermissionDenied` for a
/// namespace the caller may not read and the console passes its words through,
/// so this page never shows a namespace as empty when what it means is that it
/// was not allowed to look.
#[component]
pub fn NamespaceUsage() -> impl IntoView {
let params = use_params_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let usage = LocalResource::new(move || {
let url = format!("/api/namespaces/{}/usage", segment(&namespace.get()));
async move { crate::api::get::<Usage>(&url).await }
});
// A namespace that does not exist for this caller — absent, or private
// and not theirs; the server answers both with the same 404 — draws
// "does not exist" and nothing else: no header naming it, no panes.
let absent = move || crate::gate::gate(&usage.get()) == crate::gate::Gate::Absent;
view! {
<Show when=move || !absent() fallback=|| view! { <super::NotHere/> }>
<PageHeader
title=move || namespace.get()
>
<A href="/usage" attr:class="btn quiet">"All namespaces"</A>
</PageHeader>
{move || {
let now = now_ms();
resolved(usage.get(), None, move |usage: Usage| {
view! {
<div class="stack">
<Room usage=usage.clone()/>
// **Directly under the number that made somebody want
// it**, and only in a `metered` build: the community
// console does not contain the panel at all, so this
// page keeps its promise — "nothing here is a price" —
// by construction rather than by a runtime check.
//
// Even in a metered build the panel renders NOTHING
// unless the server has a monetize bearer. `Room` above
// it is unaffected either way: a cap is a resource
// number, and the table is the same table for a
// self-hoster who will never pay anyone.
{
// Not on a SOLD (provided) Gunnar (one shop,
// 2026-10-07): nothing is bought there.
#[cfg(feature = "metered")]
{
let session = crate::app::session();
move || session.get().filter(|s| s.sells_here()).map(|_| view! {
<crate::pages::increase::MoreRoom
namespace=Signal::derive(move || namespace.get())
/>
})
}
}
// **What moved**, under what is held. Below the Room
// table because a tenant reading this page has come for
// the disk; above the per-tier breakdown because "am I
// still being used, and am I hitting walls" is the
// question the tiers cannot answer at all.
<Flow
namespace=Signal::derive(move || namespace.get())
last_push_unix_ms=usage.last_push_unix_ms
last_fetch_unix_ms=usage.last_fetch_unix_ms
/>
<Admission namespace=Signal::derive(move || namespace.get())/>
<Panel title="On disk">
<dl class="metrics">
<Metric
label="Not re-derivable"
value=fmt::bytes(usage.pack_bytes)
unit="packs and their indexes"
/>
<Metric
label="Read cache"
value=fmt::bytes(usage.cache_bytes)
unit="re-derivable"
/>
<Metric
label="Cold tier"
value=fmt::bytes(usage.cold_bytes)
unit="archived"
/>
<Metric
label="LFS"
value=fmt::bytes(usage.lfs_bytes)
unit="Gunnar has no LFS store yet"
/>
</dl>
<Hint
line="\"Not re-derivable\" is the number that matters: the rest can be rebuilt from it."
more="Everything else on this row can be rebuilt from it. The read cache \
is there because it is on the disk today, not because it is owed \
— Gunnar will make it again if it is dropped."
/>
</Panel>
<Panel title="Held after a delete">
<div class="panel-body">
<dl class="metrics">
<Metric
label="Tombstoned"
value=fmt::bytes(usage.tombstoned_bytes)
unit="not in the numbers above"
/>
</dl>
{if usage.tombstoned_bytes > 0 {
view! {
<Hint
line="Deleted repositories hold their bytes until the retention window closes."
more="This is the answer to \u{201c}I deleted a repository and my \
usage did not go down\u{201d}. A delete is a SOFT delete: \
the name stops working immediately and the bytes stay \
until the retention window closes. A tombstoned \
repository has no catalog row, so these bytes are in \
none of the meters above."
/>
}
.into_any()
} else {
view! {
<p class="hint">
"Nothing deleted is still being held. When something \
is, its bytes appear here and in none of the meters \
above."
</p>
}
.into_any()
}}
</div>
</Panel>
<Panel title="What is in it">
<dl class="metrics">
<Metric label="Repositories" value=fmt::count(usage.repos)/>
<Metric
label="Indexed objects"
value=fmt::count(usage.objects)
unit="in stores open right now"
/>
</dl>
<div class="panel-body">
<p class="meta">
"Measured "{move || when(usage.measured_at_unix_ms, now)}
</p>
<Hint
line="Indexed objects is not a byte count: it is the stores OPEN right now."
more="It is the objects indexed by the stores that are OPEN in the \
server right now — roughly 382 B of RAM each — so it falls when \
a store is closed without anything leaving the disk."
/>
</div>
</Panel>
</div>
}
})
}}
</Show>
}
}