0c5812ee878a

show bucket storage by repo at /admin/storage, with per-tarball delete and cleanup of Kepr's orphaned files (v3.0.162)

time
2026-09-30 23:20 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
850a5062b8fe
8 files package.json+1 −1public/css/kepr.css+16 −0src/admin/console.ts+1 −0src/admin/storage.ts+120 −0src/bucket/reader.ts+23 −7src/server/app.ts+2 −0tests/bucket.test.ts+13 −3tests/storage.test.ts+77 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.161",3+	"version": "3.0.162",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+16 −0
@@ -2225,6 +2225,22 @@22252225 .admin-days li > span:last-child {22262226 	text-align: right;22272227 }2228+.admin-table {2229+	width: 100%;2230+	border-collapse: collapse;2231+	font-size: 0.6875rem;2232+}2233+.admin-table th,2234+.admin-table td {2235+	padding: 6px 12px 6px 0;2236+	border-bottom: 1px solid var(--border);2237+	text-align: left;2238+	font-weight: 400;2239+	overflow-wrap: anywhere;2240+}2241+.admin-table th {2242+	color: var(--text-muted);2243+}22282244 .admin-releases {22292245 	margin: 12px 0 0;22302246 	padding: 0;

view file

src/admin/console.ts+1 −0
@@ -24,6 +24,7 @@2424 	["/admin/repos", "repos"],2525 	["/admin/issues", "issues"],2626 	["/admin/downloads", "downloads"],27+	["/admin/storage", "storage"],2728 	["/admin/notifications", "notifications"],2829 	["/admin/email", "email"],2930 	["/admin/bucket", "bucket"],

view file

src/admin/storage.ts+120 −0
@@ -0,0 +1,120 @@1+import type { Context, Hono } from "hono";2+import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3+import { listRepos } from "../bucket/repo.ts";4+import { tarballGeneration } from "../mirror/queries.ts";5+import { openMirror } from "../mirror/store.ts";6+import { html } from "../pages/html.ts";7+import { formatSize, plural } from "../pages/layout.ts";8+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";9+10+const TARBALL = /^\.kepr\/tarballs\/([^/]+)\/([^/]+)\/([0-9a-f]{12})\.tar\.gz$/;11+const HIDDEN = /^\.kepr\/hidden\/([^/]+)$/;12+13+type RepoUse = { history: number; builds: number; tarballs: number };14+type Tarball = { key: string; repo: string; save: string; size: number };15+16+/**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+ */20+async 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+}56+57+async 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)}61+besides 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)}: tarballs82+of 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+}99+100+/** §24's Storage section: sizes, kept tarballs and orphan cleanup. */101+export function registerStorage(app: Hono, ctx: AdminContext): void {102+	app.get("/admin/storage", async (c) => c.html(await storagePage(c, ctx, "")));103+104+	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+}

view file

src/bucket/reader.ts+23 −7
@@ -14,6 +14,8 @@1414 export type Bucket = {1515 	get(key: string): Promise<Uint8Array | null>;1616 	list(prefix: string): Promise<string[]>;17+	/** Every key under `prefix` with its size in bytes, for the console's storage view. */18+	sizes(prefix: string): Promise<Map<string, number>>;1719 	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */1820 	downloadUrl(key: string, filename: string, type: string): string;1921 	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */@@ -114,6 +116,16 @@114116 			} while (continuationToken);115117 			return keys;116118 		},119+		async sizes(prefix) {120+			const sizes = new Map<string, number>();121+			let continuationToken: string | undefined;122+			do {123+				const page = await client.list({ prefix, continuationToken });124+				for (const object of page.contents ?? []) sizes.set(object.key, object.size ?? 0);125+				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;126+			} while (continuationToken);127+			return sizes;128+		},117129 		downloadUrl(key, filename, type) {118130 			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).119131 			return client.presign(key, {@@ -138,19 +150,23 @@138150 }139151 140152 function folderReader(root: string): Bucket {153+	const list = async (prefix: string) => {154+		const entries = await readdir(root, { recursive: true, withFileTypes: true });155+		return entries156+			.filter((entry) => entry.isFile())157+			.map((entry) => relative(root, join(entry.parentPath, entry.name)))158+			.filter((key) => key.startsWith(prefix))159+			.sort();160+	};141161 	return {142162 		async get(key) {143163 			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);144164 			const file = Bun.file(join(root, key));145165 			return (await file.exists()) ? await file.bytes() : null;146166 		},147-		async list(prefix) {148-			const entries = await readdir(root, { recursive: true, withFileTypes: true });149-			return entries150-				.filter((entry) => entry.isFile())151-				.map((entry) => relative(root, join(entry.parentPath, entry.name)))152-				.filter((key) => key.startsWith(prefix))153-				.sort();167+		list,168+		async sizes(prefix) {169+			return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size]));154170 		},155171 		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.156172 		downloadUrl(key) {

view file

src/server/app.ts+2 −0
@@ -13,6 +13,7 @@1313 import { registerNotifications } from "../admin/notifications.ts";1414 import { registerRepos } from "../admin/repos.ts";1515 import { registerSite } from "../admin/site.ts";16+import { registerStorage } from "../admin/storage.ts";1617 import { registerRepoText } from "../admin/text.ts";1718 import { type Bucket, openBucket } from "../bucket/reader.ts";1819 import { isSafePath } from "../bucket/repo.ts";@@ -143,6 +144,7 @@143144 	registerRepos(app, admin);144145 	registerIssues(app, admin);145146 	registerDownloads(app, admin);147+	registerStorage(app, admin);146148 	registerSite(app, admin);147149 	registerBucket(app, admin);148150 	registerNotifications(app, admin);

view file

tests/bucket.test.ts+13 −3
@@ -53,7 +53,7 @@5353 	});5454 });5555 56-test("the S3 reader pages through listings and reads a missing key as null", async () => {56+test("the S3 reader pages through listings with sizes and reads a missing key as null", async () => {5757 	const pageSize = 2;5858 	const server = Bun.serve({5959 		port: 0,@@ -61,11 +61,14 @@6161 			const url = new URL(req.url);6262 			const folder = bucket();6363 			if (url.searchParams.get("list-type") === "2") {64-				const all = await folder.list(url.searchParams.get("prefix") ?? "");64+				const sizes = await folder.sizes(url.searchParams.get("prefix") ?? "");65+				const all = [...sizes.keys()];6566 				const start = Number(url.searchParams.get("continuation-token") ?? 0);6667 				const page = all.slice(start, start + pageSize);6768 				const more = start + pageSize < all.length;68-				const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");69+				const contents = page70+					.map((key) => `<Contents><Key>${key}</Key><Size>${sizes.get(key)}</Size></Contents>`)71+					.join("");6972 				const next = more7073 					? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`7174 					: "";@@ -88,6 +91,9 @@8891 			secretAccessKey: "only",8992 		});9093 		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));94+		const sizes = await s3.sizes(`${fx.repo}/`);95+		expect(sizes).toEqual(await bucket().sizes(`${fx.repo}/`));96+		expect(sizes.get(`${fx.repo}/head`)).toBe(12);9197 		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);9298 		// koh3 writes `<repo>/format` on its first offer (§9.2), so it is present;9399 		// a key that is not there is what reads as null.@@ -146,6 +152,7 @@146152 			{147153 				get: async () => bytes,148154 				list: async () => [],155+				sizes: async () => new Map(),149156 				downloadUrl: () => "",150157 				viewUrl: () => null,151158 				put: async () => {},@@ -198,6 +205,7 @@198205 			{199206 				get: async () => bytes,200207 				list: async () => [],208+				sizes: async () => new Map(),201209 				downloadUrl: () => "",202210 				viewUrl: () => null,203211 				put: async () => {},@@ -270,6 +278,7 @@270278 		const reader = {271279 			get: async () => accepted,272280 			list: async () => [],281+			sizes: async () => new Map(),273282 			downloadUrl: () => "",274283 			viewUrl: () => null,275284 			put: async () => {},@@ -290,6 +299,7 @@290299 			const tampered = {291300 				get: async () => bytes,292301 				list: async () => [],302+				sizes: async () => new Map(),293303 				downloadUrl: () => "",294304 				viewUrl: () => null,295305 				put: async () => {},

view file

tests/storage.test.ts+77 −0
@@ -0,0 +1,77 @@1+// Covers §24's Storage section: sizes by repo and kind, kept tarballs with a delete each, and2+// orphan cleanup that touches only Kepr's own files.3+4+import { beforeAll, describe, expect, test } from "bun:test";5+import { mkdir, writeFile } from "node:fs/promises";6+import { dirname, join } from "node:path";7+import { Browser, type Instance, instance } from "./browser.ts";8+import { makeRepos, writeRelease } from "./fixture.ts";9+10+describe("storage view (§24)", () => {11+	let inst: Instance;12+	let operator: Browser;13+	let bucketDir: string;14+	let save: string;15+	const exists = (key: string) => Bun.file(join(bucketDir, key)).exists();16+	const put = async (key: string) => {17+		await mkdir(dirname(join(bucketDir, key)), { recursive: true });18+		await writeFile(join(bucketDir, key), "x");19+	};20+	let generation = "";21+	const kept = () => `.kepr/tarballs/app/${generation}/${save}.tar.gz`;22+	const orphans = [23+		".kepr/tarballs/app/0-000000000000/aaaaaaaaaaaa.tar.gz",24+		".kepr/tarballs/gone/0-000000000000/aaaaaaaaaaaa.tar.gz",25+		".kepr/hidden/gone",26+	];27+	const unlisted = "app/releases/9.9.9/linux-x86_64/app";28+29+	beforeAll(async () => {30+		const repos = await makeRepos(["app"]);31+		bucketDir = repos.bucketDir;32+		save = repos.saves.app as string;33+		await writeRelease(bucketDir, "app", {34+			version: "1.0.0",35+			save,36+			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array(2048) }],37+		});38+		inst = await instance(bucketDir);39+		operator = new Browser(inst.app);40+		await operator.signIn();41+		await operator.post("/admin/repos/app", { public: "1" });42+		expect((await inst.app.request(`/app/archive/${save}.tar.gz`)).status).toBe(200);43+		const listed = new Bun.Glob(`.kepr/tarballs/app/*/${save}.tar.gz`).scanSync({44+			cwd: bucketDir,45+			dot: true,46+		});47+		generation = (Array.from(listed)[0] as string).split("/")[3] as string;48+		for (const key of [...orphans, unlisted]) await put(key);49+	});50+51+	test("shows each repo's history, builds and kept tarballs, and the orphans", async () => {52+		const page = await (await operator.get("/admin/storage")).text();53+		expect(page).toMatch(/<td>app<\/td>\s*<td>[\d.]+ K?B<\/td>\s*<td>2\.\d KB<\/td>/);54+		expect(page).toContain(`${save}.tar.gz`);55+		expect(page).toContain("3 orphans");56+	});57+58+	test("deleting orphans removes only Kepr's stale files", async () => {59+		expect((await operator.post("/admin/storage", { action: "delete_orphans" })).status).toBe(303);60+		for (const key of orphans) expect(await exists(key)).toBe(false);61+		expect(await exists(kept())).toBe(true);62+		expect(await exists(unlisted)).toBe(true);63+		expect(await (await operator.get("/admin/storage")).text()).toContain("no orphans");64+	});65+66+	test("one kept tarball can be deleted; any other key is a 404", async () => {67+		const other = { action: "delete_tarball", key: "app/releases/1.0.0/linux-x86_64/app" };68+		expect((await operator.post("/admin/storage", other)).status).toBe(404);69+		expect(await exists("app/releases/1.0.0/linux-x86_64/app")).toBe(true);70+		const response = await operator.post("/admin/storage", {71+			action: "delete_tarball",72+			key: kept(),73+		});74+		expect(response.status).toBe(303);75+		expect(await exists(kept())).toBe(false);76+	});77+});

view file