eb098609196d

add in-app operator notifications with unread count and per-kind switches (v3.0.135)

time
2026-09-29 22:34 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator notifications
parent
987482f17e06
9 files package.json+1 −1public/css/kepr.css+14 −0src/admin/console.ts+10 −1src/admin/notifications.ts+72 −0src/issues/pages.ts+13 −0src/server/app.ts+2 −0src/server/backup.ts+7 −0src/server/db.ts+45 −0tests/notifications.test.ts+139 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.134",3+	"version": "3.0.135",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+14 −0
@@ -2136,6 +2136,20 @@21362136 .admin-sidebar a {21372137 	color: var(--text-secondary);21382138 }2139+.admin-unread {2140+	display: inline-block;2141+	min-width: 1.5em;2142+	padding: 0 5px;2143+	border-radius: 999px;2144+	background: var(--accent);2145+	color: var(--bg);2146+	font-size: 0.6875rem;2147+	text-align: center;2148+}2149+.notification-unread {2150+	box-shadow: inset 2px 0 0 var(--accent);2151+	background: var(--accent-tint-04);2152+}21392153 .admin-sidebar a[aria-current] {21402154 	color: var(--accent);21412155 	border-left: 2px solid var(--accent);

view file

src/admin/console.ts+10 −1
@@ -23,6 +23,7 @@2323 	["/admin/repos", "repos"],2424 	["/admin/issues", "issues"],2525 	["/admin/releases", "releases"],26+	["/admin/notifications", "notifications"],2627 	["/admin/bucket", "bucket"],2728 	["/admin?section=backups", "backups"],2829 	["/admin/site?section=profile", "profile"],@@ -60,7 +61,15 @@6061 			)6162 				? html` aria-current="page"`6263 				: ""63-		}>${name}</a>`;64+		}>${name}${65+			name === "notifications" && unread > 066+				? html` <span class="admin-unread">${String(unread)}</span>`67+				: ""68+		}</a>`;69+	const unread =70+		ctx.kepr71+			.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")72+			.get()?.n ?? 0;6473 	const handle = setting(ctx.kepr, "handle");6574 	return page(ctx, {6675 		title: `${title} · admin`,

view file

