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

//! What the object store holds, and where — the types and the panels the two
//! Storage pages share (You → Account → Storage is `pages::my_storage`,
//! Administration → Storage is `pages::admin_storage`).
//!
//! gunnar keeps a hot tier in redb and a cold tier in a znippy archive, so the
//! interesting number is not "how big is the repository" but how the two
//! compare — a repository with everything still hot has never been repacked.

use leptos::prelude::*;
use serde::Deserialize;

use crate::api::Page;
use crate::fmt;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, FailBox, Hint, Metric, Panel, Tone, Unset};

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Storage {
    pub object_backend: String,
    pub ref_backend: String,
    /// The engine in parts (name, description, source repository); absent
    /// from an older console server.
    #[serde(default)]
    pub engine: Option<Engine>,
    pub total_hot_bytes: u64,
    pub total_cold_bytes: u64,
    #[serde(default)]
    pub cold_measured: bool,
    pub repos: Vec<RepoStorage>,
    /// The exploded read cache and the sum of the per-repository budgets.
    #[serde(default)]
    pub total_explode_bytes: u64,
    #[serde(default)]
    pub total_explode_budget_bytes: u64,
    /// Bytes held by soft-deleted repositories that have not been purged.
    ///
    /// WHICH repositories hold them comes from `Repos.ListDeleted` at
    /// `/api/repos/deleted` rather than from here. `Admin.StorageInfo` carries
    /// a list too, but it is the storage layer's echo of the tombstones; the
    /// listing that owns the question is the one that also says who deleted
    /// each repository and is what the undo hangs off — one writer for
    /// "which repositories are deleted".
    #[serde(default)]
    pub total_tombstoned_bytes: u64,
}

/// The storage engine, as gunnar names it: shown as its name, linked to its
/// source, its description the tooltip.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Engine {
    pub name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub repo_url: String,
}

/// One soft-deleted repository, from `Repos.ListDeleted`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct DeletedRepo {
    pub path: String,
    pub namespace: String,
    pub name: String,
    pub bytes: u64,
    pub deleted_at_unix_ms: i64,
    /// `None` = no deadline. A reclaim that FAILED leaves one of these: bytes
    /// owed to an operator rather than bytes on a schedule.
    pub purge_after_unix_ms: Option<i64>,
    /// Who asked for the delete. Empty when gunnar did not record one.
    #[serde(default)]
    pub principal_id: String,
}

/// One store directory with no catalog row and no tombstone.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Orphan {
    pub path: String,
    pub dir: String,
    pub bytes: u64,
    #[serde(default)]
    pub hash_marker: String,
    pub modified_unix_ms: Option<i64>,
    #[serde(default)]
    pub failed_reclaim: String,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RepoStorage {
    pub repo: String,
    pub hash_kind: String,
    pub loose_object_count: u64,
    pub hot_bytes: u64,
    pub cold_bytes: u64,
    pub cold_object_count: u64,
    #[serde(default)]
    pub cold_measured: bool,
    pub last_repack_at_unix_ms: Option<i64>,
    pub ref_count: u64,
    #[serde(default)]
    pub explode_bytes: u64,
    #[serde(default)]
    pub explode_budget_bytes: u64,
}

