1import type { Context, Hono } from "hono";2import { checkFormat, listRepos } from "../bucket/repo.ts";3import type { SyncTarget } from "../bucket/sync.ts";4import { operatorIssues } from "../issues/store.ts";5import { type Html, html } from "../pages/html.ts";6import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";7import { backups, newestBackupTarball } from "../server/backup.ts";8import { MACHINES_LISTED } from "../server/connect.ts";9import { setting } from "../server/db.ts";10import { formToken } from "../server/login.ts";11import { allRepos } from "./store.ts";1213export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };1415const SECTIONS = [16 ["/admin", "overview"],17 ["/admin/repos", "repos"],18 ["/admin/issues", "issues"],19 ["/admin/releases", "releases"],20 ["/admin/site?section=profile", "profile"],21 ["/admin/site", "site"],22] as const;232425export function formField(c: Context): Html {26 return html`<input type="hidden" name="form" value="${formToken(c)}">`;27}282930export async function textFields(c: Context): Promise<Record<string, string>> {31 const fields: Record<string, string> = {};32 for (const [name, value] of Object.entries(await c.req.parseBody())) {33 if (typeof value === "string") fields[name] = value;34 }35 return fields;36}3738export function notice(error: string): Html | "" {39 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;40}414243export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {44 const tab = ([href, name]: (typeof SECTIONS)[number]) =>45 html`<a href="${href}"${46 (47 c.req.path === "/admin/site"48 ? (c.req.query("section") === "profile" ? "profile" : "site") === name49 : c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))50 )51 ? html` aria-current="page"`52 : ""53 }>${name}</a>`;54 const handle = setting(ctx.kepr, "handle");55 return page(ctx, {56 title: `${title} · admin`,57 description: "",58 path: c.req.path,59 body: html`<div class="admin-layout">60<aside class="admin-sidebar mono">61<span class="section-label">workspace</span>62<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>63<div class="admin-sidebar-foot">64${handle && html`<a href="/@${handle}">view profile ↗</a>`}65<a href="/">view site ↗</a>66<form class="nav-logout" method="post" action="/admin/sign-out">67${formField(c)}<button type="submit">log out</button>68</form></div></aside>69<section class="admin-content">70<header class="admin-heading"><span class="section-label mono">operator</span>71<h1>${title}</h1></header>72${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}73${body}</section></div>`,74 });75}767778let runningLines: number | null = null;79async function sourceLines(): Promise<number> {80 if (runningLines !== null) return runningLines;81 let total = 0;82 const root = `${import.meta.dir}/../..`;83 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {84 if (path.endsWith(".test.ts")) continue;85 const text = await Bun.file(`${root}/${path}`).text();86 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);87 }88 runningLines = total;89 return total;90}919293function connectedMachines(ctx: AdminContext): Html | "" {94 const rows = ctx.kepr95 .query<{ machine: string; used_at: number }, [number]>(96 `SELECT machine, used_at FROM connect_codes97 WHERE used_at IS NOT NULL98 ORDER BY used_at DESC, rowid DESC99 LIMIT ?`,100 )101 .all(MACHINES_LISTED);102 if (rows.length === 0) return "";103 const led = rows.map((row) => {104 const at = new Date(row.used_at * 1000).toISOString();105 return html`<li>${row.machine} <span class="muted">${at}</span></li>`;106 });107 return html`<h2>Machines connected</h2>108<ul>${led}</ul>109<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;110}111112113export function registerStatus(app: Hono, ctx: AdminContext): void {114 app.get("/admin", async (c) => {115 let bucket: Html;116 try {117 const repos = await listRepos(ctx.sync.bucket);118 const formats = await Promise.all(119 repos.map(async (repo) => {120 try {121 await checkFormat(ctx.sync.bucket, repo);122 return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;123 } catch (error) {124 return html`<li><span class="mono">${repo}</span>125<span class="issues-error">${String(error)}</span></li>`;126 }127 }),128 );129 bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;130 } catch (error) {131 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;132 }133 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {134 version: string;135 };136 const lines = (await sourceLines()).toLocaleString("en-US");137 const newest = backups(ctx.dataDir).at(-1);138 const repos = allRepos(ctx.kepr);139 const open = operatorIssues(ctx.kepr, "open").length;140 const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);141 const vital = (label: string, value: number, href: string) =>142 html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;143 const overview = html`<div class="admin-vitals">144${vital("repositories", repos.length, "/admin/repos")}145${vital("public", repos.filter((repo) => repo.public).length, "/browse")}146${vital("open issues", open, "/admin/issues")}147</div>148<section class="repo-section"><h2 class="section-label mono">needs attention</h2>149${attention.map(150 (repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">151<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,152)}153${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}154${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}155${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}156</section>157<section class="repo-section"><h2 class="section-label mono">repositories</h2>158${repos159 .slice()160 .sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))161 .slice(0, 8)162 .map(163 (repo) => html`164<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>165<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,166 )}167${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}168</section>`;169 const backup = newest170 ? html`<p>Last backup: ${newest.replace(171 /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,172 "$1-$2-$3 $4:$5:$6 UTC",173 )}. <a href="/admin/backup">download backup</a></p>`174 : html`<p>No backup yet.</p>`;175 return c.html(176 adminPage(177 c,178 ctx,179 "overview",180 html`${overview}<section class="repo-section markdown">181<h2 class="section-label mono">backup</h2>${backup}182<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>183<details><summary class="mono">bucket and runtime</summary>${bucket}184${ctx.canConnect ? connectedMachines(ctx) : ""}185<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,186 ),187 );188 });189190 app.get("/admin/backup", (c) => {191 const backup = newestBackupTarball(ctx.dataDir);192 if (!backup) return c.notFound();193 return c.body(backup.bytes, 200, {194 "Content-Type": "application/gzip",195 "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,196 });197 });198}