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
↓ kepr-eb098609.tar.gzatlas at this save
files changed9 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 −0package.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": {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);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`,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+}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,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);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();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,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 <on> 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 <on> 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+});