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 { MACHINES_LISTED } from "../server/connect.ts";8import { formToken } from "../server/login.ts";910export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };1112const SECTIONS = [13 ["/admin", "status"],14 ["/admin/repos", "repos"],15 ["/admin/issues", "issues"],16 ["/admin/releases", "releases"],17 ["/admin/site", "site"],18] as const;192021export function formField(c: Context): Html {22 return html`<input type="hidden" name="form" value="${formToken(c)}">`;23}242526export async function textFields(c: Context): Promise<Record<string, string>> {27 const fields: Record<string, string> = {};28 for (const [name, value] of Object.entries(await c.req.parseBody())) {29 if (typeof value === "string") fields[name] = value;30 }31 return fields;32}3334export function notice(error: string): Html | "" {35 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;36}373839export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {40 const tab = ([href, name]: (typeof SECTIONS)[number]) =>41 html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;42 return page(ctx, {43 title: `${title} · admin`,44 description: "",45 path: c.req.path,46 body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">47${SECTIONS.map(tab)}${formField(c)}48<button class="issues-tab issues-new" type="submit">sign out</button>49</form>50<h1 class="file-page-title">${title}</h1>51${body}`,52 });53}545556let runningLines: number | null = null;57async function sourceLines(): Promise<number> {58 if (runningLines !== null) return runningLines;59 let total = 0;60 const root = `${import.meta.dir}/../..`;61 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {62 if (path.endsWith(".test.ts")) continue;63 const text = await Bun.file(`${root}/${path}`).text();64 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);65 }66 runningLines = total;67 return total;68}697071function connectedMachines(ctx: AdminContext): Html | "" {72 const rows = ctx.kepr73 .query<{ machine: string; used_at: number }, [number]>(74 `SELECT machine, used_at FROM connect_codes75 WHERE used_at IS NOT NULL76 ORDER BY used_at DESC, rowid DESC77 LIMIT ?`,78 )79 .all(MACHINES_LISTED);80 if (rows.length === 0) return "";81 const led = rows.map((row) => {82 const at = new Date(row.used_at * 1000).toISOString();83 return html`<li>${row.machine} <span class="muted">${at}</span></li>`;84 });85 return html`<h2>Machines connected</h2>86<ul>${led}</ul>87<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;88}899091export function registerStatus(app: Hono, ctx: AdminContext): void {92 app.get("/admin", async (c) => {93 let bucket: Html;94 try {95 const repos = await listRepos(ctx.sync.bucket);96 const formats = await Promise.all(97 repos.map(async (repo) => {98 try {99 await checkFormat(ctx.sync.bucket, repo);100 return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;101 } catch (error) {102 return html`<li><span class="mono">${repo}</span>103<span class="issues-error">${String(error)}</span></li>`;104 }105 }),106 );107 bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;108 } catch (error) {109 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;110 }111 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {112 version: string;113 };114 const lines = (await sourceLines()).toLocaleString("en-US");115 const newest = backups(ctx.dataDir).at(-1);116 const backup = newest117 ? html`<p>Last backup: ${newest.replace(118 /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,119 "$1-$2-$3 $4:$5:$6 UTC",120 )}. <a href="/admin/backup">download backup</a></p>`121 : html`<p>No backup yet.</p>`;122 return c.html(123 adminPage(124 c,125 ctx,126 "status",127 html`<section class="repo-section markdown">${bucket}128${ctx.canConnect ? connectedMachines(ctx) : ""}129${backup}130<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,131 ),132 );133 });134135 app.get("/admin/backup", (c) => {136 const backup = newestBackupTarball(ctx.dataDir);137 if (!backup) return c.notFound();138 return c.body(backup.bytes, 200, {139 "Content-Type": "application/gzip",140 "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,141 });142 });143}