crates/gunnar-ui-web/src/pages/admin.rs
//! **Administration** — the instruments that act on the whole box.
//!
//! # Why this page exists at all
//!
//! Until 2026-09-18 every operator instrument in this console lived inside a
//! `<Show when=is_admin>` on whatever page its data happened to be on: the
//! box's passkeys and invites on `/account`, the orphan objects on `/storage`,
//! the twin's heartbeat there too, replication on the Overview. Each one was
//! invisible to a viewer and, for an admin, *indistinguishable from the panel
//! above it*. Nothing on screen said "this one is not yours".
//!
//! The complaint that moved them is the customer half of the same coin, in the
//! owner's words: *"im an admin but must be treated as a ordinary user that has
//! right to buy too."* `/account` is the page that sells; it cannot also be the
//! page that signs everyone out.
//!
//! # What changed, and what deliberately did not
//!
//! **Nothing about who may see or do anything.** Every panel below keeps the
//! gate it already had — [`crate::pages::storage::Orphans`] and
//! [`crate::pages::overview::ReplicationPanel`] still check `is_admin()`
//! themselves, `pages::admin_storage`'s Twin panel still checks `may_write()`,
//! and [`crate::pages::account::PasskeyAdmin`] is wrapped in the same
//! `is_admin()` condition that used to sit on `/account`. The server floors
//! every route regardless; this page is a place, never a permission.
//!
//! That is also why the page itself is gated on `may_write()` rather than
//! `is_admin()`: the twin's heartbeat is an OPERATOR's read, and an admin-only
//! route would have taken it away from the role that has it today. The floor is
//! the union of what the moved panels ask for, and each panel still asks.
//!
//! # The banner is not decoration
//!
//! Every page under Administration carries [`Banner`], in the console's `bad`
//! tone. Red here does not mean "broken" — the other four uses of that tone in
//! this console do — and the strip says so in words rather than relying on the
//! colour to be read correctly. It exists because a screenshot of this page
//! must be tellable from a screenshot of `/account` with no chrome in frame.
use leptos::prelude::*;
use crate::pages::{overview::Stats, resolved};
use crate::ui::{Empty, PageHeader, Panel};
/// **The strip that says which half of the console this is.**
///
/// Shown on every page of the Administration menu: Overview, Storage,
/// Identities, Mirrors and Instruments (`/admin`).
/// `role="note"` rather than `role="alert"`: nothing is wrong, and a screen
/// reader that announced this as an alert on every navigation would be a worse
/// lie than no announcement at all.
#[component]
pub fn Banner() -> impl IntoView {
view! {
<div class="admin-banner" role="note">
<span class="admin-banner-tag">"Administration"</span>
<span class="admin-banner-line">"Settings for the whole site. Your own are under " <a href="/account">"Account"</a> "."</span>
<details class="why">
<summary title="Why?" aria-label="Why?">"?"</summary>
<p>"Every namespace on the appliance, and every person with a key."</p>
</details>
</div>
}
}
/// `/admin` — the box's own instruments, in one place.
#[component]
pub fn Admin() -> impl IntoView {
let session = crate::app::session();
view! {
<PageHeader
title="Administration"
/>
{move || match session.get() {
// The same one-round-trip rule the whole console is written under:
// drawing an operator's page for somebody who may not have it is as
// wrong as hiding it from somebody who does.
None => view! { <crate::ui::Loading/> }.into_any(),
Some(status) if !status.may_write() => view! {
<Empty
title="This page is the operator's"
detail="The appliance's own instruments; Gunnar refuses them to a viewer."
more="The keys that open the console, the objects nothing points at any more, and \
the copies of this box. This console does not ask on your behalf."
/>
}
.into_any(),
Some(status) => {
let is_admin = status.is_admin();
let carried = status.switches.clone();
view! {
<Banner/>
<div class="stack">
// The public site's and the MCP connector's switches (owner
// 2026-10-26), FIRST (GUI-PUBSW, 2026-10-29: an administrator of
// gunnar.rs found none): one panel per switch the console says
// its build carries, to an administrator only, in every bundle.
{site_switches(is_admin, carried)}
// No "Pages" panel (owner, 2026-10-04): it repeated
// the Administration menu right above it, link for
// link. The menu is the one way to the other pages.
// Passkeys, invites and the epoch switch. Gated exactly
// as they were on `/account`: this `Show` IS the
// `<Show when=is_admin>` that used to be there, moved
// with its panels rather than rewritten.
<Show when=move || is_admin>
<crate::pages::account::PasskeyAdmin/>
</Show>
// The twin's heartbeat and the restore behind it. Its
// own `may_write()` check is inside the component and
// stays there — an operator has this one.
// Replication, which needs the Overview's `/api/stats`
// and so fetches it here. The panel draws nothing for a
// non-admin, and it is asked for only once the session
// has answered, so a viewer's browser never makes the
// call at all.
<Replication/>
// Objects nothing points at. Draws nothing when there
// are none, which is the ordinary state of a healthy
// server.
<crate::pages::storage::Orphans/>
<ConsoleIdentity/>
// The front's certificates (owner 2026-10-25), as the front says
// them; the server answers them to an admin only.
<Show when=move || is_admin>
<FrontCerts/>
</Show>
// ★ A SOLD Gunnar is grown at the shop that sold it
// (one shop 4, 2026-10-07): a link there, for the
// admin — the shop's own console has none.
{status.grow_this_instance().filter(|_| is_admin).map(|url| view! { <GrowThisInstance url=url instance=status.instance.clone().unwrap_or_default()/> })}
</div>
}
.into_any()
}
}}
}
}
/// **"Grow this instance"**: more room and more machine for a SOLD Gunnar are
/// bought by its account at the shop that sold it; this is the way there.
#[component]
fn GrowThisInstance(url: String, instance: String) -> impl IntoView {
view! {
<Panel title="This instance">
<div class="panel-body">
<p>{format!("{instance}’s Gunnar comes from its provider, and grows there: more room, a bigger setup.")}</p>
<p style="margin-top:.5rem"><a class="button" rel="external" href=url>"Grow this instance"</a></p>
</div>
</Panel>
}
}
/// Whether gunnar has an administrator, and the key this console signs with.
#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
pub struct Bootstrap {
pub window_open: bool,
pub bootstrapped: bool,
pub bootstrapped_principal: String,
pub principals: u64,
pub keys: u64,
pub detail: String,
pub console_fingerprint: String,
}
/// **The console's own identity** — the key it signs every call to gunnar
/// with, and whether gunnar is bootstrapped. An administrator's fact: it was on
/// the Overview until 2026-10-02, where the owner ruled it does not belong (the
/// Overview's second line is the FORGE's key, which is the one a user checks).
#[component]
pub fn ConsoleIdentity() -> impl IntoView {
let bootstrap = LocalResource::new(|| async { crate::api::get::<Bootstrap>("/api/bootstrap").await });
move || {
resolved(bootstrap.get(), None, |bootstrap: Bootstrap| {
let fingerprint = bootstrap.console_fingerprint.clone();
view! {
<Panel title="The console's own identity">
<div class="panel-body">
<p>"The console signs every call to Gunnar with its own key. This is it:"</p>
<p class="mono" style="overflow-wrap:anywhere;margin-top:.5rem">{fingerprint}</p>
<p class="truncation" style="border:none;padding-left:0">
{if bootstrap.bootstrapped {
format!(
"Gunnar is bootstrapped by {} and has {} principals and {} keys.",
bootstrap.bootstrapped_principal, bootstrap.principals, bootstrap.keys,
)
} else {
format!("Gunnar is not bootstrapped yet: {}", bootstrap.detail)
}}
</p>
</div>
</Panel>
}
})
}
}
/// **The front's certificates** (owner 2026-10-25): valid until, next attempt, last reason
/// and the alert, each certificate as the front words it; "Try now" where the front allows
/// it. A console not served by a front draws nothing.
#[component]
fn FrontCerts() -> impl IntoView {
use crate::front_certs::{button, try_body, Cert, Certs, Tried, CERTS_API, TRY_API};
let reread = RwSignal::new(0u32);
let certs = LocalResource::new(move || {
reread.get();
async move { crate::api::get::<Certs>(CERTS_API).await }
});
let said = RwSignal::new(None::<String>);
let press = move |c: Cert| {
leptos::task::spawn_local(async move {
let words = match crate::api::post::<_, Tried>(TRY_API, &try_body(&c)).await {
Ok(t) => t.words,
Err(f) => f.message,
};
said.set(Some(words));
reread.update(|n| *n = n.wrapping_add(1));
});
};
move || {
certs.get().and_then(|r| r.ok()).filter(|c| !c.certificates.is_empty()).map(|c: Certs| {
view! {
<Panel title="Certificates">
<div class="panel-body">
{c.certificates.into_iter().map(|cert| {
let (may, why) = button(&cert, true);
let shown = cert.clone();
view! {
<section>
<p class="mono">{cert.host.clone()}</p>
{cert.alert.clone().map(|a| view! { <p class="failure" role="alert">{a}</p> })}
<pre class="mono">{cert.lines.join("\n")}</pre>
<p><button class="btn" title=why disabled=!may on:click=move |_| press(shown.clone())>"Try now"</button></p>
</section>
}
}).collect_view()}
{move || said.get().map(|w| view! { <p role="status">{w}</p> })}
</div>
</Panel>
}
})
}
}
/// The switches' panels, for an administrator: each switch the console says its build carries
/// (`/auth/status` `switches`, GUI-PUBSW), and IndexNow's key with the public site's.
fn site_switches(is_admin: bool, carried: Vec<String>) -> impl IntoView {
let switches = crate::site_switches::carried(&carried);
let public_site = switches.iter().any(|s| s.api == crate::site_switches::PUBLIC_SITE_API);
(is_admin && !switches.is_empty()).then(|| view! { <SiteSwitches switches/> {public_site.then(|| view! { <IndexNowKey/> })} })
}
/// **The administrator's switches** (`crate::site_switches`): one panel per switch the console
/// carries, drawn whether or not its position could be read (GUI-PUBSW): unread, the panel says
/// why and offers both positions; the console decides whether a flip is taken.
#[component]
fn SiteSwitches(switches: Vec<crate::site_switches::Switch>) -> impl IntoView {
use crate::site_switches::{button, flip_body, position, Position, Shown};
switches
.into_iter()
.map(|switch| {
let reread = RwSignal::new(0u32);
let read = LocalResource::new(move || {
reread.get();
async move { position(crate::api::get::<serde_json::Value>(switch.api).await.map_err(|f| f.message)) }
});
let said = RwSignal::new(None::<String>);
let press = move |on: bool| {
leptos::task::spawn_local(async move {
match crate::api::put::<_, Position>(switch.api, &flip_body(on)).await {
Ok(_) => said.set(None),
Err(f) => said.set(Some(f.message)),
}
reread.update(|n| *n = n.wrapping_add(1));
});
};
view! {
<Panel title=switch.title>
<div class="panel-body">
<p>{switch.what}</p>
{move || match read.get() {
None => view! { <p class="muted">"Reading its position…"</p> }.into_any(),
Some(Shown::On) => view! { <p>"It is on."</p> <p><button class="btn" on:click=move |_| press(false)>{button(true)}</button></p> }.into_any(),
Some(Shown::Off) => view! { <p>"It is off."</p> <p><button class="btn" on:click=move |_| press(true)>{button(false)}</button></p> }.into_any(),
Some(Shown::Unread(why)) => view! {
<p class="failure" role="alert">{format!("Its position could not be read: {why}")}</p>
<p><button class="btn" on:click=move |_| press(true)>{button(false)}</button>" "<button class="btn" on:click=move |_| press(false)>{button(true)}</button></p>
}.into_any(),
}}
{move || said.get().map(|w| view! { <p class="failure" role="alert">{w}</p> })}
</div>
</Panel>
}
})
.collect_view()
}
/// **IndexNow's key** (A32-16): drawn where the console's public site says whether it holds
/// one; the console stores it as gunnar's setting, judges the key and audits the change.
#[component]
fn IndexNowKey() -> impl IntoView {
use crate::site_switches::{indexnow_shown, key_body, INDEXNOW_API, PUBLIC_SITE_API};
let reread = RwSignal::new(0u32);
let held = LocalResource::new(move || {
reread.get();
async move { indexnow_shown(crate::api::get::<serde_json::Value>(PUBLIC_SITE_API).await.map_err(|f| f.status)) }
});
let typed = RwSignal::new(String::new());
let said = RwSignal::new(None::<String>);
let send = move |key: String| {
leptos::task::spawn_local(async move {
match crate::api::put::<_, serde_json::Value>(INDEXNOW_API, &key_body(&key)).await {
Ok(_) => {
said.set(None);
typed.set(String::new());
}
Err(f) => said.set(Some(f.message)),
}
reread.update(|n| *n = n.wrapping_add(1));
});
};
move || {
held.get().flatten().map(|set| {
view! {
<Panel title="IndexNow">
<div class="panel-body">
<p>"Tells search engines which public pages changed. Its key is the only reason the public site calls out."</p>
<p>{if set { "A key is set." } else { "No key is set: nothing is announced." }}</p>
<p>
<input type="text" placeholder="IndexNow key" prop:value=move || typed.get() on:input=move |ev| typed.set(event_target_value(&ev)) />
" "
<button class="btn" on:click=move |_| send(typed.get())>"Save key"</button>
{set.then(|| view! { " " <button class="btn" on:click=move |_| send(String::new())>"Clear key"</button> })}
</p>
{move || said.get().map(|w| view! { <p class="failure" role="alert">{w}</p> })}
</div>
</Panel>
}
})
}
}
/// Replication, with the counters it needs fetched here rather than inherited
/// from the Overview it used to hang on.
#[component]
fn Replication() -> impl IntoView {
let stats = LocalResource::new(|| async { crate::api::get::<Stats>("/api/stats").await });
view! {
{move || {
resolved(stats.get(), None, |stats: Stats| {
view! { <crate::pages::overview::ReplicationPanel stats/> }
})
}}
}
}