crates/gunnar-ui-web/src/pages/overview.rs
//! What the server is doing right now — and, first, the Machine card: the
//! estate's boxes as gunnar knows them, drawn by the suite's one estate
//! renderer, and gunnar's few verdict lines on the disks (owner, 2026-10-04;
//! the Machine page it replaces said again what this page and Storage say).
use leptos::prelude::*;
use serde::Deserialize;
use crate::feed::{use_feed, Connection};
use crate::fmt;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, Hint, Metric, PageHeader, Panel, Tone};
/// gunnar's own report on itself.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Health {
pub serving: bool,
pub agent: String,
pub version: String,
/// gunnar's `git describe` stamp. Empty on an older gunnar that predates
/// the field, which is why it is `default` and not required.
#[serde(default)]
pub build_ref: String,
pub uptime_seconds: u64,
pub grpc_authentication_enabled: bool,
pub grpc_bind: String,
pub ssh_bind: String,
}
/// gunnar's counters.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Stats {
pub namespaces: u64,
pub repos: u64,
pub principals: u64,
pub keys: u64,
pub grants: u64,
pub event_subscribers: u64,
pub events_published: u64,
pub events_dropped: u64,
pub event_buffer_capacity: u64,
/// Background index work in flight — one row per repository whose pushed
/// pack is still being resolved. Empty when the box is idle.
///
/// `serde(default)`, and that is not decoration: an appliance older than
/// 2026-08-30 does not send this field at all, and a console that refused
/// to parse its stats over a missing additive field would go blank against
/// exactly the server an operator is trying to look at.
#[serde(default)]
pub index: Vec<IndexProgress>,
#[serde(default)]
pub replication_configured: bool,
#[serde(default)]
pub replication_synced: u64,
#[serde(default)]
pub replication_failed: u64,
#[serde(default)]
pub replication_enqueued: u64,
/// **Every ref on the server, and how many of them no standby carries.**
/// Replication copies `refs/heads/*` and `refs/tags/*` and nothing else.
#[serde(default)]
pub replication_refs_total: u64,
#[serde(default)]
pub replication_refs_unmirrored: u64,
#[serde(default)]
pub standbys: Vec<StandbyStatus>,
}
/// One replication standby, as gunnar reports it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct StandbyStatus {
pub name: String,
pub remote: String,
pub pending: u64,
pub consecutive_failures: u32,
#[serde(default)]
pub last_error: String,
/// **`None` means NEVER.**
pub last_success_unix_ms: Option<i64>,
pub needs_full_resync: bool,
pub stale: bool,
/// **The one word gunnar has for this standby**: `NEVER`, `STALE`,
/// `BEHIND`, `syncing` or `idle`.
///
/// The console prints it rather than deriving a word of its own from the
/// booleans, so this badge and `gunnar control replication list` cannot
/// disagree about the same standby. Empty from a gunnar older than
/// 2026-08-31 — see [`StandbyStatus::word`].
#[serde(default)]
pub verdict: String,
}
impl StandbyStatus {
/// gunnar's word, or the best this console can say without one.
///
/// The fallback exists for one case and no other: an appliance that has not
/// been updated yet. It deliberately cannot invent `idle`, because the old
/// server's `stale` did not mean what the new one's does — an old box with
/// nothing to do sent `stale: true`, and printing "idle" over it would be
/// this console overruling the server about a thing only the server knows.
pub fn word(&self) -> String {
if !self.verdict.is_empty() {
return self.verdict.clone();
}
if self.last_success_unix_ms.is_none() {
"NEVER".to_owned()
} else if self.stale {
"STALE".to_owned()
} else {
"syncing".to_owned()
}
}
/// Does the word deserve somebody's attention tonight?
pub fn alarming(&self) -> bool {
matches!(self.word().as_str(), "NEVER" | "STALE" | "BEHIND")
}
}
/// One repository's post-ack index work, as the appliance's own band shows it:
/// `index alice/linux 34% 4.7M/13.9M resolve`.
///
/// Information, never a gate — nothing waits on it. It is here because the
/// memory climbing on a box after a big push is this, and an operator with no
/// way to see it is left guessing at a leak.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct IndexProgress {
pub repo: String,
pub objects_done: u64,
pub objects_total: u64,
pub bytes_done: u64,
pub bytes_total: u64,
pub phase: String,
pub percent: u32,
pub resumed: bool,
pub reinflations: u64,
pub pauses: u64,
}
/// The forge's SSH host key, as `GET /api/server-key` reports it (gunnar's own
/// key, read live: `api::server_key` on the server).
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ServerKey {
pub fingerprint: String,
}
#[component]
pub fn Overview() -> impl IntoView {
let health = LocalResource::new(|| async { crate::api::get::<Health>("/api/health").await });
// `tick` is what makes the index rows live. The rest of this page is
// static enough to fetch once; index progress is the one thing on it that
// moves, and a number that is minutes stale is worse than no number
// because it reads as a stall.
let (tick, set_tick) = signal(0u32);
let stats = LocalResource::new(move || {
tick.track();
async { crate::api::get::<Stats>("/api/stats").await }
});
// **Line 2 of the header: the forge's SSH host key fingerprint** (owner,
// 2026-10-02), read live from gunnar — never written into this file — so a
// user can check what their ssh client shows on first connect.
let server_key =
LocalResource::new(|| async { crate::api::get::<ServerKey>("/api/server-key").await });
let feed = use_feed();
// Poll ONLY while there is work, and stop when there is none: each fetch
// that finds index rows schedules exactly one more. An unconditional timer
// would ask an idle server for its counters forever, for a panel that is
// not even drawn.
Effect::new(move |_| {
let busy = stats
.get()
.and_then(|r| r.ok())
.is_some_and(|s| !s.index.is_empty());
if busy {
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(2_000).await;
set_tick.update(|t| *t = t.wrapping_add(1));
});
}
});
// A push is what starts index work, and the feed is how this page learns a
// push happened. Without this the panel would only appear on the next
// navigation — the work would be under way and the console silent about it.
//
// It watches the newest SEQUENCE NUMBER, not the item count. The feed keeps
// a bounded window and truncates, so once it is full the length stops
// growing and a count-based test would go quiet forever, on a busy server,
// which is precisely when this matters.
Effect::new(move |previous: Option<Option<u64>>| {
let newest = feed.items.with(|items| items.first().map(|i| i.seq));
if let (Some(Some(was)), Some(now)) = (previous, newest) {
if now > was {
set_tick.update(|t| *t = t.wrapping_add(1));
}
}
newest
});
view! {
<PageHeader
title="Overview"
line=view! {
{move || server_key
.get()
.and_then(|k| k.ok())
.and_then(|k| crate::headers::server_key_line(&k.fingerprint))
.map(|said| view! { <span class="mono">{said}</span> })}
}.into_any()
/>
// Administration → Overview (owner, 2026-10-02): the whole server's
// events and counts are an administrator's.
<crate::pages::admin::Banner/>
<div class="stack">
<MachineCard/>
{move || {
let health = health.get();
resolved(health, None, |health| view! { <ServerPanel health/> })
}}
{move || {
let stats = stats.get();
resolved(
stats,
None,
|stats| {
view! {
<IndexPanel stats=stats.clone()/>
<CountsPanel stats/>
}
},
)
}}
// The console's own key is an administrator's fact and lives under
// Administration (`pages::admin::ConsoleIdentity`), not here
// (owner, 2026-10-02).
<Panel title="Latest events">
<LiveFeed feed/>
</Panel>
</div>
}
}
/// ★ **The Machine card**: the estate's boxes as gunnar knows them — a solo
/// gunnar is one box, a front and a server two, with a twin three — and
/// gunnar's few lines on the disks (`/data`'s shape, the off-stripe copies,
/// the free space, the disk speed), each with gunnar's verdict.
///
/// **Live** (owner, 2026-10-04: "the picture needs to be a bit dynamic, if twin
/// dies etc." / "make it svg dynamic"): the card asks again every
/// [`crate::machine::MACHINE_CARD_POLL_MS`] while the page is visible, and
/// swaps the picture IN PLACE — inline SVG, never an `<img>` that blanks
/// between fetches — only when gunnar's answer drew something different, and
/// only a drawing that can do nothing but draw ([`crate::machine::swap`],
/// [`crate::machine::drawable`]): it goes in by `inner_html`. The pulses, the red edges and the words on the
/// chips are the shared renderer's drawing of gunnar's state words.
///
/// The console decides NOTHING here (owner: a thin layer): it fetches and
/// swaps. The picture is `Admin.Topology` drawn on the BFF
/// (`/api/machine/topology`'s `svg`); the lines are `StorageReality.glance`
/// as they came (`/api/machine`), green, amber or red by gunnar's own word
/// ([`crate::machine::glance_grade`]). Every disk detail beyond them is on
/// Administration → Storage, once.
#[component]
fn MachineCard() -> impl IntoView {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let svg = RwSignal::new(String::new());
let words = RwSignal::new(String::new());
let boxes = RwSignal::new(0usize);
let machine = RwSignal::new(None::<Result<crate::machine::Machine, crate::api::Fail>>);
let alive = Arc::new(AtomicBool::new(true));
{
let alive = alive.clone();
on_cleanup(move || alive.store(false, Ordering::Relaxed));
}
// Fetch, and set a signal only when its value changed: an unchanged
// estate touches nothing on screen. `try_*`: an answer that lands after
// the card is gone (the reader navigated away) touches nothing either.
let refresh = move || {
leptos::task::spawn_local(async move {
if let Ok(t) = crate::api::get::<crate::machine::Topology>("/api/machine/topology").await {
let Some(on_screen) = svg.try_get_untracked() else { return };
if crate::machine::swap(&on_screen, &t.svg) {
let _ = svg.try_set(t.svg.clone());
}
let said = crate::machine::estate_words(&t);
if words.try_get_untracked().is_some_and(|w| w != said) {
let _ = words.try_set(said);
}
if boxes.try_get_untracked().is_some_and(|n| n != t.boxes.len()) {
let _ = boxes.try_set(t.boxes.len());
}
}
let m = crate::api::get::<crate::machine::Machine>("/api/machine").await;
let Some(was) = machine.try_get_untracked() else { return };
let changed = match (was.as_ref(), &m) {
(Some(Ok(was)), Ok(now)) => was != now,
_ => true,
};
if changed {
let _ = machine.try_set(Some(m));
}
});
};
refresh();
// Ask again while the page is visible; a hidden tab asks nothing and
// catches up the moment it is shown. Ends with the card.
leptos::task::spawn_local(async move {
let hidden = || web_sys::window().and_then(|w| w.document()).map(|d| d.hidden()).unwrap_or(false);
let mut waited = 0u32;
while alive.load(Ordering::Relaxed) {
gloo_timers::future::TimeoutFuture::new(1_000).await;
waited = waited.saturating_add(1_000);
if !hidden() && waited >= crate::machine::MACHINE_CARD_POLL_MS && alive.load(Ordering::Relaxed) {
waited = 0;
refresh();
}
}
});
view! {
<div class="machine-card">
<Panel title="Machine">
<div class="panel-body">
<div
class="estate-picture"
role="img"
aria-label=move || words.get()
data-boxes=move || boxes.get().to_string()
inner_html=move || svg.get()
></div>
{move || resolved(machine.get(), None, |m: crate::machine::Machine| {
if m.glance.is_empty() {
let why = if m.unanswered.is_empty() {
"this Gunnar sends no glance lines (it is older than them)".to_owned()
} else {
m.unanswered.clone()
};
return view! { <Hint line=format!("The disks at a glance: {why}")/> }.into_any();
}
view! {
<ul class="glance">
{m.glance.into_iter().map(|g| {
let tone = match crate::machine::glance_grade(&g.state) {
crate::machine::Grade::Good => Tone::Pass,
crate::machine::Grade::Caution => Tone::Warn,
crate::machine::Grade::Alarm => Tone::Bad,
crate::machine::Grade::Neutral => Tone::Plain,
};
view! {
<li data-key=g.key.clone() data-state=g.state.clone()>
<Badge tone=tone label=crate::machine::glance_label(&g.state)/>
<Hint line=g.line.clone() more=g.detail.clone()/>
</li>
}
}).collect_view()}
</ul>
}
.into_any()
})}
<p class="meta"><a href="/admin/storage">"Every disk detail: Administration → Storage"</a></p>
</div>
</Panel>
</div>
}
}
#[component]
fn ServerPanel(health: Health) -> impl IntoView {
let unauthenticated = !health.grpc_authentication_enabled;
view! {
<Panel title="The server">
{unauthenticated.then(|| view! {
<div class="panel-body">
<div class="failure" role="alert">
<h3>"The control plane is unauthenticated"</h3>
<Hint
line="Every call to the control plane is accepted without a credential."
more="Gunnar is running with `insecure_allow_all_for_local_development`. \
That is only defensible on a machine nobody else can reach."
/>
</div>
</div>
})}
<dl class="metrics">
// ★ **The BUILD, not the crate version, when gunnar sends
// one.** `version` is `1.5.0` out of a manifest and does not
// move between builds of a release: this console showed it,
// an operator read it as "the appliance is on the release",
// and the box was 228 commits past the tag. An older gunnar
// sends no `build_ref`, and then the crate version is still
// the honest best answer — so this falls back rather than
// showing an empty row.
<Metric
label="Version"
value=if health.build_ref.is_empty() {
health.version.clone()
} else {
format!("{} ({})", health.build_ref.clone(), health.version.clone())
}
/>
<Metric label="Uptime" value=fmt::uptime(health.uptime_seconds)/>
<Metric label="Control plane" value=health.grpc_bind.clone()/>
<Metric label="SSH" value=health.ssh_bind.clone()/>
</dl>
</Panel>
}
}
#[component]
fn CountsPanel(stats: Stats) -> impl IntoView {
// gunnar's ring is bounded on purpose: it drops rather than growing. A
// non-zero drop count means somebody was not listening fast enough, which
// is a fact about this console as much as about gunnar.
let dropped_tone = if stats.events_dropped > 0 {
Tone::Warn
} else {
Tone::Pass
};
view! {
<Panel title="Contents and identities">
<dl class="metrics">
<Metric label="Repositories" value=fmt::count(stats.repos)/>
<Metric label="Namespaces" value=fmt::count(stats.namespaces)/>
<Metric label="Principals" value=fmt::count(stats.principals)/>
<Metric label="Keys" value=fmt::count(stats.keys)/>
<Metric label="Grants" value=fmt::count(stats.grants)/>
<Metric
label="Events"
value=fmt::count(stats.events_published)
unit="published"
/>
</dl>
<p class="truncation">
<Badge
tone=dropped_tone
label=format!("{} dropped events", fmt::count(stats.events_dropped))
/>
{format!(
" · the ring holds {} · {} listeners",
fmt::count(stats.event_buffer_capacity),
fmt::count(stats.event_subscribers),
)}
</p>
</Panel>
}
}
#[component]
fn LiveFeed(feed: crate::feed::Feed) -> impl IntoView {
view! {
<div class="panel-body" style="padding-bottom:.4rem">
{move || match feed.connection.get() {
Connection::Live => view! { <Badge tone=Tone::Live label="live"/> }.into_any(),
Connection::Connecting => {
view! { <Badge tone=Tone::Plain label="connecting …"/> }.into_any()
}
Connection::Ended => {
view! { <Badge tone=Tone::Bad label="stream ended"/> }.into_any()
}
}}
</div>
<ul class="feed">
{move || {
let items = feed.items.get();
if items.is_empty() {
// **This line used to read "Nothing has happened since the
// page was opened", and it was an accusation against a
// forge that had done nothing wrong.** The panel beside it
// said "Events 147 published · the ring holds 1 024" at the
// same moment: gunnar was holding every one of those events
// and the console had no way to ask for them, so it
// reported its own blindness as the forge being idle.
//
// The feed now opens with whatever the console still holds
// (see `crate::feed`), so an empty list is a real answer —
// but only when the stream is actually up. While it is
// connecting or after it has ended there is no answer at
// all, and saying "nothing happened" then would be the same
// mistake in a new place.
let empty = match feed.connection.get() {
Connection::Live => {
"Nothing on the event stream: neither in the history the console \
still holds nor since this page opened."
}
Connection::Connecting => {
"The event stream is not connected yet, so the console cannot say \
what has happened."
}
Connection::Ended => {
"The event stream ended — the console is not listening. Reload to reconnect."
}
};
return view! {
<li style="grid-template-columns:1fr;color:var(--ink-mute)">{empty}</li>
}
.into_any();
}
let now = now_ms();
items
.into_iter()
.take(12)
.map(|item| {
if item.breaks_continuity() {
return view! { <li class="gap">{item.summary.clone()}</li> }.into_any();
}
view! {
<li>
<time title=fmt::since(item.at_unix_ms, now)>
{fmt::clock(item.at_unix_ms)}
</time>
<span class="what">{item.kind.replace('_', " ")}</span>
<span class="where">
{item.repo.clone().unwrap_or_default()}
" "
{item.summary.clone()}
</span>
</li>
}
.into_any()
})
.collect_view()
.into_any()
}}
</ul>
}
}
/// The index rows, or nothing at all.
///
/// Nothing at all is the common case and the right one: a panel reading
/// "0 repositories indexing" on an idle box is noise an operator learns to
/// skip past, and then misses the day it says something.
#[component]
fn IndexPanel(stats: Stats) -> impl IntoView {
if stats.index.is_empty() {
return ().into_any();
}
let rows = stats.index.clone();
view! {
<Panel title="Indexing">
<div class="panel-body">
<Hint
line="Work that runs AFTER a push was acknowledged. Nothing waits on it."
more="Clones, fetches and further pushes are served throughout, and the memory a box \
uses while this runs is this."
/>
</div>
<ul class="indexing">
{rows
.into_iter()
.map(|r| {
let pct = r.percent.min(100);
let objects = format!(
"{} / {} objects",
fmt::count(r.objects_done),
fmt::count(r.objects_total),
);
let bytes = format!(
"{} / {}",
fmt::bytes(r.bytes_done),
fmt::bytes(r.bytes_total),
);
// Re-inflations and pauses are the memory ceiling being
// ENFORCED, not errors. They are shown because a push
// that is slow and pausing is a box that wants more RAM,
// and that is the one conclusion these numbers support.
let strain = if r.reinflations > 0 || r.pauses > 0 {
format!(
" · {} re-inflated · {} pause(s)",
fmt::count(r.reinflations),
fmt::count(r.pauses),
)
} else {
String::new()
};
let resumed = if r.resumed {
" · resumed after a restart"
} else {
""
};
view! {
<li>
<div class="row">
<strong>{r.repo.clone()}</strong>
<Badge tone=Tone::Live label=r.phase.clone()/>
<span class="pct">{format!("{pct}%")}</span>
</div>
<div
class="bar"
role="progressbar"
aria-label=format!("indexing {}", r.repo)
aria-valuenow=pct.to_string()
aria-valuemin="0"
aria-valuemax="100"
>
<span style=format!("width:{pct}%")></span>
</div>
<div class="meta">
{objects} " · " {bytes} {strain} {resumed}
</div>
</li>
}
})
.collect::<Vec<_>>()}
</ul>
</Panel>
}
.into_any()
}
/// Replication, and above all whether a standby has ever taken a byte.
///
/// Absent when nothing is configured — an operator who has not set up
/// replication does not need a panel telling them so every day.
///
/// When something IS configured, the loudest thing here is `never`. A standby
/// with no successful sync is not "behind": it is a durability promise that
/// does not exist, and the feature sent nothing at all for weeks before this
/// panel existed, with nothing saying so. That is what this panel is for.
#[component]
pub fn ReplicationPanel(stats: Stats) -> impl IntoView {
if !stats.replication_configured && stats.standbys.is_empty() {
return ().into_any();
}
// **Admins only**, and REACTIVELY. These rows carry a standby's ssh URL and
// its last error — where a second copy of every object lives, and why it
// last refused — so they belong beside identity and grants, not beside what
// a viewer reads.
//
// The first version read the session with `get_untracked()` and returned
// early. The session is a RESOURCE: at first render it has not arrived, so
// the panel was dropped for EVERYONE and never came back when it did. The
// suite caught it with the only sentence that matters — "the ADMIN cannot
// see it either, so this proves nothing" — which is exactly why that
// situation asserts both halves instead of the viewer's absence alone.
let session = crate::app::session();
let is_admin = Memo::new(move |_| session.get().is_some_and(|s| s.is_admin()));
let now = now_ms();
let rows = stats.standbys.clone();
let failed = stats.replication_failed;
let synced = stats.replication_synced;
let enqueued = stats.replication_enqueued;
let refs_total = stats.replication_refs_total;
let refs_unmirrored = stats.replication_refs_unmirrored;
view! { {move || {
if !is_admin.get() { return ().into_any(); }
let rows = rows.clone();
view! {
<Panel title="Replication">
<div class="panel-body">
<Hint
line="A green row: every branch and tag from the standby's NEXT PUSH on — not that your data is safe."
more="Replication carries refs/heads/* and refs/tags/* and nothing else, and a standby \
holds a repository only from its NEXT PUSH onward — configuring one copies \
nothing that is already here. Measured 2026-08-31: 21 of 25 repositories had \
no second copy at all while these rows read healthy, and 567 of the server's \
1292 refs are outside those two prefixes. Treat a green row as \"replication \
is running\", never as \"my data is safe\", until a backfill has run."
/>
<dl class="metrics">
<Metric label="Synced" value=fmt::count(synced)/>
<Metric label="Queued" value=fmt::count(enqueued)/>
<Metric label="Failed" value=fmt::count(failed)/>
<Metric label="Standbys" value=fmt::count(rows.len() as u64)/>
// ★ **The refs no standby carries.** It is always shown,
// including as a zero: a number that appears only when it
// is bad cannot be told from one that stopped being
// computed, and this one is the difference between "the
// standby has my repositories" and "the standby has my
// branches and tags".
<Metric
label="Refs not mirrored"
value=fmt::count(refs_unmirrored)
unit={
if refs_total > 0 {
format!("of {}", fmt::count(refs_total))
} else {
"Gunnar did not report a ref count".to_owned()
}
}
/>
</dl>
{(refs_unmirrored > 0)
.then(|| {
view! {
<Hint
line=format!("{} ref(s) are outside refs/heads and refs/tags: no standby has a copy of them.", fmt::count(refs_unmirrored))
more="Most of a set like that is refs/remotes/* left by mirror clones; the rest \
is wherever somebody parked work on purpose. `gunnar control \
replication list` names them per repository."
/>
}
})}
</div>
{if rows.is_empty() {
view! {
<div class="panel-body">
<Badge
tone=Tone::Bad
label="configured, but Gunnar reports no standby"
/>
</div>
}
.into_any()
} else {
view! {
<ul class="indexing">
{rows
.into_iter()
.map(|s| {
// ★ **gunnar's word, not the console's.** The
// badge, `replication list` and the appliance's
// own band all print what
// `replication_view::Verdict` decided, so an
// operator comparing two of them never has to
// work out which is lying. Until 2026-08-31 the
// console said "stale" whenever the server's
// `stale` bool was set, and the server set it on
// any box with nothing to do — a healthy idle
// appliance wore the alarm all weekend.
let word = s.word();
let never = s.last_success_unix_ms.is_none();
let tone = if s.alarming() || s.needs_full_resync {
Tone::Bad
} else if s.consecutive_failures > 0 {
Tone::Plain
} else {
Tone::Live
};
let label = if never {
"never synced".to_owned()
} else if s.needs_full_resync {
"needs a full resync".to_owned()
} else if s.alarming() {
word.to_lowercase()
} else {
format!(
"{word} · last {}",
fmt::since(s.last_success_unix_ms.unwrap_or_default(), now),
)
};
let detail = if s.last_error.is_empty() {
format!("{} · {} pending", s.remote, fmt::count(s.pending))
} else {
format!(
"{} · {} pending · {} consecutive failure(s) · {}",
s.remote,
fmt::count(s.pending),
s.consecutive_failures,
s.last_error,
)
};
view! {
<li>
<div class="row">
<strong>{s.name.clone()}</strong>
<Badge tone label/>
</div>
<div class="meta">{detail}</div>
</li>
}
})
.collect::<Vec<_>>()}
</ul>
}
.into_any()
}}
</Panel>
}.into_any()
}} }
.into_any()
}