crates/gunnar-ui-web/src/pages/storage.rs
//! What the object store holds, and where — the types and the panels the two
//! Storage pages share (You → Account → Storage is `pages::my_storage`,
//! Administration → Storage is `pages::admin_storage`).
//!
//! gunnar keeps a hot tier in redb and a cold tier in a znippy archive, so the
//! interesting number is not "how big is the repository" but how the two
//! compare — a repository with everything still hot has never been repacked.
use leptos::prelude::*;
use serde::Deserialize;
use crate::api::Page;
use crate::fmt;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, FailBox, Hint, Metric, Panel, Tone, Unset};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Storage {
pub object_backend: String,
pub ref_backend: String,
/// The engine in parts (name, description, source repository); absent
/// from an older console server.
#[serde(default)]
pub engine: Option<Engine>,
pub total_hot_bytes: u64,
pub total_cold_bytes: u64,
#[serde(default)]
pub cold_measured: bool,
pub repos: Vec<RepoStorage>,
/// The exploded read cache and the sum of the per-repository budgets.
#[serde(default)]
pub total_explode_bytes: u64,
#[serde(default)]
pub total_explode_budget_bytes: u64,
/// Bytes held by soft-deleted repositories that have not been purged.
///
/// WHICH repositories hold them comes from `Repos.ListDeleted` at
/// `/api/repos/deleted` rather than from here. `Admin.StorageInfo` carries
/// a list too, but it is the storage layer's echo of the tombstones; the
/// listing that owns the question is the one that also says who deleted
/// each repository and is what the undo hangs off — one writer for
/// "which repositories are deleted".
#[serde(default)]
pub total_tombstoned_bytes: u64,
}
/// The storage engine, as gunnar names it: shown as its name, linked to its
/// source, its description the tooltip.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Engine {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub repo_url: String,
}
/// One soft-deleted repository, from `Repos.ListDeleted`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct DeletedRepo {
pub path: String,
pub namespace: String,
pub name: String,
pub bytes: u64,
pub deleted_at_unix_ms: i64,
/// `None` = no deadline. A reclaim that FAILED leaves one of these: bytes
/// owed to an operator rather than bytes on a schedule.
pub purge_after_unix_ms: Option<i64>,
/// Who asked for the delete. Empty when gunnar did not record one.
#[serde(default)]
pub principal_id: String,
}
/// One store directory with no catalog row and no tombstone.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Orphan {
pub path: String,
pub dir: String,
pub bytes: u64,
#[serde(default)]
pub hash_marker: String,
pub modified_unix_ms: Option<i64>,
#[serde(default)]
pub failed_reclaim: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RepoStorage {
pub repo: String,
pub hash_kind: String,
pub loose_object_count: u64,
pub hot_bytes: u64,
pub cold_bytes: u64,
pub cold_object_count: u64,
#[serde(default)]
pub cold_measured: bool,
pub last_repack_at_unix_ms: Option<i64>,
pub ref_count: u64,
#[serde(default)]
pub explode_bytes: u64,
#[serde(default)]
pub explode_budget_bytes: u64,
}
/// ★ **Bytes held by repositories somebody has already deleted — and the undo.**
///
/// Absent when there are none. Present, it is the answer to the only question
/// this number ever prompts: *I deleted a repository and my usage did not go
/// down.* It did not, and this says by how much, until when, and who did it.
///
/// # The undo that existed and could not be reached
///
/// The console's delete is a SOFT delete (`purge: false`, gunnar's retention
/// window), so every one of those clicks was undoable — from the control plane,
/// on a box whose PID 1 has no shell. `Repos.Undelete` has been there the whole
/// time and nothing in the console called it, which made a reversible action
/// read as an irreversible one to everybody who only has the console. The
/// button is here, beside the row it is about, because that is where somebody
/// who has just deleted the wrong repository will look.
///
/// **There is no Purge button, deliberately.** Purge destroys objects with no
/// undo of its own, and it stays an operator's decision at the control plane.
///
/// These bytes are NOT inside the hot or cold totals on this page: a tombstoned
/// repository has no catalog row, so it is in none of the per-repository rows.
/// It is charged to the account all the same.
#[component]
pub fn Deleted(held: u64) -> impl IntoView {
let reload = RwSignal::new(0u32);
let deleted = LocalResource::new(move || {
// Read here, so an undo shows up without a page reload.
reload.get();
async { crate::api::get::<Page<DeletedRepo>>("/api/repos/deleted").await }
});
let may_write = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.may_write()));
view! { {move || {
let now = now_ms();
resolved(deleted.get(), None, move |page: Page<DeletedRepo>| {
// Nothing deleted and nothing held: no panel. An operator who has
// never deleted anything does not need a table telling them so.
if page.items.is_empty() && held == 0 {
return ().into_any();
}
let rows = page.items.clone();
view! {
<div id="deleted">
<Panel title="Deleted, not yet purged">
<div class="panel-body">
<dl class="metrics">
<Metric
label="Held"
value=fmt::bytes(held)
unit="not in the totals above"
/>
<Metric label="Repositories" value=fmt::count(rows.len() as u64)/>
</dl>
<Hint
line="A SOFT delete: the bytes stay until the retention window closes. Undo restores it."
more="A delete from this console is a SOFT delete: the name stops \
working and nothing can be pushed to it, but the bytes stay until \
the retention window closes. Undo puts the repository back exactly \
as it was — the recorded hash kind included. Reclaiming the bytes \
early is `gunnar control repo purge`, which has no undo and is not \
a button here."
/>
</div>
{if rows.is_empty() {
view! {
<div class="panel-body">
<Unset why="Gunnar reports the bytes but no tombstone rows"/>
</div>
}
.into_any()
} else {
view! {
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Held"</th>
<th scope="col">"Deleted"</th>
<th scope="col">"By"</th>
<th scope="col">"Purges"</th>
<th scope="col">"Undo"</th>
</tr>
</thead>
<tbody>
{rows
.into_iter()
.map(|t| {
view! {
<tr>
<td class="mono">{t.path.clone()}</td>
<td class="num">{fmt::bytes(t.bytes)}</td>
<td class="num">
{fmt::since(t.deleted_at_unix_ms, now)}
</td>
<td class="mono">
{if t.principal_id.is_empty() {
view! {
<Unset why="Gunnar recorded no principal"/>
}
.into_any()
} else {
view! { {t.principal_id.clone()} }.into_any()
}}
</td>
<td class="num">
{match t.purge_after_unix_ms {
Some(at) => view! { {fmt::deadline(at, now)} }.into_any(),
// No deadline is a reclaim that
// FAILED, not a window that never
// closes.
None => view! {
<Unset why="no deadline — a reclaim failed here"/>
}
.into_any(),
}}
</td>
<td>
<Show when=move || may_write.get()>
<Undo
repo=t.clone()
on_undone=Callback::new(move |()| {
reload.update(|n| *n += 1)
})
/>
</Show>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
}}
</Panel>
</div>
}
.into_any()
})
}} }
}
/// The undo itself: `Repos.Undelete`, one repository.
///
/// It reports gunnar's refusal verbatim rather than a shrug. The window CAN
/// close between the page being drawn and the button being pressed, and
/// "the tombstone is gone" is the one answer an operator must not have to
/// guess at.
#[component]
fn Undo(repo: DeletedRepo, on_undone: Callback<()>) -> impl IntoView {
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let path = format!(
"/api/repos/{}/{}/undelete",
crate::api::segment(&repo.namespace),
crate::api::segment(&repo.name)
);
let click = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
let path = path.clone();
leptos::task::spawn_local(async move {
match crate::api::post_empty::<serde_json::Value>(&path).await {
Ok(_) => on_undone.run(()),
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<button class="btn" disabled=move || busy.get() on:click=click>
{move || if busy.get() { "Restoring…" } else { "Undo delete" }}
</button>
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
}
}
/// ★ **Store directories with no catalog row and no tombstone**: bytes nothing
/// else on the box can see, under a name nothing else can free.
///
/// `List` reads the catalog and an orphan is not in it; the deleted list keys
/// off a tombstone and an orphan has none — so before `Repos.ListOrphans` these
/// bytes appeared in no listing anywhere, while `Create` refused the name
/// because the directory was there. On the appliance that was ~25-39 GB of a
/// failed kernel mirror standing at `alice/linux`. **An orphan is invisible
/// bytes and invisible bytes are a billing surprise**, which is why this panel
/// exists at all.
///
/// **It shows and does not touch.** The two exits — `ReclaimOrphan`, and
/// `Create --adopt` with the hash this row reports — either destroy objects
/// somebody wrote or take responsibility for them, and both are an operator's
/// decision at the control plane rather than a button beside a size.
///
/// Admin only: the rows carry paths on the appliance's disk.
#[component]
pub fn Orphans() -> impl IntoView {
let orphans =
LocalResource::new(|| async { crate::api::get::<Page<Orphan>>("/api/orphans").await });
let is_admin = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.is_admin()));
view! { {move || {
if !is_admin.get() {
return ().into_any();
}
let now = now_ms();
resolved(orphans.get(), None, move |page: Page<Orphan>| {
// No orphans, no panel: this is the ordinary state of a healthy
// server, and a permanent "none" would be noise on every visit.
if page.items.is_empty() {
return ().into_any();
}
let stranded: u64 = page.items.iter().map(|o| o.bytes).sum();
let rows = page.items.clone();
view! {
<Panel title="Orphan stores">
<div class="panel-body">
<dl class="metrics">
<Metric label="Stranded" value=fmt::bytes(stranded)/>
<Metric label="Directories" value=fmt::count(rows.len() as u64)/>
</dl>
<Hint
line="Stores with no catalog row and no tombstone. Exits: `gunnar control repo reclaim` or `create --adopt`."
more="These bytes are in no other listing on this page and in no repository, \
and the name cannot be re-created while the directory is there. The \
two exits are `gunnar control repo reclaim <ns/name>`, which destroys \
the objects, and `gunnar control repo create <ns/name> --adopt \
--hash <marker>`, which takes them over as a repository. Both are \
deliberate and neither is a button here."
/>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">"Name"</th>
<th scope="col">"On disk"</th>
<th scope="col">"Size"</th>
<th scope="col">"Hash"</th>
<th scope="col">"Last written"</th>
<th scope="col">"Why"</th>
</tr>
</thead>
<tbody>
{rows
.into_iter()
.map(|o| {
view! {
<tr>
<td class="mono">{o.path.clone()}</td>
<td class="mono">{o.dir.clone()}</td>
<td class="num">{fmt::bytes(o.bytes)}</td>
<td class="mono">
{if o.hash_marker.is_empty() {
// What `--adopt` must be told, and
// the reason one adopt was refused.
view! {
<Unset why="no OBJECT_FORMAT marker in the store"/>
}
.into_any()
} else {
view! { {o.hash_marker.clone()} }.into_any()
}}
</td>
<td class="num">
{match o.modified_unix_ms {
Some(at) => view! { {fmt::since(at, now)} }.into_any(),
None => view! { <Unset why="nothing readable inside"/> }
.into_any(),
}}
</td>
<td>
{if o.failed_reclaim.is_empty() {
view! {
<Badge
tone=Tone::Warn
label="no record of it anywhere"
/>
}
.into_any()
} else {
// Bytes OWED to an operator: the
// deleted list filters these out on
// purpose, which left them in no
// listing at all.
view! {
<>
<Badge tone=Tone::Bad label="a reclaim failed"/>
<div class="meta">{o.failed_reclaim.clone()}</div>
</>
}
.into_any()
}}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
</Panel>
}
.into_any()
})
}} }
}