1import type { Context, Hono } from "hono";2import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3import { listRepos } from "../bucket/repo.ts";4import { tarballGeneration } from "../mirror/queries.ts";5import { openMirror } from "../mirror/store.ts";6import { html } from "../pages/html.ts";7import { formatSize, plural } from "../pages/layout.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";910const TARBALL = /^\.kepr\/tarballs\/([^/]+)\/([^/]+)\/([0-9a-f]{6}|[0-9a-f]{12})\.tar\.gz$/;11const HIDDEN = /^\.kepr\/hidden\/([^/]+)$/;1213type RepoUse = { history: number; builds: number; tarballs: number };14type Tarball = { key: string; repo: string; save: string; size: number };151617181920async function survey(ctx: AdminContext) {21 const sizes = await ctx.sync.bucket.sizes("");22 const repos = await listRepos(ctx.sync.bucket);23 const generations = new Map(24 repos.map((repo) => [repo, tarballGeneration(openMirror(ctx.mirrorDir, repo))]),25 );26 const use = new Map<string, RepoUse>(27 repos.map((repo) => [repo, { history: 0, builds: 0, tarballs: 0 }]),28 );29 const kept: Tarball[] = [];30 const orphans: string[] = [];31 let kepr = 0;32 let total = 0;33 for (const [key, size] of sizes) {34 total += size;35 const tarball = TARBALL.exec(key);36 const hidden = HIDDEN.exec(key);37 if (tarball) {38 const [, repo = "", generation, save = ""] = tarball;39 const owner = use.get(repo);40 if (owner && generations.get(repo) === generation) {41 owner.tarballs += size;42 kept.push({ key, repo, save, size });43 } else orphans.push(key);44 } else if (hidden && !use.has(hidden[1] ?? "")) orphans.push(key);45 else if (key.startsWith(KEPR_PREFIX)) kepr += size;46 else {47 const [repo = "", kind] = key.split("/");48 const owner = use.get(repo);49 if (owner && kind === "releases") owner.builds += size;50 else if (owner) owner.history += size;51 }52 }53 const orphanBytes = orphans.reduce((sum, key) => sum + (sizes.get(key) ?? 0), 0);54 return { use, kept, orphans, orphanBytes, kepr, total };55}5657async function storagePage(c: Context, ctx: AdminContext, error: string) {58 const { use, kept, orphans, orphanBytes, kepr, total } = await survey(ctx);59 const body = html`${notice(error)}60<p class="admin-intro">The bucket holds ${formatSize(total)}; Kepr's own folder ${formatSize(kepr)}61besides its kept tarballs.</p>62<section class="repo-section"><h2 class="section-label mono">by repository</h2>63<table class="admin-table mono">64<thead><tr><th>repo</th><th>history</th><th>builds</th><th>kept tarballs</th></tr></thead>65<tbody>66${[...use].map(67 ([repo, u]) => html`<tr>68<td>${repo}</td>69<td>${formatSize(u.history)}</td>70<td>${formatSize(u.builds)}</td>71<td>${formatSize(u.tarballs)}</td>72</tr>`,73)}74</tbody></table></section>75<section class="repo-section"><h2 class="section-label mono">orphans</h2>76${77 orphans.length === 078 ? html`<p class="muted mono">no orphans</p>`79 : html`<form class="issues-form" method="post" action="/admin/storage">80${formField(c)}<input type="hidden" name="action" value="delete_orphans">81<p class="issue-meta mono">${plural(orphans.length, "orphan")}, ${formatSize(orphanBytes)}: tarballs82of an older generation and files of repos no longer in the bucket.</p>83<button class="issues-submit" type="submit">delete orphans</button></form>`84}85</section>86<section class="repo-section"><h2 class="section-label mono">kept tarballs</h2>87${kept.length === 0 && html`<p class="muted mono">none kept</p>`}88${kept.map(89 (t) => html`<form class="issue-row" method="post" action="/admin/storage">90${formField(c)}<input type="hidden" name="action" value="delete_tarball">91<input type="hidden" name="key" value="${t.key}">92<span class="mono">${t.repo} ${t.save}.tar.gz</span>93<span class="issue-meta mono">${formatSize(t.size)}</span>94<button class="issues-submit" type="submit">delete</button></form>`,95)}96</section>`;97 return adminPage(c, ctx, "storage", body);98}99100101export function registerStorage(app: Hono, ctx: AdminContext): void {102 app.get("/admin/storage", async (c) => c.html(await storagePage(c, ctx, "")));103104 app.post("/admin/storage", async (c) => {105 const fields = await textFields(c);106 const { kept, orphans } = await survey(ctx);107 const doomed =108 fields.action === "delete_orphans"109 ? orphans110 : kept.filter((t) => t.key === fields.key).map((t) => t.key);111 if (doomed.length === 0 && fields.action !== "delete_orphans") return c.notFound();112 try {113 for (const key of doomed) await ctx.sync.bucket.remove(key);114 } catch (error) {115 if (!(error instanceof BucketWriteError)) throw error;116 return c.html(await storagePage(c, ctx, error.message), 503);117 }118 return c.redirect("/admin/storage?saved=1", 303);119 });120}