/// ★ **Bytes held by repositories somebody has already deleted — and the undo.**
///
/// Absent when there are none. Present, it is the answer to the only question
/// this number ever prompts: *I deleted a repository and my usage did not go
/// down.* It did not, and this says by how much, until when, and who did it.
///
/// # The undo that existed and could not be reached
///
/// The console's delete is a SOFT delete (`purge: false`, gunnar's retention
/// window), so every one of those clicks was undoable — from the control plane,
/// on a box whose PID 1 has no shell. `Repos.Undelete` has been there the whole
/// time and nothing in the console called it, which made a reversible action
/// read as an irreversible one to everybody who only has the console. The
/// button is here, beside the row it is about, because that is where somebody
/// who has just deleted the wrong repository will look.
///
/// **There is no Purge button, deliberately.** Purge destroys objects with no
/// undo of its own, and it stays an operator's decision at the control plane.
///
/// These bytes are NOT inside the hot or cold totals on this page: a tombstoned
/// repository has no catalog row, so it is in none of the per-repository rows.
/// It is charged to the account all the same.
#[component]
pub fn Deleted(held: u64) -> impl IntoView {
    let reload = RwSignal::new(0u32);
    let deleted = LocalResource::new(move || {
        // Read here, so an undo shows up without a page reload.
        reload.get();
        async { crate::api::get::<Page<DeletedRepo>>("/api/repos/deleted").await }
    });
    let may_write = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.may_write()));

    view! { {move || {
        let now = now_ms();
        resolved(deleted.get(), None, move |page: Page<DeletedRepo>| {
            // Nothing deleted and nothing held: no panel. An operator who has
            // never deleted anything does not need a table telling them so.
            if page.items.is_empty() && held == 0 {
                return ().into_any();
            }
            let rows = page.items.clone();
            view! {
                <div id="deleted">
                <Panel title="Deleted, not yet purged">
                    <div class="panel-body">
                        <dl class="metrics">
                            <Metric
                                label="Held"
                                value=fmt::bytes(held)
                                unit="not in the totals above"
                            />
                            <Metric label="Repositories" value=fmt::count(rows.len() as u64)/>
                        </dl>
                        <Hint
                            line="A SOFT delete: the bytes stay until the retention window closes. Undo restores it."
                            more="A delete from this console is a SOFT delete: the name stops \
                                  working and nothing can be pushed to it, but the bytes stay until \
                                  the retention window closes. Undo puts the repository back exactly \
                                  as it was — the recorded hash kind included. Reclaiming the bytes \
                                  early is `gunnar control repo purge`, which has no undo and is not \
                                  a button here."
                        />
                    </div>
                    {if rows.is_empty() {
                        view! {
                            <div class="panel-body">
                                <Unset why="Gunnar reports the bytes but no tombstone rows"/>
                            </div>
                        }
                        .into_any()
                    } else {
                        view! {
                            <div class="table-wrap">
                                <table>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Repository"</th>
                                            <th scope="col">"Held"</th>
                                            <th scope="col">"Deleted"</th>
                                            <th scope="col">"By"</th>
                                            <th scope="col">"Purges"</th>
                                            <th scope="col">"Undo"</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {rows
                                            .into_iter()
                                            .map(|t| {
                                                view! {
                                                    <tr>
                                                        <td class="mono">{t.path.clone()}</td>
                                                        <td class="num">{fmt::bytes(t.bytes)}</td>
                                                        <td class="num">
                                                            {fmt::since(t.deleted_at_unix_ms, now)}
                                                        </td>
                                                        <td class="mono">
                                                            {if t.principal_id.is_empty() {
                                                                view! {
                                                                    <Unset why="Gunnar recorded no principal"/>
                                                                }
                                                                .into_any()
                                                            } else {
                                                                view! { {t.principal_id.clone()} }.into_any()
                                                            }}
                                                        </td>
                                                        <td class="num">
                                                            {match t.purge_after_unix_ms {
                                                                Some(at) => view! { {fmt::deadline(at, now)} }.into_any(),
                                                                // No deadline is a reclaim that
                                                                // FAILED, not a window that never
                                                                // closes.
                                                                None => view! {
                                                                    <Unset why="no deadline — a reclaim failed here"/>
                                                                }
                                                                .into_any(),
                                                            }}
                                                        </td>
                                                        <td>
                                                            <Show when=move || may_write.get()>
                                                                <Undo
                                                                    repo=t.clone()
                                                                    on_undone=Callback::new(move |()| {
                                                                        reload.update(|n| *n += 1)
                                                                    })
                                                                />
                                                            </Show>
                                                        </td>
                                                    </tr>
                                                }
                                            })
                                            .collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        }
                        .into_any()
                    }}
                </Panel>
                </div>
            }
            .into_any()
        })
    }} }
}

/// The undo itself: `Repos.Undelete`, one repository.
///
/// It reports gunnar's refusal verbatim rather than a shrug. The window CAN
/// close between the page being drawn and the button being pressed, and
/// "the tombstone is gone" is the one answer an operator must not have to
/// guess at.
#[component]
fn Undo(repo: DeletedRepo, on_undone: Callback<()>) -> impl IntoView {
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<crate::api::Fail>);
    let path = format!(
        "/api/repos/{}/{}/undelete",
        crate::api::segment(&repo.namespace),
        crate::api::segment(&repo.name)
    );
    let click = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        let path = path.clone();
        leptos::task::spawn_local(async move {
            match crate::api::post_empty::<serde_json::Value>(&path).await {
                Ok(_) => on_undone.run(()),
                Err(fail) => failed.set(Some(fail)),
            }
            busy.set(false);
        });
    };
    view! {
        <button class="btn" disabled=move || busy.get() on:click=click>
            {move || if busy.get() { "Restoring…" } else { "Undo delete" }}
        </button>
        {move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
    }
}

