console.ts

8.6 KB · raw

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?section=backups", "backups"],21	["/admin/site?section=profile", "profile"],22	["/admin/site", "site"],23] as const;2425/** The hidden same-site token (§25) that every admin form posts back. */26export function formField(c: Context): Html {27	return html`<input type="hidden" name="form" value="${formToken(c)}">`;28}2930/** A posted form's text fields; the guard has already parsed and checked the body. */31export async function textFields(c: Context): Promise<Record<string, string>> {32	const fields: Record<string, string> = {};33	for (const [name, value] of Object.entries(await c.req.parseBody())) {34		if (typeof value === "string") fields[name] = value;35	}36	return fields;37}3839export function notice(error: string): Html | "" {40	return error && html`<p class="issues-error mono" role="alert">${error}</p>`;41}4243/** An admin page: the site's own look, with the console's sections and sign-out on top. */44export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {45	const tab = ([href, name]: (typeof SECTIONS)[number]) =>46		html`<a href="${href}"${47			(48				c.req.path === "/admin"49					? (c.req.query("section") === "backups" ? "backups" : "overview") === name50					: c.req.path === "/admin/site"51						? (c.req.query("section") === "profile" ? "profile" : "site") === name52						: c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))53			)54				? html` aria-current="page"`55				: ""56		}>${name}</a>`;57	const handle = setting(ctx.kepr, "handle");58	return page(ctx, {59		title: `${title} · admin`,60		description: "",61		path: c.req.path,62		body: html`<div class="admin-layout">63<aside class="admin-sidebar mono">64<span class="section-label">workspace</span>65<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>66<div class="admin-sidebar-foot">67${handle && html`<a href="/@${handle}">view profile ↗</a>`}68<a href="/">view site ↗</a>69<form class="nav-logout" method="post" action="/admin/sign-out">70${formField(c)}<button type="submit">log out</button>71</form></div></aside>72<section class="admin-content">73<header class="admin-heading"><span class="section-label mono">operator</span>74<h1>${title}</h1></header>75${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}76${body}</section></div>`,77	});78}7980// ponytail: counted once per process by walking the source, since there is no build step.81let runningLines: number | null = null;82async function sourceLines(): Promise<number> {83	if (runningLines !== null) return runningLines;84	let total = 0;85	const root = `${import.meta.dir}/../..`;86	for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {87		if (path.endsWith(".test.ts")) continue;88		const text = await Bun.file(`${root}/${path}`).text();89		total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);90	}91	runningLines = total;92	return total;93}9495/** The last machines that redeemed a code, and how to take their access away again. */96function connectedMachines(ctx: AdminContext): Html | "" {97	const rows = ctx.kepr98		.query<{ machine: string; used_at: number }, [number]>(99			`SELECT machine, used_at FROM connect_codes100			 WHERE used_at IS NOT NULL101			 ORDER BY used_at DESC, rowid DESC102			 LIMIT ?`,103		)104		.all(MACHINES_LISTED);105	if (rows.length === 0) return "";106	const led = rows.map((row) => {107		const at = new Date(row.used_at * 1000).toISOString();108		return html`<li>${row.machine} <span class="muted">${at}</span></li>`;109	});110	return html`<h2>Machines connected</h2>111<ul>${led}</ul>112<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;113}114115/** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */116export function registerStatus(app: Hono, ctx: AdminContext): void {117	app.get("/admin", async (c) => {118		if (c.req.query("section") === "backups") {119			const newest = backups(ctx.dataDir).at(-1);120			const backup = newest121				? html`<p>Last backup: ${newest.replace(122						/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,123						"$1-$2-$3 $4:$5:$6 UTC",124					)}. <a href="/admin/backup">download backup</a></p>`125				: html`<p>No backup yet.</p>`;126			return c.html(127				adminPage(128					c,129					ctx,130					"backups",131					html`${backup}132<p>Kepr keeps the last 14 daily backups of its database and assets.</p>133<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>134<p class="muted">Sessions and bucket credentials are not included.</p>`,135				),136			);137		}138		let bucket: Html;139		try {140			const repos = await listRepos(ctx.sync.bucket);141			const formats = await Promise.all(142				repos.map(async (repo) => {143					try {144						await checkFormat(ctx.sync.bucket, repo);145						return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;146					} catch (error) {147						return html`<li><span class="mono">${repo}</span>148<span class="issues-error">${String(error)}</span></li>`;149					}150				}),151			);152			bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;153		} catch (error) {154			bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;155		}156		const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {157			version: string;158		};159		const lines = (await sourceLines()).toLocaleString("en-US");160		const newest = backups(ctx.dataDir).at(-1);161		const repos = allRepos(ctx.kepr);162		const open = operatorIssues(ctx.kepr, "open").length;163		const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);164		const vital = (label: string, value: number, href: string) =>165			html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;166		const overview = html`<div class="admin-vitals">167${vital("repositories", repos.length, "/admin/repos")}168${vital("public", repos.filter((repo) => repo.public).length, "/browse")}169${vital("open issues", open, "/admin/issues")}170</div>171<section class="repo-section"><h2 class="section-label mono">needs attention</h2>172${attention.map(173	(repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">174<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,175)}176${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}177${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}178${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}179</section>180<section class="repo-section"><h2 class="section-label mono">repositories</h2>181${repos182	.slice()183	.sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))184	.slice(0, 8)185	.map(186		(repo) => html`187<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>188<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,189	)}190${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}191</section>`;192		return c.html(193			adminPage(194				c,195				ctx,196				"overview",197				html`${overview}<section class="repo-section markdown">198<p><a href="/admin?section=backups">manage backups →</a></p>199<details><summary class="mono">bucket and runtime</summary>${bucket}200${ctx.canConnect ? connectedMachines(ctx) : ""}201<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,202			),203		);204	});205206	app.get("/admin/backup", (c) => {207		const backup = newestBackupTarball(ctx.dataDir);208		if (!backup) return c.notFound();209		return c.body(backup.bytes, 200, {210			"Content-Type": "application/gzip",211			"Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,212		});213	});214}