src/admin/notifications.ts+72 −0
@@ -0,0 +1,72 @@1+import type { Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { relativeTime } from "../pages/layout.ts";4+import { NOTIFICATION_KINDS, type NotificationKind, setting } from "../server/db.ts";5+import { type AdminContext, adminPage, formField, textFields } from "./console.ts";6+import { saveSettings } from "./store.ts";7+8+type Notification = { id: number; text: string; href: string; created_at: number; read: number };9+10+const LABELS: Record<NotificationKind, string> = {11+	issue_filed: "an issue is filed",12+	issue_followup: "a reporter adds a follow-up",13+	sync_failed: "a repo's sync starts failing",14+	backup_failed: "a backup starts failing",15+};16+17+/** §24's Notifications section: the list, opening one, mark all read, and which kinds to raise. */18+export function registerNotifications(app: Hono, ctx: AdminContext): void {19+	app.get("/admin/notifications", (c) => {20+		const rows = ctx.kepr21+			.query<Notification, []>(22+				"SELECT id, text, href, created_at, read FROM notifications ORDER BY id DESC",23+			)24+			.all();25+		const row = (n: Notification) =>26+			html`<a class="issue-row${n.read ? "" : " notification-unread"}" href="/admin/notifications/${String(n.id)}">27+<span class="issue-title">${n.text}</span>28+<span class="issue-meta mono">${n.read ? "" : "unread · "}${relativeTime(n.created_at)}</span></a>`;29+		const kind = (k: NotificationKind) =>30+			html`<label class="issues-label"><input type="checkbox" name="${k}" value="1"${31+				setting(ctx.kepr, `notify_off_${k}`) ? "" : html` checked`32+			}> ${LABELS[k]}</label>`;33+		const body = html`<form method="post" action="/admin/notifications">34+${formField(c)}<input type="hidden" name="action" value="read_all">35+<button class="issues-submit" type="submit">mark all read</button></form>36+<section class="repo-section">37+${rows.length > 0 ? rows.map(row) : html`<p class="muted mono save-empty">nothing yet</p>`}38+</section>39+<section class="repo-section"><h2 class="section-label mono">tell me when</h2>40+<form class="issues-form" method="post" action="/admin/notifications">41+${formField(c)}<input type="hidden" name="action" value="preferences">42+${NOTIFICATION_KINDS.map(kind)}43+<button class="issues-submit" type="submit">save</button>44+</form></section>`;45+		return c.html(adminPage(c, ctx, "notifications", body));46+	});47+48+	app.get("/admin/notifications/:id{[0-9]{1,9}}", (c) => {49+		const found = ctx.kepr50+			.query<{ href: string }, [number]>(51+				"UPDATE notifications SET read = 1 WHERE id = ? RETURNING href",52+			)53+			.get(Number(c.req.param("id")));54+		return found ? c.redirect(found.href, 303) : c.notFound();55+	});56+57+	app.post("/admin/notifications", async (c) => {58+		const fields = await textFields(c);59+		if (fields.action === "read_all") {60+			ctx.kepr.run("UPDATE notifications SET read = 1 WHERE read = 0");61+		} else if (fields.action === "preferences") {62+			// A box left unticked is a kind switched off; the setting records only that.63+			saveSettings(64+				ctx.kepr,65+				Object.fromEntries(66+					NOTIFICATION_KINDS.map((k) => [`notify_off_${k}`, fields[k] === "1" ? "" : "1"]),67+				),68+			);69+		} else return c.text("Unknown action.", 400);70+		return c.redirect("/admin/notifications?saved=1", 303);71+	});72+}

view file

src/issues/pages.ts+13 −0
@@ -12,6 +12,7 @@1212 	relativeTime,1313 } from "../pages/layout.ts";1414 import { visibleReleases } from "../releases/releases.ts";15+import { notify } from "../server/db.ts";1516 import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";1617 import {1718 	addEntry,@@ -90,6 +91,12 @@9091 			ipHash: hash,9192 			filedAt: now,9293 		});94+		notify(95+			ctx.kepr,96+			"issue_filed",97+			`New issue ${name} #${filed.number}: ${title}`,98+			`/admin/issues/${name}/${filed.number}`,99+		);93100 		const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;94101 		privatePage(c);95102 		return c.html(@@ -139,6 +146,12 @@139146 			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);140147 		}141148 		const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());149+		notify(150+			ctx.kepr,151+			"issue_followup",152+			`Follow-up on ${repo.settings.name} #${issue.number}: ${issue.title}`,153+			`/admin/issues/${repo.settings.name}/${issue.number}`,154+		);142155 		return c.redirect(143156 			`/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,144157 			303,

view file

src/server/app.ts+2 −0
@@ -6,6 +6,7 @@66 import { registerBucket } from "../admin/bucket.ts";77 import { registerStatus } from "../admin/console.ts";88 import { registerIssues } from "../admin/issues.ts";9+import { registerNotifications } from "../admin/notifications.ts";910 import { registerReleases } from "../admin/releases.ts";1011 import { registerRepos } from "../admin/repos.ts";1112 import { registerSite } from "../admin/site.ts";@@ -125,6 +126,7 @@125126 	registerReleases(app, admin);126127 	registerSite(app, admin);127128 	registerBucket(app, admin);129+	registerNotifications(app, admin);128130 	registerRepoText(app, admin);129131 	registerHousekeeping(app, ctx);130132 	registerFrontPages(app, ctx);

view file

src/server/backup.ts+7 −0
@@ -10,6 +10,7 @@1010 } from "node:fs";1111 import { join, relative } from "node:path";1212 import { buildTarball } from "../tarball/tar.ts";13+import { notify } from "./db.ts";1314 1415 const KEEP = 14;1516 const DAY_MS = 24 * 3600 * 1000;@@ -67,11 +68,17 @@6768 6869 /** Daily backups: checked at startup and every hour, so a restart never skips a day. */6970 export function startBackupLoop(kepr: Database, dataDir: string): void {71+	// Only a backup that starts failing is told, not every hourly retry of one that keeps failing.72+	let failing = false;7073 	const tick = () => {7174 		try {7275 			backupIfDue(kepr, dataDir);76+			failing = false;7377 		} catch (error) {7478 			console.error(`kepr: backup failed: ${error}`);79+			if (!failing)80+				notify(kepr, "backup_failed", `Backup failed: ${error}`, "/admin?section=backups");81+			failing = true;7582 		}7683 	};7784 	tick();

view file

src/server/db.ts+45 −0
@@ -92,8 +92,27 @@9292 	expires_at  INTEGER NOT NULL,9393 	used_at     INTEGER9494 );95+-- §24: what Kepr tells the operator in the console, newest 200 kept.96+CREATE TABLE IF NOT EXISTS notifications (97+	id         INTEGER PRIMARY KEY,98+	kind       TEXT NOT NULL,99+	text       TEXT NOT NULL,100+	href       TEXT NOT NULL,101+	created_at INTEGER NOT NULL,102+	read       INTEGER NOT NULL DEFAULT 0103+);95104 `;96105 106+/** What raises a notification (§24); each can be switched off. */107+export const NOTIFICATION_KINDS = [108+	"issue_filed",109+	"issue_followup",110+	"sync_failed",111+	"backup_failed",112+] as const;113+export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];114+const NOTIFICATIONS_KEPT = 200;115+97116 // Columns added after the first kepr.db was made; SQLite has no ADD COLUMN IF NOT EXISTS.98117 const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [99118 	["repos", "languages", "TEXT NOT NULL DEFAULT ''"],@@ -158,7 +177,33 @@158177 	db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);159178 }160179 180+/** Tells the operator, unless that kind is switched off; only the newest 200 are kept. */181+export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {182+	if (setting(db, `notify_off_${kind}`)) return;183+	db.transaction(() => {184+		db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(185+			kind,186+			text,187+			href,188+			Math.floor(Date.now() / 1000),189+		);190+		db.query(191+			`DELETE FROM notifications192+			WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)`,193+		).run(NOTIFICATIONS_KEPT);194+	})();195+}196+197+/** Records a sync's outcome; a repo that starts failing tells the operator, once. */161198 export function recordSync(db: Database, name: string, at: number, error: string | null): void {199+	const before = db200+		.query<{ last_sync_error: string | null }, [string]>(201+			"SELECT last_sync_error FROM repos WHERE name = ?",202+		)203+		.get(name);204+	if (error && !before?.last_sync_error) {205+		notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);206+	}162207 	db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(163208 		at,164209 		error,

view file

tests/notifications.test.ts+139 −0
@@ -0,0 +1,139 @@1+// Covers §24's in-app notifications: what raises one, unread state, opening, mark all read,2+// switching kinds off, and keeping the newest 200.3+4+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5+import { writeFileSync } from "node:fs";6+import { startBackupLoop } from "../src/server/backup.ts";7+import { notify, recordSync } from "../src/server/db.ts";8+import { Browser, type Instance, instance } from "./browser.ts";9+import { makeRepos } from "./fixture.ts";10+11+let clock = Date.parse("2026-09-29T12:00:00Z");12+const tick = (seconds: number) => {13+	clock += seconds * 1000;14+	setSystemTime(new Date(clock));15+};16+17+describe("operator notifications (§24)", () => {18+	let inst: Instance;19+	let operator: Browser;20+	let followUpLink: string;21+	const unread = () =>22+		inst.kepr23+			.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")24+			.get()?.n;25+	const page = async () => (await operator.get("/admin/notifications")).text();26+	const clear = () => inst.kepr.run("DELETE FROM notifications");27+28+	beforeAll(async () => {29+		setSystemTime(new Date(clock));30+		const repos = await makeRepos(["app"]);31+		inst = await instance(repos.bucketDir);32+		operator = new Browser(inst.app);33+		await operator.signIn();34+		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });35+		clear();36+		const reporter = new Browser(inst.app, "198.51.100.41");37+		const form = await (await reporter.request("/app/issues/new")).text();38+		tick(4);39+		const filed = await reporter.request("/app/issues/new", {40+			method: "POST",41+			body: new URLSearchParams({42+				stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "",43+				website: "",44+				title: "Crash <on> start",45+				body: "It crashed.",46+				type: "bug",47+			}),48+		});49+		followUpLink = /href="https:\/\/kepr\.example(\/app\/issues\/1\?token=[0-9a-f]+)"/.exec(50+			await filed.text(),51+		)?.[1] as string;52+	});53+	afterAll(() => setSystemTime());54+55+	test("a filed issue raises one, unread, shown in the sidebar and escaped", async () => {56+		expect(unread()).toBe(1);57+		const text = await page();58+		expect(text).toMatch(59+			/href="\/admin\/notifications"[^>]*>notifications <span class="admin-unread">1<\/span>/,60+		);61+		expect(text).toContain("New issue app #1: Crash &lt;on&gt; start");62+	});63+64+	test("opening one marks it read and goes to what it is about", async () => {65+		const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id;66+		const opened = await operator.get(`/admin/notifications/${id}`);67+		expect(opened.status).toBe(303);68+		expect(opened.headers.get("location")).toBe("/admin/issues/app/1");69+		expect(unread()).toBe(0);70+		expect(await page()).not.toContain('class="admin-unread"');71+		expect((await operator.get("/admin/notifications/999999")).status).toBe(404);72+	});73+74+	test("a reporter's follow-up raises one", async () => {75+		const reporter = new Browser(inst.app, "198.51.100.41");76+		const thread = await (await reporter.request(followUpLink)).text();77+		tick(4);78+		const posted = await reporter.request("/app/issues/1", {79+			method: "POST",80+			body: new URLSearchParams({81+				token: new URL(followUpLink, "https://x").searchParams.get("token") ?? "",82+				stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "",83+				body: "Still broken.",84+			}),85+		});86+		expect(posted.status).toBe(303);87+		expect(await page()).toContain("Follow-up on app #1: Crash &lt;on&gt; start");88+	});89+90+	test("a sync starting to fail raises one; a failure that continues does not", () => {91+		clear();92+		recordSync(inst.kepr, "app", 1, "bucket unreachable");93+		recordSync(inst.kepr, "app", 2, "bucket unreachable");94+		expect(unread()).toBe(1);95+		recordSync(inst.kepr, "app", 3, null);96+		recordSync(inst.kepr, "app", 4, "format changed");97+		expect(unread()).toBe(2);98+	});99+100+	test("a backup starting to fail raises one", () => {101+		clear();102+		// A file where the backups folder belongs makes every backup fail.103+		writeFileSync(`${inst.dataDir}/backups`, "");104+		startBackupLoop(inst.kepr, inst.dataDir);105+		const row = inst.kepr106+			.query<{ text: string; href: string }, []>("SELECT text, href FROM notifications")107+			.get();108+		expect(row?.text).toStartWith("Backup failed:");109+		expect(row?.href).toBe("/admin?section=backups");110+	});111+112+	test("mark all read, and a kind switched off raises nothing", async () => {113+		notify(inst.kepr, "issue_filed", "one", "/admin/issues");114+		expect((await operator.post("/admin/notifications", { action: "read_all" })).status).toBe(303);115+		expect(unread()).toBe(0);116+		const off = await operator.post("/admin/notifications", {117+			action: "preferences",118+			sync_failed: "1",119+			backup_failed: "1",120+			issue_followup: "1",121+		});122+		expect(off.status).toBe(303);123+		expect(await page()).toMatch(/name="issue_filed" value="1">/);124+		notify(inst.kepr, "issue_filed", "two", "/admin/issues");125+		expect(unread()).toBe(0);126+		notify(inst.kepr, "sync_failed", "three", "/admin/repos");127+		expect(unread()).toBe(1);128+	});129+130+	test("keeps the newest 200", () => {131+		clear();132+		for (let n = 0; n < 205; n++) notify(inst.kepr, "sync_failed", `n${n}`, "/admin/repos");133+		const rows = inst.kepr134+			.query<{ text: string }, []>("SELECT text FROM notifications ORDER BY id")135+			.all();136+		expect(rows.length).toBe(200);137+		expect(rows[0]?.text).toBe("n5");138+	});139+});

view file