import type { Context, Hono } from "hono"; import { checkFormat, listRepos } from "../bucket/repo.ts"; import type { SyncTarget } from "../bucket/sync.ts"; import { operatorIssues } from "../issues/store.ts"; import { type Html, html } from "../pages/html.ts"; import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts"; import { backups } from "../server/backup.ts"; import type { Config } from "../server/config.ts"; import { connectKeys, MACHINES_LISTED } from "../server/connect.ts"; import { setting } from "../server/db.ts"; import { formToken } from "../server/login.ts"; import { backupsPage } from "./backups.ts"; import { allRepos } from "./store.ts"; export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; config: Config; configPath: string; }; const SECTIONS = [ ["/admin", "overview"], ["/admin/repos", "repos"], ["/admin/issues", "issues"], ["/admin/notifications", "notifications"], ["/admin/email", "email"], ["/admin/bucket", "bucket"], ["/admin?section=backups", "backups"], ["/admin/site?section=profile", "profile"], ["/admin/site", "site"], ["/admin/account", "account"], ] as const; /** The hidden same-site token (§25) that every admin form posts back. */ export function formField(c: Context): Html { return html``; } /** A posted form's text fields; the guard has already parsed and checked the body. */ export async function textFields(c: Context): Promise> { const fields: Record = {}; for (const [name, value] of Object.entries(await c.req.parseBody())) { if (typeof value === "string") fields[name] = value; } return fields; } export function notice(error: string): Html | "" { return error && html``; } /** An admin page: the site's own look, with the console's sections and sign-out on top. */ export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string { const tab = ([href, name]: (typeof SECTIONS)[number]) => html`${name}${ name === "notifications" && unread > 0 ? html` ${String(unread)}` : "" }`; const unread = ctx.kepr .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0") .get()?.n ?? 0; const handle = setting(ctx.kepr, "handle"); return page(ctx, { title: `${title} · admin`, description: "", path: c.req.path, body: html`

${title}

${c.req.query("saved") === "1" && html`

Changes saved.

`} ${body}
`, }); } // ponytail: counted once per process by walking the source, since there is no build step. let runningLines: number | null = null; async function sourceLines(): Promise { if (runningLines !== null) return runningLines; let total = 0; const root = `${import.meta.dir}/../..`; for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) { if (path.endsWith(".test.ts")) continue; const text = await Bun.file(`${root}/${path}`).text(); total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0); } runningLines = total; return total; } /** The last machines that redeemed a code, and how to take their access away again. */ function connectedMachines(ctx: AdminContext): Html | "" { const rows = ctx.kepr .query<{ machine: string; used_at: number }, [number]>( `SELECT machine, used_at FROM connect_codes WHERE used_at IS NOT NULL ORDER BY used_at DESC, rowid DESC LIMIT ?`, ) .all(MACHINES_LISTED); if (rows.length === 0) return ""; const led = rows.map((row) => { const at = new Date(row.used_at * 1000).toISOString(); return html`
  • ${row.machine} ${at}
  • `; }); return html`

    Machines connected

      ${led}

    To revoke access, rotate the write key in Scaleway and update it under bucket.

    `; } /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */ export function registerStatus(app: Hono, ctx: AdminContext): void { app.get("/admin", async (c) => { if (c.req.query("section") === "backups") return c.html(await backupsPage(c, ctx)); let bucket: Html; try { const repos = await listRepos(ctx.sync.bucket); const formats = await Promise.all( repos.map(async (repo) => { try { await checkFormat(ctx.sync.bucket, repo); return html`
  • ${repo} format ok
  • `; } catch (error) { return html`
  • ${repo} ${String(error)}
  • `; } }), ); bucket = html`

    Bucket reachable: ${plural(repos.length, "repo")}.

      ${formats}
    `; } catch (error) { bucket = html``; } const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as { version: string; }; const lines = (await sourceLines()).toLocaleString("en-US"); const newest = backups(ctx.dataDir).at(-1); const repos = allRepos(ctx.kepr); const open = operatorIssues(ctx.kepr, "open").length; const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null); const vital = (label: string, value: number, href: string) => html`${value}${label}`; const overview = html`
    ${vital("repositories", repos.length, "/admin/repos")} ${vital("public", repos.filter((repo) => repo.public).length, "/browse")} ${vital("open issues", open, "/admin/issues")}
    ${attention.map( (repo) => html` ${repo.name}${repo.last_sync_error ?? "Not synced yet"}`, )} ${!newest && html`

    No backup yet. Check the server log for backup errors.

    `} ${!setting(ctx.kepr, "handle") && html`

    Set your handle to publish your profile →

    `} ${attention.length === 0 && newest && html`

    All repositories are synced. A local backup is available.

    `}
    ${repos .slice() .sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0)) .slice(0, 8) .map( (repo) => html` ${repo.name} ${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}`, )} ${repos.length === 0 && html`

    No repositories yet. Offer one with Koh, then sync it here.

    `}
    `; return c.html( adminPage( c, ctx, "overview", html`${overview}

    manage backups →

    bucket and runtime${bucket} ${connectKeys(ctx.config) ? connectedMachines(ctx) : ""}

    Running kepr ${version}: ${lines} lines of TypeScript.

    `, ), ); }); }