storage.ts

4.9 KB · raw

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 };1516/**17 * The bucket sorted into what §24's Storage section shows. Orphans are only Kepr's own files that18 * nothing reads again; Koh's never are, since a build's files land before its record (§11.2).19 */20async 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}99100/** §24's Storage section: sizes, kept tarballs and orphan cleanup. */101export 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}