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/mod.rs

//! The console's pages.

pub mod access;
/// **Events by context**: a repository's Activity tab and You → Activity.
pub mod activity;
pub mod account;
/// **Administration** — every instrument that acts on the box rather than on
/// the reader's own account, in one place and behind one banner. See the module
/// for why they were collected and why the move changed no permission.
pub mod admin;
/// **Administration → Storage**: the machine's storage, for an administrator.
pub mod admin_storage;
pub mod blame;
pub mod browse;
pub mod chain;
/// **You → Clans** (2026-10-05): the person's clans and the clan verbs.
pub mod clans;
/// **The gunnar CLI** (E4, 2026-10-11): the signed binaries this site serves, and crates.io.
pub mod cli;
pub mod diff;
pub mod events;
pub mod graph;
/// **The "more room" panel.** Only under `metered`: a community build does not
/// contain it, so there is nothing to hide and nothing to explain.
#[cfg(feature = "metered")]
pub mod increase;
/// **Administration → Identity provider**: company SSO and provisioning.
pub mod identity_provider;
/// **Administration → Audit log** (2026-10-05): the account admin's log, the operator's looks, the names held.
pub mod audit_log;
/// **Administration → Accounts** (2026-10-05): the operator's totals, and the look it asks for.
pub mod account_totals;
/// **The customer's own account pane** — plan, storage, renew, buy. Only under
/// `metered`, the second module behind this feature and the first addition to
/// it.
///
/// # The money-containment boundary, restated because it just moved
///
/// `the_consoles_own_words_are_free_of_money` scans every source this console
/// compiles for money vocabulary and skips exactly the files named
/// `METERED_ONLY`. That list is the boundary: a file on it may say what a
/// community build must never contain, and a file off it may not. Adding a
/// second entry widens the area the scanner is NOT watching, so it is a
/// deliberate act and not a convenience.
///
/// What makes this file safe to put there is not the feature gate. It is the
/// same property that makes the routes it reads safe: **every one of them is
/// under `/api/account/`, and every handler there acts on the name in the
/// SESSION — no path segment, query parameter or body field can change it.**
/// This page sends no namespace, reads none out of the route, and has no way to
/// name another account even by mistake. The gate decides whether the pane
/// exists; that property decides whose account it can touch, and the second is
/// the one that would matter if the first were ever wrong.
#[cfg(feature = "metered")]
pub mod subscription;
pub mod issues;
/// **The machine's Layout and Allocation panels** (Administration → Storage):
/// `/data` and `/serialdat` each naming their own disk, the off-stripe copies,
/// gunnar's placement table and, folded, the layout config the installer
/// wrote. No page of its own since 2026-10-04. Every decision lives in
/// [`crate::machine`]; this module draws them.
pub mod machine;
/// **Mirrors**: connect a forge, pick repositories, watch them arrive. Every
/// decision it makes lives in [`crate::mirror`], on the host side of the wasm
/// boundary; this module draws them.
pub mod mirrors;
/// **You → Account → Storage**: a person's own room and repositories.
pub mod my_storage;
pub mod namespace;
pub mod overview;
pub mod policy;
pub mod pulls;
/// **The README, typeset by the server.** The console does no markdown work;
/// see the module for why it sets the answer with `inner_html` and why that is
/// not the usual mistake.
pub mod readme;
pub mod repo;
pub mod repos;
pub mod search;
pub mod storage;
pub mod usage;

use leptos::prelude::*;

use crate::api::Fail;
use crate::ui::{FailBox, Loading};

/// **A control that writes, or "Sign in to …"** (GUN-GUEST, owner 2026-10-30): the
/// children for whoever may (`may`: gunnar's per-repository answer for this
/// person), a "Sign in to {what}" link to the login page and back here for a
/// visitor (with "sign up" where this console signs people up), and nothing for a
/// person gunnar gives no right here ([`crate::guest::affordance`]). Nothing is
/// drawn until `/auth/status` has answered. Not a permission: the server refuses
/// every write to whoever may not make it.
#[component]
pub fn Writes(what: &'static str, #[prop(into)] may: Signal<bool>, children: ChildrenFn) -> impl IntoView {
    let session = crate::app::session();
    let location = leptos_router::hooks::use_location();
    let children = StoredValue::new(children);
    move || {
        let Some(status) = session.get() else {
            return ().into_any();
        };
        let signed_in = status.authenticated || status.open;
        let search = location.search.get();
        let here = if search.is_empty() {
            location.pathname.get()
        } else {
            format!("{}?{}", location.pathname.get(), search.trim_start_matches('?'))
        };
        match crate::guest::affordance(signed_in, may.get(), what, &here, status.signup_url.as_deref()) {
            crate::guest::Affordance::Control => children.with_value(|c| c()).into_any(),
            // `rel="external"`: `/login` and `/signup` are the SERVER's pages, which
            // the router would otherwise claim and draw nothing for.
            crate::guest::Affordance::SignIn { words, login, signup } => view! {
                <p class="sign-in-to">
                    <a class="btn" rel="external" href=login>{words}</a>
                    {signup.map(|to| view! { " or " <a rel="external" href=to>"sign up"</a> })}
                </p>
            }
            .into_any(),
            crate::guest::Affordance::Nothing => ().into_any(),
        }
    }
}

