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 } from "../pages/layout.ts";6import { formToken } from "../server/login.ts";78export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string };910const SECTIONS = [11 ["/admin", "status"],12 ["/admin/repos", "repos"],13 ["/admin/issues", "issues"],14 ["/admin/releases", "releases"],15 ["/admin/site", "site"],16] as const;171819export function formField(c: Context): Html {20 return html`<input type="hidden" name="form" value="${formToken(c)}">`;21}222324export async function textFields(c: Context): Promise<Record<string, string>> {25 const fields: Record<string, string> = {};26 for (const [name, value] of Object.entries(await c.req.parseBody())) {27 if (typeof value === "string") fields[name] = value;28 }29 return fields;30}3132export function notice(error: string): Html | "" {33 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;34}353637export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {38 const tab = ([href, name]: (typeof SECTIONS)[number]) =>39 html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;40 return page(ctx, {41 title: `${title} · admin`,42 description: "",43 path: c.req.path,44 body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">${SECTIONS.map(tab)}${formField(c)}<button class="issues-tab issues-new" type="submit">sign out</button></form>45<h1 class="file-page-title">${title}</h1>46${body}`,47 });48}495051let runningLines: number | null = null;52async function sourceLines(): Promise<number> {53 if (runningLines !== null) return runningLines;54 let total = 0;55 const root = `${import.meta.dir}/../..`;56 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {57 if (path.endsWith(".test.ts")) continue;58 const text = await Bun.file(`${root}/${path}`).text();59 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);60 }61 runningLines = total;62 return total;63}646566export function registerStatus(app: Hono, ctx: AdminContext): void {67 app.get("/admin", async (c) => {68 let bucket: Html;69 try {70 const repos = await listRepos(ctx.sync.bucket);71 const formats = await Promise.all(72 repos.map(async (repo) => {73 try {74 await checkFormat(ctx.sync.bucket, repo);75 return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;76 } catch (error) {77 return html`<li><span class="mono">${repo}</span> <span class="issues-error">${String(error)}</span></li>`;78 }79 }),80 );81 bucket = html`<p>Bucket reachable: ${repos.length} repos.</p><ul>${formats}</ul>`;82 } catch (error) {83 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;84 }85 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {86 version: string;87 };88 const lines = (await sourceLines()).toLocaleString("en-US");89 return c.html(90 adminPage(91 c,92 ctx,93 "status",94 html`<section class="repo-section markdown">${bucket}95<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,96 ),97 );98 });99}