/// ★ **Store directories with no catalog row and no tombstone**: bytes nothing
/// else on the box can see, under a name nothing else can free.
///
/// `List` reads the catalog and an orphan is not in it; the deleted list keys
/// off a tombstone and an orphan has none — so before `Repos.ListOrphans` these
/// bytes appeared in no listing anywhere, while `Create` refused the name
/// because the directory was there. On the appliance that was ~25-39 GB of a
/// failed kernel mirror standing at `alice/linux`. **An orphan is invisible
/// bytes and invisible bytes are a billing surprise**, which is why this panel
/// exists at all.
///
/// **It shows and does not touch.** The two exits — `ReclaimOrphan`, and
/// `Create --adopt` with the hash this row reports — either destroy objects
/// somebody wrote or take responsibility for them, and both are an operator's
/// decision at the control plane rather than a button beside a size.
///
/// Admin only: the rows carry paths on the appliance's disk.
#[component]
pub fn Orphans() -> impl IntoView {
    let orphans =
        LocalResource::new(|| async { crate::api::get::<Page<Orphan>>("/api/orphans").await });
    let is_admin = Memo::new(move |_| crate::app::session().get().is_some_and(|s| s.is_admin()));
    view! { {move || {
        if !is_admin.get() {
            return ().into_any();
        }
        let now = now_ms();
        resolved(orphans.get(), None, move |page: Page<Orphan>| {
            // No orphans, no panel: this is the ordinary state of a healthy
            // server, and a permanent "none" would be noise on every visit.
            if page.items.is_empty() {
                return ().into_any();
            }
            let stranded: u64 = page.items.iter().map(|o| o.bytes).sum();
            let rows = page.items.clone();
            view! {
                <Panel title="Orphan stores">
                    <div class="panel-body">
                        <dl class="metrics">
                            <Metric label="Stranded" value=fmt::bytes(stranded)/>
                            <Metric label="Directories" value=fmt::count(rows.len() as u64)/>
                        </dl>
                        <Hint
                            line="Stores with no catalog row and no tombstone. Exits: `gunnar control repo reclaim` or `create --adopt`."
                            more="These bytes are in no other listing on this page and in no repository, \
                                  and the name cannot be re-created while the directory is there. The \
                                  two exits are `gunnar control repo reclaim <ns/name>`, which destroys \
                                  the objects, and `gunnar control repo create <ns/name> --adopt \
                                  --hash <marker>`, which takes them over as a repository. Both are \
                                  deliberate and neither is a button here."
                        />
                    </div>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th scope="col">"Name"</th>
                                    <th scope="col">"On disk"</th>
                                    <th scope="col">"Size"</th>
                                    <th scope="col">"Hash"</th>
                                    <th scope="col">"Last written"</th>
                                    <th scope="col">"Why"</th>
                                </tr>
                            </thead>
                            <tbody>
                                {rows
                                    .into_iter()
                                    .map(|o| {
                                        view! {
                                            <tr>
                                                <td class="mono">{o.path.clone()}</td>
                                                <td class="mono">{o.dir.clone()}</td>
                                                <td class="num">{fmt::bytes(o.bytes)}</td>
                                                <td class="mono">
                                                    {if o.hash_marker.is_empty() {
                                                        // What `--adopt` must be told, and
                                                        // the reason one adopt was refused.
                                                        view! {
                                                            <Unset why="no OBJECT_FORMAT marker in the store"/>
                                                        }
                                                        .into_any()
                                                    } else {
                                                        view! { {o.hash_marker.clone()} }.into_any()
                                                    }}
                                                </td>
                                                <td class="num">
                                                    {match o.modified_unix_ms {
                                                        Some(at) => view! { {fmt::since(at, now)} }.into_any(),
                                                        None => view! { <Unset why="nothing readable inside"/> }
                                                            .into_any(),
                                                    }}
                                                </td>
                                                <td>
                                                    {if o.failed_reclaim.is_empty() {
                                                        view! {
                                                            <Badge
                                                                tone=Tone::Warn
                                                                label="no record of it anywhere"
                                                            />
                                                        }
                                                        .into_any()
                                                    } else {
                                                        // Bytes OWED to an operator: the
                                                        // deleted list filters these out on
                                                        // purpose, which left them in no
                                                        // listing at all.
                                                        view! {
                                                            <>
                                                                <Badge tone=Tone::Bad label="a reclaim failed"/>
                                                                <div class="meta">{o.failed_reclaim.clone()}</div>
                                                            </>
                                                        }
                                                        .into_any()
                                                    }}
                                                </td>
                                            </tr>
                                        }
                                    })
                                    .collect_view()}
                            </tbody>
                        </table>
                    </div>
                </Panel>
            }
            .into_any()
        })
    }} }
}