console.ts

8.8 KB · raw

1import type { Context, Hono } from "hono";2import type { 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	sync: SyncTarget;16	dataDir: string;17	config: Config;18	configPath: string;19};2021const SECTIONS = [22	["/admin", "overview"],23	["/admin/repos", "repos"],24	["/admin/issues", "issues"],25	["/admin/downloads", "downloads"],26	["/admin/storage", "storage"],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	["/admin/account", "account"],34] as const;3536/** The hidden same-site token (§25) that every admin form posts back. */37export function formField(c: Context): Html {38	return html`<input type="hidden" name="form" value="${formToken(c)}">`;39}4041/** A posted form's text fields; the guard has already parsed and checked the body. */42export async function textFields(c: Context): Promise<Record<string, string>> {43	const fields: Record<string, string> = {};44	for (const [name, value] of Object.entries(await c.req.parseBody())) {45		if (typeof value === "string") fields[name] = value;46	}47	return fields;48}4950export function notice(error: string): Html | "" {51	return error && html`<p class="issues-error mono" role="alert">${error}</p>`;52}5354/** An admin page: the site's own look, with the console's sections and sign-out on top. */55export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {56	const tab = ([href, name]: (typeof SECTIONS)[number]) =>57		html`<a href="${href}"${58			(59				c.req.path === "/admin"60					? (c.req.query("section") === "backups" ? "backups" : "overview") === name61					: c.req.path === "/admin/site"62						? (c.req.query("section") === "profile" ? "profile" : "site") === name63						: c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))64			)65				? html` aria-current="page"`66				: ""67		}>${name}${68			name === "notifications" && unread > 069				? html` <span class="admin-unread">${String(unread)}</span>`70				: ""71		}</a>`;72	const unread =73		ctx.kepr74			.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")75			.get()?.n ?? 0;76	const handle = setting(ctx.kepr, "handle");77	return page(ctx, {78		title: `${title} · admin`,79		description: "",80		path: c.req.path,81		body: html`<div class="admin-layout">82<aside class="admin-sidebar mono">83<span class="section-label">workspace</span>84<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>85<div class="admin-sidebar-foot">86${handle && html`<a href="/@${handle}">view profile ↗</a>`}87<a href="/">view site ↗</a>88<form class="nav-logout" method="post" action="/admin/sign-out">89${formField(c)}<button type="submit">log out</button>90</form></div></aside>91<section class="admin-content">92<header class="admin-heading"><span class="section-label mono">operator</span>93<h1>${title}</h1></header>94${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}95${body}</section></div>`,96	});97}9899// ponytail: counted once per process by walking the source, since there is no build step.100let runningLines: number | null = null;101async function sourceLines(): Promise<number> {102	if (runningLines !== null) return runningLines;103	let total = 0;104	const root = `${import.meta.dir}/../..`;105	for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {106		if (path.endsWith(".test.ts")) continue;107		const text = await Bun.file(`${root}/${path}`).text();108		total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);109	}110	runningLines = total;111	return total;112}113114/** The last machines that redeemed a code, and how to take their access away again. */115function connectedMachines(ctx: AdminContext): Html | "" {116	const rows = ctx.kepr117		.query<{ machine: string; used_at: number }, [number]>(118			`SELECT machine, used_at FROM connect_codes119			 WHERE used_at IS NOT NULL120			 ORDER BY used_at DESC, rowid DESC121			 LIMIT ?`,122		)123		.all(MACHINES_LISTED);124	if (rows.length === 0) return "";125	const led = rows.map((row) => {126		const at = new Date(row.used_at * 1000).toISOString();127		return html`<li>${row.machine} <span class="muted">${at}</span></li>`;128	});129	return html`<h2>Machines connected</h2>130<ul>${led}</ul>131<p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;132}133134/**135 * The bucket as the sync last read it, and each repo's format and sync result: the sync checks the136 * format of every repo on every pass, so the console never waits on the bucket itself.137 */138function bucketStatus(ctx: AdminContext): Html {139	const error = setting(ctx.kepr, "bucket_error");140	if (error) {141		return html`<p class="issues-error" role="alert">Bucket unreachable at the last sync: ${error}</p>`;142	}143	const readAt = Number(setting(ctx.kepr, "bucket_read_at") ?? 0);144	if (readAt === 0) return html`<p class="muted">The sync has not read the bucket yet.</p>`;145	// The repos that listing found; Kepr keeps the rows of repos gone from the bucket.146	const seen = JSON.parse(setting(ctx.kepr, "bucket_repos") ?? "[]") as string[];147	const errors = new Map(allRepos(ctx.kepr).map((repo) => [repo.name, repo.last_sync_error]));148	return html`<p>Bucket read by the sync ${relativeTime(readAt)}: ${plural(seen.length, "repo")}.</p>149<ul>${seen.map((name) => {150		const failure = errors.get(name);151		return html`<li><span class="mono">${name}</span> ${152			failure153				? html`<span class="issues-error">${failure}</span>`154				: html`<span class="muted">format ok</span>`155		}</li>`;156	})}</ul>`;157}158159/** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */160export function registerStatus(app: Hono, ctx: AdminContext): void {161	app.get("/admin", async (c) => {162		if (c.req.query("section") === "backups") return c.html(await backupsPage(c, ctx));163		const bucket = bucketStatus(ctx);164		const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {165			version: string;166		};167		const lines = (await sourceLines()).toLocaleString("en-US");168		const newest = backups(ctx.dataDir).at(-1);169		const repos = allRepos(ctx.kepr);170		const open = operatorIssues(ctx.kepr, "open").length;171		const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);172		const vital = (label: string, value: number, href: string) =>173			html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;174		const overview = html`<div class="admin-vitals">175${vital("repositories", repos.length, "/admin/repos")}176${vital("public", repos.filter((repo) => repo.public).length, "/browse")}177${vital("open issues", open, "/admin/issues")}178</div>179<section class="repo-section"><h2 class="section-label mono">needs attention</h2>180${attention.map(181	(repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">182<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,183)}184${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}185${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}186${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}187</section>188<section class="repo-section"><h2 class="section-label mono">repositories</h2>189${repos190	.slice()191	.sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))192	.slice(0, 8)193	.map(194		(repo) => html`195<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>196<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,197	)}198${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}199</section>`;200		return c.html(201			adminPage(202				c,203				ctx,204				"overview",205				html`${overview}<section class="repo-section markdown">206<p><a href="/admin?section=backups">manage backups →</a></p>207<details><summary class="mono">bucket and runtime</summary>${bucket}208${connectKeys(ctx.config) ? connectedMachines(ctx) : ""}209<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,210			),211		);212	});213}