1import type { Context, Hono } from "hono";2import { checkFormat, listRepos } from "../bucket/repo.ts";3import type { SyncTarget } from "../bucket/sync.ts";4import { type Html, html } from "../pages/html.ts";5import { type PageContext, page, plural } from "../pages/layout.ts";6import { backups, newestBackupTarball } from "../server/backup.ts";7import { formToken } from "../server/login.ts";89export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string };1011const SECTIONS = [12 ["/admin", "status"],13 ["/admin/repos", "repos"],14 ["/admin/issues", "issues"],15 ["/admin/releases", "releases"],16 ["/admin/site", "site"],17] as const;181920export function formField(c: Context): Html {21 return html`<input type="hidden" name="form" value="${formToken(c)}">`;22}232425export async function textFields(c: Context): Promise<Record<string, string>> {26 const fields: Record<string, string> = {};27 for (const [name, value] of Object.entries(await c.req.parseBody())) {28 if (typeof value === "string") fields[name] = value;29 }30 return fields;31}3233export function notice(error: string): Html | "" {34 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;35}363738export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {39 const tab = ([href, name]: (typeof SECTIONS)[number]) =>40 html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;41 return page(ctx, {42 title: `${title} · admin`,43 description: "",44 path: c.req.path,45 body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">46${SECTIONS.map(tab)}${formField(c)}47<button class="issues-tab issues-new" type="submit">sign out</button>48</form>49<h1 class="file-page-title">${title}</h1>50${body}`,51 });52}535455let runningLines: number | null = null;56async function sourceLines(): Promise<number> {57 if (runningLines !== null) return runningLines;58 let total = 0;59 const root = `${import.meta.dir}/../..`;60 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {61 if (path.endsWith(".test.ts")) continue;62 const text = await Bun.file(`${root}/${path}`).text();63 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);64 }65 runningLines = total;66 return total;67}686970export function registerStatus(app: Hono, ctx: AdminContext): void {71 app.get("/admin", async (c) => {72 let bucket: Html;73 try {74 const repos = await listRepos(ctx.sync.bucket);75 const formats = await Promise.all(76 repos.map(async (repo) => {77 try {78 await checkFormat(ctx.sync.bucket, repo);79 return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;80 } catch (error) {81 return html`<li><span class="mono">${repo}</span>82<span class="issues-error">${String(error)}</span></li>`;83 }84 }),85 );86 bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;87 } catch (error) {88 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;89 }90 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {91 version: string;92 };93 const lines = (await sourceLines()).toLocaleString("en-US");94 const newest = backups(ctx.dataDir).at(-1);95 const backup = newest96 ? html`<p>Last backup: ${newest.replace(97 /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,98 "$1-$2-$3 $4:$5:$6 UTC",99 )}. <a href="/admin/backup">download backup</a></p>`100 : html`<p>No backup yet.</p>`;101 return c.html(102 adminPage(103 c,104 ctx,105 "status",106 html`<section class="repo-section markdown">${bucket}107${backup}108<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,109 ),110 );111 });112113 app.get("/admin/backup", (c) => {114 const backup = newestBackupTarball(ctx.dataDir);115 if (!backup) return c.notFound();116 return c.body(backup.bytes, 200, {117 "Content-Type": "application/gzip",118 "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,119 });120 });121}