/// Render the three states every read can be in.
///
/// Every page in the console does this, and doing it by hand in each one is how
/// a loading state quietly turns into a blank panel.
pub fn resolved<T: Clone + 'static, V: IntoView + 'static>(
    state: Option<Result<T, Fail>>,
    retry: Option<Callback<()>>,
    render: impl FnOnce(T) -> V,
) -> AnyView {
    match state {
        None => view! { <Loading/> }.into_any(),
        Some(Err(fail)) => match retry {
            Some(retry) => view! {
                <div class="panel-body"><FailBox fail=fail retry=retry/></div>
            }
            .into_any(),
            None => view! {
                <div class="panel-body"><FailBox fail=fail/></div>
            }
            .into_any(),
        },
        Some(Ok(value)) => render(value).into_any(),
    }
}

/// Milliseconds since the Unix epoch, from the browser's clock.
pub fn now_ms() -> i64 {
    js_sys::Date::now() as i64
}

/// How long ago something happened, or a dash if gunnar never recorded it.
///
/// gunnar leaves several of its timestamps at zero — principals, grants and
/// namespaces all arrive that way. Rendering the zero puts "689 months ago"
/// beside a row created two minutes back, which is worse than admitting the
/// server does not keep the fact.
pub fn when(unix_ms: Option<i64>, now: i64) -> AnyView {
    match unix_ms {
        Some(at) => view! { {crate::fmt::since(at, now)} }.into_any(),
        None => {
            view! { <crate::ui::Unset why="Gunnar does not record this timestamp"/> }.into_any()
        }
    }
}

/// The same, where only a string will do.
pub fn when_text(unix_ms: Option<i64>, now: i64) -> String {
    match unix_ms {
        Some(at) => crate::fmt::since(at, now),
        None => "—".to_owned(),
    }
}

/// **"This page does not exist."** The one view a namespace or repository the
/// caller cannot see is drawn as — whether it is not there or is private and
/// not theirs, the server does not say and neither does this.
#[component]
pub fn NotHere() -> impl IntoView {
    view! {
        <div class="empty" role="status">
            <h3>"This page does not exist"</h3>
            <p>
                "There is nothing at this address. Check the namespace and the \
                 repository name, or pick one from the list."
            </p>
            <a class="btn" href="/repos">"All repositories"</a>
        </div>
    }
}

/// **Every `/:namespace/:name/…` page, behind the one read that says
/// whether the repository is there.** The page's own panes are constructed
/// only once that read has answered and the answer is not absence
/// ([`crate::gate`]), so an unknown namespace sends none of their requests and
/// draws none of their frames.
#[component]
pub fn RepoGate(children: ChildrenFn) -> impl IntoView {
    let params = leptos_router::hooks::use_params_map();
    let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
    let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
    let record = LocalResource::new(move || {
        let url = format!(
            "/api/repos/{}/{}",
            crate::api::segment(&namespace.get()),
            crate::api::segment(&name.get())
        );
        async move { crate::api::get::<serde_json::Value>(&url).await }
    });
    let children = StoredValue::new(children);
    move || match crate::gate::gate(&record.get()) {
        crate::gate::Gate::Wait => view! { <Loading/> }.into_any(),
        crate::gate::Gate::Absent => view! { <NotHere/> }.into_any(),
        crate::gate::Gate::Show => children.with_value(|c| c()).into_any(),
    }
}

/// **What THIS person may do on the repository in the address** — gunnar's
/// per-repository answer ([`crate::rights`]), asked once the session is known.
/// A visitor, or a read that fails, holds no right: nothing to draw.
pub fn repo_rights() -> Memo<crate::rights::Rights> {
    let params = leptos_router::hooks::use_params_map();
    let session = crate::app::session();
    let answer = LocalResource::new(move || {
        let (ns, name) = params.with(|p| (p.get("namespace").unwrap_or_default(), p.get("name").unwrap_or_default()));
        let open = session.get().is_some_and(|s| s.open);
        let signed_in = session.get().is_some_and(|s| s.authenticated);
        async move {
            if open {
                // The loopback console acts as the console itself: every right.
                return crate::rights::Rights { read: true, write: true, admin: true };
            }
            if !signed_in || ns.is_empty() || name.is_empty() {
                return crate::rights::Rights::default();
            }
            crate::api::get::<crate::rights::Rights>(&crate::rights::url(&ns, &name))
                .await
                .unwrap_or_default()
        }
    });
    Memo::new(move |_| answer.get().unwrap_or_default())
}