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