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 } from "../server/backup.ts";8import type { Config } from "../server/config.ts";9import { connectKeys, MACHINES_LISTED } from "../server/connect.ts";10import { setting } from "../server/db.ts";11import { formToken } from "../server/login.ts";12import { backupsPage } from "./backups.ts";13import { allRepos } from "./store.ts";1415export type AdminContext = PageContext & {16 sync: SyncTarget;17 dataDir: string;18 config: Config;19 configPath: string;20};2122const SECTIONS = [23 ["/admin", "overview"],24 ["/admin/repos", "repos"],25 ["/admin/issues", "issues"],26 ["/admin/releases", "releases"],27 ["/admin/notifications", "notifications"],28 ["/admin/email", "email"],29 ["/admin/bucket", "bucket"],30 ["/admin?section=backups", "backups"],31 ["/admin/site?section=profile", "profile"],32 ["/admin/site", "site"],33] as const;343536export function formField(c: Context): Html {37 return html`<input type="hidden" name="form" value="${formToken(c)}">`;38}394041export async function textFields(c: Context): Promise<Record<string, string>> {42 const fields: Record<string, string> = {};43 for (const [name, value] of Object.entries(await c.req.parseBody())) {44 if (typeof value === "string") fields[name] = value;45 }46 return fields;47}4849export function notice(error: string): Html | "" {50 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;51}525354export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {55 const tab = ([href, name]: (typeof SECTIONS)[number]) =>56 html`<a href="${href}"${57 (58 c.req.path === "/admin"59 ? (c.req.query("section") === "backups" ? "backups" : "overview") === name60 : c.req.path === "/admin/site"61 ? (c.req.query("section") === "profile" ? "profile" : "site") === name62 : c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))63 )64 ? html` aria-current="page"`65 : ""66 }>${name}${67 name === "notifications" && unread > 068 ? html` <span class="admin-unread">${String(unread)}</span>`69 : ""70 }</a>`;71 const unread =72 ctx.kepr73 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")74 .get()?.n ?? 0;75 const handle = setting(ctx.kepr, "handle");76 return page(ctx, {77 title: `${title} · admin`,78 description: "",79 path: c.req.path,80 body: html`<div class="admin-layout">81<aside class="admin-sidebar mono">82<span class="section-label">workspace</span>83<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>84<div class="admin-sidebar-foot">85${handle && html`<a href="/@${handle}">view profile ↗</a>`}86<a href="/">view site ↗</a>87<form class="nav-logout" method="post" action="/admin/sign-out">88${formField(c)}<button type="submit">log out</button>89</form></div></aside>90<section class="admin-content">91<header class="admin-heading"><span class="section-label mono">operator</span>92<h1>${title}</h1></header>93${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}94${body}</section></div>`,95 });96}979899let runningLines: number | null = null;100async function sourceLines(): Promise<number> {101 if (runningLines !== null) return runningLines;102 let total = 0;103 const root = `${import.meta.dir}/../..`;104 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {105 if (path.endsWith(".test.ts")) continue;106 const text = await Bun.file(`${root}/${path}`).text();107 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);108 }109 runningLines = total;110 return total;111}112113114function connectedMachines(ctx: AdminContext): Html | "" {115 const rows = ctx.kepr116 .query<{ machine: string; used_at: number }, [number]>(117 `SELECT machine, used_at FROM connect_codes118 WHERE used_at IS NOT NULL119 ORDER BY used_at DESC, rowid DESC120 LIMIT ?`,121 )122 .all(MACHINES_LISTED);123 if (rows.length === 0) return "";124 const led = rows.map((row) => {125 const at = new Date(row.used_at * 1000).toISOString();126 return html`<li>${row.machine} <span class="muted">${at}</span></li>`;127 });128 return html`<h2>Machines connected</h2>129<ul>${led}</ul>130<p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;131}132133134export function registerStatus(app: Hono, ctx: AdminContext): void {135 app.get("/admin", async (c) => {136 if (c.req.query("section") === "backups") return c.html(backupsPage(c, ctx));137 let bucket: Html;138 try {139 const repos = await listRepos(ctx.sync.bucket);140 const formats = await Promise.all(141 repos.map(async (repo) => {142 try {143 await checkFormat(ctx.sync.bucket, repo);144 return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;145 } catch (error) {146 return html`<li><span class="mono">${repo}</span>147<span class="issues-error">${String(error)}</span></li>`;148 }149 }),150 );151 bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;152 } catch (error) {153 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;154 }155 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {156 version: string;157 };158 const lines = (await sourceLines()).toLocaleString("en-US");159 const newest = backups(ctx.dataDir).at(-1);160 const repos = allRepos(ctx.kepr);161 const open = operatorIssues(ctx.kepr, "open").length;162 const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);163 const vital = (label: string, value: number, href: string) =>164 html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;165 const overview = html`<div class="admin-vitals">166${vital("repositories", repos.length, "/admin/repos")}167${vital("public", repos.filter((repo) => repo.public).length, "/browse")}168${vital("open issues", open, "/admin/issues")}169</div>170<section class="repo-section"><h2 class="section-label mono">needs attention</h2>171${attention.map(172 (repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">173<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,174)}175${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}176${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}177${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}178</section>179<section class="repo-section"><h2 class="section-label mono">repositories</h2>180${repos181 .slice()182 .sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))183 .slice(0, 8)184 .map(185 (repo) => html`186<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>187<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,188 )}189${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}190</section>`;191 return c.html(192 adminPage(193 c,194 ctx,195 "overview",196 html`${overview}<section class="repo-section markdown">197<p><a href="/admin?section=backups">manage backups →</a></p>198<details><summary class="mono">bucket and runtime</summary>${bucket}199${connectKeys(ctx.config) ? connectedMachines(ctx) : ""}200<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,201 ),202 );203 });204}