crates/gunnar-ui-web/src/pages/admin_storage.rs
//! **Administration → Storage**: everything technical about the box's storage,
//! for an administrator, in the owner's order (2026-10-02, "personal →
//! Account, machine → Administration"):
//!
//! 1. **Data set** — total, used, free, floor, servable, layout, members, and
//! whether and how it grows;
//! 2. **Engine** — its name linked to its source with its description as the
//! tooltip, the archive, the cold tier when there is one, the read cache;
//! 3. **Disks** — the system disk, every mount, every block device;
//! 4. **Twin** — its heartbeat, its volume and its mounts, and the restore;
//! 5. **/serialdat** — what the box's own records take;
//! 6. **Layout** — where `/data` and `/serialdat` are, the off-stripe copies,
//! where gunnar puts what, and (folded) what the installer decided;
//! 7. **Performance** — the disk speed measured at start-up;
//! 8. **Allocation** — the box's disk, cores and RAM.
//!
//! 6 and 8 came from the Machine page when the owner retired it (2026-10-04):
//! every disk detail is said ONCE, here, and the Overview's Machine card
//! carries only the estate's picture and gunnar's few verdict lines.
//!
//! Then the per-repository technical table and the deleted-not-yet-purged
//! bytes with their undo.
//!
//! **A row is drawn only when gunnar reports a value.** The "N/A — not
//! reported" rows (write amplification, priority transfer, the read cache's
//! high/low water and its hits and misses, a cold tier an archive engine does not
//! have) are gone, with the paragraphs that explained them.
use leptos::prelude::*;
use crate::disks::{self, Disks};
use crate::fmt;
use crate::pages::storage::{Deleted, Storage};
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, Empty, FailBox, Hint, Metric, PageHeader, Panel, Tone};
#[component]
pub fn AdminStorage() -> impl IntoView {
let session = crate::app::session();
view! {
<PageHeader title="Storage"/>
{move || match session.get() {
None => view! { <crate::ui::Loading/> }.into_any(),
Some(s) if !s.is_admin() => view! {
<Empty
title="This page is the administrator's"
detail="Your own storage is under Account."
/>
}
.into_any(),
Some(_) => view! {
<crate::pages::admin::Banner/>
<Machine/>
}
.into_any(),
}}
}
}
/// The panels, off `/api/disks`, `/api/storage` and `/api/machine`.
#[component]
fn Machine() -> impl IntoView {
let disks = LocalResource::new(|| async { crate::api::get::<Disks>("/api/disks").await });
let storage = LocalResource::new(|| async { crate::api::get::<Storage>("/api/storage").await });
view! {
<div class="stack">
{move || resolved(disks.get(), None, |d: Disks| view! { <DataSet d=d.clone()/> })}
{move || resolved(storage.get(), None, |s: Storage| view! { <Engine s=s/> })}
{move || resolved(disks.get(), None, |d: Disks| view! {
<DisksPanel d=d.clone()/>
<TwinPanel d=d.clone()/>
<Serialdat d=d.clone()/>
<crate::pages::machine::Layout/>
<Performance d=d/>
<crate::pages::machine::BoxAllocation/>
})}
{move || resolved(storage.get(), None, |s: Storage| view! {
<PerRepository s=s.clone()/>
<Deleted held=s.total_tombstoned_bytes/>
})}
</div>
}
}
/// ── 1. The data set ────────────────────────────────────────────────────────
#[component]
fn DataSet(d: Disks) -> impl IntoView {
let warnings = d.warnings.clone();
d.data_set.map(|set| {
let members = disks::members_line(&set);
let used = (set.total_bytes > 0).then(|| format!("{} % of the total", set.used_bytes.saturating_mul(100) / set.total_bytes));
let growth = set.growth.clone();
let growth_known = crate::dataset::growth_label(&growth) != "Unknown";
view! {
<Panel title="Data set">
<div class="panel-body">
{warnings.into_iter().map(|w| view! { <p class="bad" role="alert">{w}</p> }).collect_view()}
<dl class="metrics">
<Metric label="Total" value=fmt::bytes(set.total_bytes) unit=set.root.clone()/>
<Metric label="Used" value=fmt::bytes(set.used_bytes) unit=used.unwrap_or_default()/>
<Metric label="Free" value=fmt::bytes(set.free_bytes)/>
<Metric label="Floor" value=fmt::bytes(set.floor_bytes) unit="never handed out"/>
<Metric label="Servable" value=fmt::bytes(set.servable_bytes)/>
{(!set.layout.is_empty()).then(|| view! { <Metric label="Layout" value=set.layout.clone()/> })}
{growth_known.then(|| view! { <Metric label="Growth" value=crate::dataset::growth_label(&growth)/> })}
</dl>
{members.map(|line| view! { <p class="meta">{format!("Members ({} {}): {line}", set.device, set.raid_level)}</p> })}
{growth_known.then(|| view! { <p class="hint">{crate::dataset::growth_detail(&set.growth)}</p> })}
</div>
</Panel>
}
})
}
/// ── 2. The engine ──────────────────────────────────────────────────────────
#[component]
fn Engine(s: Storage) -> impl IntoView {
let budget = if s.total_explode_budget_bytes > 0 { format!("of {}", fmt::bytes(s.total_explode_budget_bytes)) } else { "no budget".to_owned() };
view! {
<Panel title="Engine">
<dl class="metrics">
{s.engine.clone().map(|engine| view! {
<div class="metric">
<dt>"Engine"</dt>
<dd>
{if engine.repo_url.is_empty() {
view! { <span title=engine.description.clone()>{engine.name.clone()}</span> }.into_any()
} else {
view! {
<a href=engine.repo_url title=engine.description rel="external noopener">{engine.name}</a>
}
.into_any()
}}
</dd>
</div>
})}
{if s.cold_measured {
view! {
<Metric label="Hot tier" value=fmt::bytes(s.total_hot_bytes)/>
<Metric label="Cold tier" value=fmt::bytes(s.total_cold_bytes)/>
}
.into_any()
} else {
view! { <Metric label="Archive" value=fmt::bytes(s.total_hot_bytes) unit="one per repository"/> }.into_any()
}}
<Metric
label="Read cache"
value=fmt::bytes(s.total_explode_bytes)
unit=budget
/>
</dl>
</Panel>
}
}
/// ── 3. Every disk ──────────────────────────────────────────────────────────
#[component]
fn DisksPanel(d: Disks) -> impl IntoView {
view! {
<Panel title="Disks">
<div class="panel-body">
{d.system.clone().map(|m| view! {
<h3>"System disk"</h3>
<p>{format!("{} on {} ({}) \u{2014} {}", m.mount_point, m.source, m.fs_type, disks::mount_line(&m))}</p>
<p class="meta">{disks::mount_room(&m)}</p>
})}
{d.read_cache.clone().map(|c| view! {
<h3>"Read cache (exploded objects)"</h3>
<p>{disks::cache_line(&c)}</p>
{c.hysteresis.clone().map(|h| view! { <p class="meta">{format!("High/low water (80 % / 75 %): {h}")}</p> })}
})}
{(!d.mounts.is_empty()).then(|| view! {
<h3>"Every mount"</h3>
<div class="table-wrap">
<table class="table">
<thead><tr><th>"Mount"</th><th>"Device"</th><th>"Gunnar puts"</th><th>"Use"</th><th>"Room"</th></tr></thead>
<tbody>
{d.mounts.iter().map(|m| view! {
<tr>
<td>{format!("{} ({})", m.mount_point, m.fs_type)}</td>
<td>{m.source.clone()}</td>
<td>{disks::labels_line(m)}</td>
<td>{disks::mount_line(m)}</td>
<td>{disks::mount_room(m)}</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
})}
{(!d.devices.is_empty()).then(|| view! {
<h3>"Block devices"</h3>
<div class="table-wrap">
<table class="table">
<thead><tr><th>"Device"</th><th>"Size"</th><th>"Partitions"</th><th>"Held by / members"</th></tr></thead>
<tbody>
{d.devices.iter().map(|b| view! {
<tr>
<td>{if b.raid_level.is_empty() { b.name.clone() } else { format!("{} ({})", b.name, b.raid_level) }}</td>
<td>{fmt::bytes(b.size_bytes)}</td>
<td>{b.partitions.iter().map(|(n, s)| format!("{n} {}", fmt::bytes(*s))).collect::<Vec<_>>().join(", ")}</td>
<td>{if b.members.is_empty() { b.holders.join(", ") } else { b.members.join(", ") }}</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
})}
{d.problems.iter().map(|p| view! { <p class="hint">{p.clone()}</p> }).collect_view()}
{d.unanswered.iter().map(|u| view! { <p class="hint">{format!("Not answered: {u}")}</p> }).collect_view()}
</div>
</Panel>
}
}
/// **The twin's heartbeat**: one row per twin gunnar reports — state, last
/// seen, lag, fill and address — red when a twin is behind or silent.
/// `GET /api/twin`, an operator's read, so a viewer's session draws no panel.
/// Every sentence comes from [`crate::twin`], where it is tested.
#[component]
fn TwinPanel(d: Disks) -> impl IntoView {
use crate::twin::{self, TwinPanel as Answer};
let may_read = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.may_write()));
let answer = LocalResource::new(|| async { crate::api::get::<Answer>("/api/twin").await });
view! { {move || {
let d = d.clone();
if !may_read.get() {
return ().into_any();
}
resolved(answer.get(), None, |panel: Answer| {
let alarm = twin::panel_alarm(&panel);
let unanswered = panel.unanswered.clone();
let primary = panel.primary.clone();
let rows = panel.twins.clone();
// What "no rows" means here — a box with no twin, or a configured
// twin this primary has never heard from. See `twin::no_twin_detail`.
let detail = twin::no_twin_detail(&panel);
view! {
<div class=if alarm { "twin twin-alarm" } else { "twin" }>
<Panel title="Twin">
{(!unanswered.is_empty()).then(|| view! {
<div class="panel-body"><p class="hint">{unanswered.clone()}</p></div>
})}
{primary.map(|p| {
let tone = if twin::alarmed(&p.state) { Tone::Bad } else { Tone::Pass };
view! {
<div class="panel-body">
<p>
<Badge tone=tone label=twin::state_label(&p.state)/>
{format!(
" This box is a twin of {}: applied seq {} of the primary's {}, last answer {}. {}",
p.address,
p.applied_seq,
p.primary_head_seq,
if p.last_answer_unix_ms.is_some() { twin::ago(p.answer_ago_ms) } else { "never".to_owned() },
p.why,
)}
</p>
</div>
}
})}
{if rows.is_empty() {
if unanswered.is_empty() {
view! { <Empty title="No twin reported" detail=detail.clone()/> }.into_any()
} else {
().into_any()
}
} else {
view! {
<div class="table-wrap">
<table>
<thead>
<tr>
<th>"Twin"</th>
<th>"State"</th>
<th>"Last seen"</th>
<th>"Lag"</th>
<th>"Fill"</th>
<th>"Address"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|row| {
let red = twin::alarmed(&row.state);
let tone = if red { Tone::Bad } else { Tone::Pass };
view! {
<tr class=if red { "alarm" } else { "" } title=row.why.clone()>
<td>{row.principal.clone()}</td>
<td><Badge tone=tone label=twin::state_label(&row.state)/></td>
<td>{twin::seen_line(&row)}</td>
<td>{twin::lag_line(&row)}</td>
<td>{twin::fill_line(&row)}</td>
<td><code>{row.address.clone()}</code></td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}}
// ★ The restore lives INSIDE this panel, not beside it.
// Reading a twin and acting on it are one subject, and
// two panels made the action look like a separate
// feature that might or might not be installed — which
// is the confusion `restore_absence` already exists to
// end. It draws its own heading and nothing at all for
// a non-admin, so an operator's pane is unchanged.
<TwinVolumes d=d.clone()/>
<RestoreAction/>
<RebuildAction/>
</Panel>
</div>
}
})
}} }
}
/// ★ **The Twin panel's admin half** (5.4 resilience) — ALWAYS drawn for an
/// administrator, nothing for anyone else: **[Review twin]** (gunnar-repair's
/// review: health, head vs primary, last heartbeat, what a recharge would
/// lose) and **[Recharge from twin]**, armed only when `GET /api/restore`
/// names a phrase. A main that answers as LIVE gets only the fence-first
/// override, with its own phrase. The server asks for a fresh login
/// (step-up); its refusal is shown as it said it. See [`crate::twin::show_restore`].
#[component]
fn RestoreAction() -> impl IntoView {
use crate::twin::{self, Eligibility, Restored};
let is_admin = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.is_admin()));
let offer = LocalResource::new(|| async { crate::api::get::<Eligibility>("/api/restore").await });
let armed = RwSignal::new(false);
let typed = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let done = RwSignal::new(None::<Restored>);
view! { {move || {
if !is_admin.get() {
return ().into_any();
}
if let Some(r) = done.get() {
return view! {
<div class="panel-body">
<h3 class="disks-head">"Recharge from twin"</h3>
<p>{twin::restored_line(&r)}</p>
</div>
}
.into_any();
}
let answer = offer.get().and_then(|r| r.ok());
let review = answer.as_ref().and_then(|e| e.review.clone());
// The answer's own reason is drawn once, below; never again in the review.
let said = answer.as_ref().map(|e| e.why.clone()).unwrap_or_default();
let plain = answer.as_ref().and_then(twin::plain_why);
let review_view = view! {
<p>
<button id="twin-review" class="btn quiet" disabled=move || busy.get() on:click=move |_| offer.refetch()>
"Review twin"
</button>
</p>
{review.map(|r| twin::review_lines(&r, &said).into_iter().map(|l| view! { <p class="hint">{l}</p> }).collect_view())}
};
let overriding = twin::show_override(true, answer.as_ref());
if !twin::show_restore(true, answer.as_ref()) && !overriding {
// Not the action — the REASON there is none. See `twin::restore_absence`.
let why = twin::restore_absence(true, answer.as_ref());
return view! {
<div class="panel-body">
<h3 class="disks-head">"Recharge from twin"</h3>
{review_view}
{match plain {
Some((line, detail)) => view! { <Hint line=line more=detail/> }.into_any(),
None => why.map(|why| view! { <p class="hint">"Not offered here: "{why}</p> }).into_any(),
}}
</div>
}
.into_any();
}
let e = answer.unwrap_or_default();
let offer_text = if overriding { e.why.clone() } else { twin::offer_line(&e) };
let phrase = if overriding { e.override_confirm.clone() } else { e.confirm.clone() };
let check = e.clone();
let body_of = e.clone();
let send = move |_| {
busy.set(true);
failed.set(None);
let body = twin::restore_body(&typed.get_untracked(), &body_of, overriding);
leptos::task::spawn_local(async move {
match crate::api::post::<_, Restored>("/api/restore", &body).await {
Ok(r) => {
armed.set(false);
done.set(Some(r));
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<div class="restore">
<div class="panel-body">
<h3 class="disks-head">"Recharge from twin"</h3>
{review_view}
{match plain.clone() {
Some((line, detail)) => view! { <Hint line=line more=detail/> }.into_any(),
None => view! { <p class="hint">{offer_text}</p> }.into_any(),
}}
{move || if armed.get() {
let check = check.clone();
let send = send.clone();
view! {
<div class="consequence" role="alert">
{if overriding {
view! {
<Hint
line="The main is LIVE: this fences it first and streams nothing into it."
more="Fenced = read-only, no copies shipped. Nothing is streamed into a live main: \
re-lay it, then recharge the fresh box."
/>
}.into_any()
} else {
view! { <p>{twin::warning()}</p> }.into_any()
}}
{twin::fence_hint(&check).map(|(line, more)| view! { <Hint line=line more=more /> })}
<p>"Type " <code>{phrase.clone()}</code> " to confirm."</p>
<p>
<input
id="restore-confirm"
type="text"
autocomplete="off"
prop:value=move || typed.get()
on:input=move |ev| typed.set(event_target_value(&ev))
/>
</p>
<p>
<button
id="restore-send"
class="btn danger"
disabled=move || busy.get() || !(if overriding { twin::may_override(&typed.get(), &check) } else { twin::may_send(&typed.get(), &check) })
on:click=send
>
{if overriding { "Fence main now" } else { "Recharge now" }}
</button>
" "
<button
id="restore-cancel"
class="btn quiet"
disabled=move || busy.get()
on:click=move |_| {
armed.set(false);
typed.set(String::new());
}
>
"Cancel"
</button>
</p>
</div>
}
.into_any()
} else {
view! {
<p>
<button id="restore-arm" class="btn danger" on:click=move |_| armed.set(true)>
{if overriding { "Fence main and recharge…" } else { "Recharge from twin…" }}
</button>
</p>
}
.into_any()
}}
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
</div>
}
.into_any()
}} }
}
/// ★ **[Rebuild main]** (main rebuild, owner 2026-10-12) — drawn for an administrator, and
/// only where the console reaches monetize (`GET /api/rebuild` answers): the ONE box's
/// guardian's words about THIS estate's main, its case, and the press with its typed phrase.
/// The twin is never promoted; the guardian rebuilds the main from it. See
/// [`crate::twin::rebuild_lines`].
#[component]
fn RebuildAction() -> impl IntoView {
use crate::twin::{self, Rebuild};
let is_admin = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.is_admin()));
let look = LocalResource::new(|| async { crate::api::get::<Rebuild>("/api/rebuild").await });
let armed = RwSignal::new(false);
let typed = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
view! { {move || {
if !is_admin.get() {
return ().into_any();
}
let Some(r) = look.get().and_then(|r| r.ok()) else { return ().into_any() };
let lines = twin::rebuild_lines(&r);
let pressable = !r.confirm.is_empty();
let phrase = r.confirm.clone();
let check = r.clone();
let send = move |_| {
busy.set(true);
failed.set(None);
let body = serde_json::json!({ "confirm": typed.get_untracked() });
leptos::task::spawn_local(async move {
match crate::api::post::<_, Rebuild>("/api/rebuild", &body).await {
Ok(_) => {
armed.set(false);
typed.set(String::new());
look.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<div class="panel-body">
<h3 class="disks-head">"Rebuild main"</h3>
{lines.into_iter().map(|l| view! { <p class="hint">{l}</p> }).collect_view()}
{move || if !pressable {
().into_any()
} else if armed.get() {
let check = check.clone();
view! {
<div class="consequence" role="alert">
<Hint
line="Its provider rebuilds the main from the twin."
more="The twin is never promoted. A disk that still holds data is never reformatted without the operator's confirmation."
/>
<p>"Type " <code>{phrase.clone()}</code> " to confirm."</p>
<p>
<input id="rebuild-confirm" type="text" autocomplete="off"
prop:value=move || typed.get()
on:input=move |ev| typed.set(event_target_value(&ev)) />
</p>
<p>
<button id="rebuild-send" class="btn danger"
disabled=move || busy.get() || !twin::may_rebuild(&typed.get(), &check)
on:click=send>"Rebuild now"</button>
" "
<button id="rebuild-cancel" class="btn quiet" disabled=move || busy.get()
on:click=move |_| { armed.set(false); typed.set(String::new()); }>"Cancel"</button>
</p>
</div>
}.into_any()
} else {
let label = twin::rebuild_button(&check);
view! { <p><button id="rebuild-arm" class="btn danger" on:click=move |_| armed.set(true)>{label}</button></p> }.into_any()
}}
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
}
.into_any()
}} }
}
/// ── 4. The twin's volume and mounts ────────────────────────────────────────
/// Only what gunnar reported: a twin whose volume was not reported has no row.
#[component]
fn TwinVolumes(d: Disks) -> impl IntoView {
let volumes: Vec<_> = d.twins.iter().filter(|t| t.used_bytes.is_some() && t.total_bytes.is_some()).cloned().collect();
view! {
<div class="panel-body">
{volumes.iter().map(|t| view! {
<p>{format!("{} at {} ({}): {}", t.principal, t.address, t.state, disks::twin_line(t))}</p>
}).collect_view()}
{d.twin_box.clone().map(|tb| view! {
<h3>{format!("Twin at {}", tb.address)}</h3>
{(!tb.unanswered.is_empty()).then(|| view! { <p class="hint">{tb.unanswered.clone()}</p> })}
{tb.mounts.iter().map(|m| view! {
<p>{format!("{} ({}) \u{2014} {}", m.mount_point, m.source, disks::mount_line(m))}</p>
}).collect_view()}
})}
</div>
}
}
/// ── 5. /serialdat ──────────────────────────────────────────────────────────
#[component]
fn Serialdat(d: Disks) -> impl IntoView {
d.serialdat.map(|sd| {
let rows: Vec<_> = sd.breakdown.iter().filter(|p| p.bytes.is_some()).cloned().collect();
view! {
<Panel title="/serialdat">
<div class="panel-body">
<p>{format!("{} on {} \u{2014} {}", sd.mount.mount_point, sd.mount.source, disks::mount_line(&sd.mount))}</p>
<ul>{rows.iter().map(|p| view! { <li>{disks::path_line(p)}</li> }).collect_view()}</ul>
</div>
</Panel>
}
})
}
/// ── 6. Performance ─────────────────────────────────────────────────────────
#[component]
fn Performance(d: Disks) -> impl IntoView {
d.disk_speed.filter(|sp| sp.test == "measured").map(|sp| view! {
<Panel title="Performance">
<div class="panel-body">
<h3>"Disk speed (measured at start-up)"</h3>
<p>{disks::speed_line(&sp)}</p>
{disks::speed_basis(&sp).map(|b| view! { <p class="meta">{b}</p> })}
</div>
</Panel>
})
}
/// The technical per-repository table: archive, cold (only when an engine
/// has a cold tier), loose objects, read cache, references, last repack.
#[component]
fn PerRepository(s: Storage) -> impl IntoView {
if s.repos.is_empty() {
return ().into_any();
}
let now = now_ms();
let cold = s.repos.iter().any(|r| r.cold_measured);
let mut rows = s.repos.clone();
rows.sort_by_key(|r| std::cmp::Reverse(r.hot_bytes + r.cold_bytes));
view! {
<Panel title="Every repository">
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Archive"</th>
{cold.then(|| view! { <th scope="col">"Cold"</th><th scope="col">"Cold objects"</th> })}
<th scope="col">"Loose objects"</th>
<th scope="col">"Read cache"</th>
<th scope="col">"References"</th>
<th scope="col">"Last repacked"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|row| view! {
<tr>
<td class="mono">{row.repo.clone()}</td>
<td class="num">{fmt::bytes(row.hot_bytes)}</td>
{cold.then(|| view! {
<td class="num">{if row.cold_measured { fmt::bytes(row.cold_bytes) } else { "\u{2014}".to_owned() }}</td>
<td class="num">{if row.cold_measured { fmt::count(row.cold_object_count) } else { "\u{2014}".to_owned() }}</td>
})}
<td class="num">{fmt::count(row.loose_object_count)}</td>
<td class="num">{if row.explode_bytes > 0 { fmt::bytes(row.explode_bytes) } else { "\u{2014}".to_owned() }}</td>
<td class="num">{fmt::count(row.ref_count)}</td>
<td class="num">{row.last_repack_at_unix_ms.map(|at| fmt::since(at, now)).unwrap_or_else(|| "never".to_owned())}</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
</Panel>
}
.into_any()
}