import type { Context, Hono } from "hono"; import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts"; import { listRepos } from "../bucket/repo.ts"; import { tarballGeneration } from "../mirror/queries.ts"; import { openMirror } from "../mirror/store.ts"; import { html } from "../pages/html.ts"; import { formatSize, plural } from "../pages/layout.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; const TARBALL = /^\.kepr\/tarballs\/([^/]+)\/([^/]+)\/([0-9a-f]{12})\.tar\.gz$/; const HIDDEN = /^\.kepr\/hidden\/([^/]+)$/; type RepoUse = { history: number; builds: number; tarballs: number }; type Tarball = { key: string; repo: string; save: string; size: number }; /** * The bucket sorted into what §24's Storage section shows. Orphans are only Kepr's own files that * nothing reads again; Koh's never are, since a build's files land before its record (§11.2). */ async function survey(ctx: AdminContext) { const sizes = await ctx.sync.bucket.sizes(""); const repos = await listRepos(ctx.sync.bucket); const generations = new Map( repos.map((repo) => [repo, tarballGeneration(openMirror(ctx.mirrorDir, repo))]), ); const use = new Map( repos.map((repo) => [repo, { history: 0, builds: 0, tarballs: 0 }]), ); const kept: Tarball[] = []; const orphans: string[] = []; let kepr = 0; let total = 0; for (const [key, size] of sizes) { total += size; const tarball = TARBALL.exec(key); const hidden = HIDDEN.exec(key); if (tarball) { const [, repo = "", generation, save = ""] = tarball; const owner = use.get(repo); if (owner && generations.get(repo) === generation) { owner.tarballs += size; kept.push({ key, repo, save, size }); } else orphans.push(key); } else if (hidden && !use.has(hidden[1] ?? "")) orphans.push(key); else if (key.startsWith(KEPR_PREFIX)) kepr += size; else { const [repo = "", kind] = key.split("/"); const owner = use.get(repo); if (owner && kind === "releases") owner.builds += size; else if (owner) owner.history += size; } } const orphanBytes = orphans.reduce((sum, key) => sum + (sizes.get(key) ?? 0), 0); return { use, kept, orphans, orphanBytes, kepr, total }; } async function storagePage(c: Context, ctx: AdminContext, error: string) { const { use, kept, orphans, orphanBytes, kepr, total } = await survey(ctx); const body = html`${notice(error)}

The bucket holds ${formatSize(total)}; Kepr's own folder ${formatSize(kepr)} besides its kept tarballs.

${[...use].map( ([repo, u]) => html``, )}
repohistorybuildskept tarballs
${repo} ${formatSize(u.history)} ${formatSize(u.builds)} ${formatSize(u.tarballs)}
${ orphans.length === 0 ? html`

no orphans

` : html`
${formField(c)}

${plural(orphans.length, "orphan")}, ${formatSize(orphanBytes)}: tarballs of an older generation and files of repos no longer in the bucket.

` }
${kept.length === 0 && html`

none kept

`} ${kept.map( (t) => html`
${formField(c)} ${t.repo} ${t.save}.tar.gz ${formatSize(t.size)}
`, )}
`; return adminPage(c, ctx, "storage", body); } /** §24's Storage section: sizes, kept tarballs and orphan cleanup. */ export function registerStorage(app: Hono, ctx: AdminContext): void { app.get("/admin/storage", async (c) => c.html(await storagePage(c, ctx, ""))); app.post("/admin/storage", async (c) => { const fields = await textFields(c); const { kept, orphans } = await survey(ctx); const doomed = fields.action === "delete_orphans" ? orphans : kept.filter((t) => t.key === fields.key).map((t) => t.key); if (doomed.length === 0 && fields.action !== "delete_orphans") return c.notFound(); try { for (const key of doomed) await ctx.sync.bucket.remove(key); } catch (error) { if (!(error instanceof BucketWriteError)) throw error; return c.html(await storagePage(c, ctx, error.message), 503); } return c.redirect("/admin/storage?saved=1", 303); }); }