Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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