1import type { Context, Hono } from "hono";2import type { Syncer, SyncTarget } from "../bucket/sync.ts";3import { operatorIssues } from "../issues/store.ts";4import { type Html, html } from "../pages/html.ts";5import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";6import { backups } from "../server/backup.ts";7import type { Config } from "../server/config.ts";8import { connectKeys, MACHINES_LISTED } from "../server/connect.ts";9import { setting } from "../server/db.ts";10import { formToken } from "../server/login.ts";11import { backupsPage } from "./backups.ts";12import { allRepos } from "./store.ts";1314export type AdminContext = PageContext & {15 16 sync: SyncTarget;17 18 syncer: Syncer;19 dataDir: string;20 config: Config;21 configPath: string;22};2324const SECTIONS = [25 ["/admin", "overview"],26 ["/admin/repos", "repos"],27 ["/admin/issues", "issues"],28 ["/admin/downloads", "downloads"],29 ["/admin/storage", "storage"],30 ["/admin/notifications", "notifications"],31 ["/admin/email", "email"],32 ["/admin/bucket", "bucket"],33 ["/admin?section=backups", "backups"],34 ["/admin/site?section=profile", "profile"],35 ["/admin/site", "site"],36 ["/admin/account", "account"],37] as const;383940export function formField(c: Context): Html {41 return html`<input type="hidden" name="form" value="${formToken(c)}">`;42}434445export async function textFields(c: Context): Promise<Record<string, string>> {46 const fields: Record<string, string> = {};47 for (const [name, value] of Object.entries(await c.req.parseBody())) {48 if (typeof value === "string") fields[name] = value;49 }50 return fields;51}5253export function notice(error: string): Html | "" {54 return error && html`<p class="issues-error mono" role="alert">${error}</p>`;55}565758export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {59 const tab = ([href, name]: (typeof SECTIONS)[number]) =>60 html`<a href="${href}"${61 (62 c.req.path === "/admin"63 ? (c.req.query("section") === "backups" ? "backups" : "overview") === name64 : c.req.path === "/admin/site"65 ? (c.req.query("section") === "profile" ? "profile" : "site") === name66 : c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))67 )68 ? html` aria-current="page"`69 : ""70 }>${name}${71 name === "notifications" && unread > 072 ? html` <span class="admin-unread">${String(unread)}</span>`73 : ""74 }</a>`;75 const unread =76 ctx.kepr77 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")78 .get()?.n ?? 0;79 const handle = setting(ctx.kepr, "handle");80 return page(ctx, {81 title: `${title} · admin`,82 description: "",83 path: c.req.path,84 body: html`<div class="admin-layout">85<aside class="admin-sidebar mono">86<span class="section-label">workspace</span>87<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>88<div class="admin-sidebar-foot">89${handle && html`<a href="/@${handle}">view profile ↗</a>`}90<a href="/">view site ↗</a>91<form class="nav-logout" method="post" action="/admin/sign-out">92${formField(c)}<button type="submit">log out</button>93</form></div></aside>94<section class="admin-content">95<header class="admin-heading"><span class="section-label mono">operator</span>96<h1>${title}</h1></header>97${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}98${body}</section></div>`,99 });100}101102103let runningLines: number | null = null;104async function sourceLines(): Promise<number> {105 if (runningLines !== null) return runningLines;106 let total = 0;107 const root = `${import.meta.dir}/../..`;108 for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {109 if (path.endsWith(".test.ts")) continue;110 const text = await Bun.file(`${root}/${path}`).text();111 total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);112 }113 runningLines = total;114 return total;115}116117118function connectedMachines(ctx: AdminContext): Html | "" {119 const rows = ctx.kepr120 .query<{ machine: string; used_at: number }, [number]>(121 `SELECT machine, used_at FROM connect_codes122 WHERE used_at IS NOT NULL123 ORDER BY used_at DESC, rowid DESC124 LIMIT ?`,125 )126 .all(MACHINES_LISTED);127 if (rows.length === 0) return "";128 const led = rows.map((row) => {129 const at = new Date(row.used_at * 1000).toISOString();130 return html`<li>${row.machine} <span class="muted">${at}</span></li>`;131 });132 return html`<h2>Machines connected</h2>133<ul>${led}</ul>134<p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;135}136137138139140141function bucketStatus(ctx: AdminContext): Html {142 const error = setting(ctx.kepr, "bucket_error");143 if (error) {144 return html`<p class="issues-error" role="alert">Bucket unreachable at the last sync: ${error}</p>`;145 }146 const readAt = Number(setting(ctx.kepr, "bucket_read_at") ?? 0);147 if (readAt === 0) return html`<p class="muted">The sync has not read the bucket yet.</p>`;148 149 const seen = JSON.parse(setting(ctx.kepr, "bucket_repos") ?? "[]") as string[];150 const errors = new Map(allRepos(ctx.kepr).map((repo) => [repo.name, repo.last_sync_error]));151 return html`<p>Bucket read by the sync ${relativeTime(readAt)}: ${plural(seen.length, "repo")}.</p>152<ul>${seen.map((name) => {153 const failure = errors.get(name);154 return html`<li><span class="mono">${name}</span> ${155 failure156 ? html`<span class="issues-error">${failure}</span>`157 : html`<span class="muted">format ok</span>`158 }</li>`;159 })}</ul>`;160}161162163export function registerStatus(app: Hono, ctx: AdminContext): void {164 app.get("/admin", async (c) => {165 if (c.req.query("section") === "backups") return c.html(await backupsPage(c, ctx));166 const bucket = bucketStatus(ctx);167 const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {168 version: string;169 };170 const lines = (await sourceLines()).toLocaleString("en-US");171 const newest = backups(ctx.dataDir).at(-1);172 const repos = allRepos(ctx.kepr);173 const open = operatorIssues(ctx.kepr, "open").length;174 const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);175 const vital = (label: string, value: number, href: string) =>176 html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;177 const overview = html`<div class="admin-vitals">178${vital("repositories", repos.length, "/admin/repos")}179${vital("public", repos.filter((repo) => repo.public).length, "/browse")}180${vital("open issues", open, "/admin/issues")}181</div>182<section class="repo-section"><h2 class="section-label mono">needs attention</h2>183${attention.map(184 (repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">185<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,186)}187${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}188${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}189${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}190</section>191<section class="repo-section"><h2 class="section-label mono">repositories</h2>192${repos193 .slice()194 .sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))195 .slice(0, 8)196 .map(197 (repo) => html`198<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>199<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,200 )}201${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}202</section>`;203 return c.html(204 adminPage(205 c,206 ctx,207 "overview",208 html`${overview}<section class="repo-section markdown">209<p><a href="/admin?section=backups">manage backups →</a></p>210<details><summary class="mono">bucket and runtime</summary>${bucket}211${connectKeys(ctx.config) ? connectedMachines(ctx) : ""}212<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,213 ),214 );215 });216}