feat(crash): account system for the crash dashboard (#4119)

Replace the single shared STATS_PASSWORD with email/password accounts,
sessions, and role-based access. New registrations land as `pending`
with no access until an admin grants `viewer` or `admin`; the
ADMIN_EMAILS secret bootstraps the first admin on register. Admins get
user management, crash-group triage (status / note / delete), and an
audit log. The /v1 ingest endpoints stay public and unchanged.
This commit is contained in:
YHH authored and GitHub committed 2026-06-12 12:14:48 +08:00
1 parent 2fa3a57ae1
commit c0aac6412f
9 files changed
+761 -138

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
-- One-time upgrade of the live reasonix-crash DB to the account system.
-- Apply once: wrangler d1 execute reasonix-crash --remote --file=migrate.sql
-- The ALTERs are not idempotent; the CREATEs are. Fresh installs use schema.sql.
ALTER TABLE groups ADD COLUMN status TEXT NOT NULL DEFAULT 'open';
ALTER TABLE groups ADD COLUMN note TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
approved_at TEXT,
approved_by INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor_id INTEGER,
actor_email TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
+32 -1
View File
@@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS groups (
count INTEGER NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
last_version TEXT NOT NULL
last_version TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
note TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS reports (
@@ -44,3 +46,32 @@ CREATE TABLE IF NOT EXISTS metrics (
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, version, os, signal, bucket)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
approved_at TEXT,
approved_by INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor_id INTEGER,
actor_email TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
+75
View File
@@ -0,0 +1,75 @@
import { esc, page } from "./shell";
import { type Role, type User, userNav } from "./auth";
export interface UserRow {
id: number;
email: string;
role: Role;
created_at: string;
approved_at: string | null;
}
export interface AuditRow {
at: string;
actor_email: string;
action: string;
target: string;
detail: string;
}
function roleSelect(row: UserRow): string {
const opt = (r: Role) => `<option value="${r}"${row.role === r ? " selected" : ""}>${r}</option>`;
return `<form method="post" action="/admin/users" class="actions">
<input type="hidden" name="action" value="role"><input type="hidden" name="userId" value="${row.id}">
<select name="role" onchange="this.form.submit()">${opt("pending")}${opt("viewer")}${opt("admin")}</select>
<noscript><button class="btn sm" type="submit">Set</button></noscript></form>`;
}
function deleteForm(row: UserRow): string {
return `<form method="post" action="/admin/users" class="inline" onsubmit="return confirm('Delete ${esc(row.email)}?')">
<input type="hidden" name="action" value="delete"><input type="hidden" name="userId" value="${row.id}">
<button class="btn danger sm" type="submit">Delete</button></form>`;
}
export function renderUsers(viewer: User, users: UserRow[], n?: { kind: "err" | "ok"; text: string }): string {
const pending = users.filter((u) => u.role === "pending").length;
const rows = users
.map((u) => {
const self = u.id === viewer.id;
const approved = u.approved_at ? esc(u.approved_at.slice(0, 10)) : "—";
return `<tr><td>${esc(u.email)}${self ? ' <span class="badge viewer">you</span>' : ""}</td>
<td>${self ? `<span class="badge ${u.role}">${u.role}</span>` : roleSelect(u)}</td>
<td class="n">${esc(u.created_at.slice(0, 10))}</td><td class="n">${approved}</td>
<td>${self ? "" : deleteForm(u)}</td></tr>`;
})
.join("");
return page(
"Reasonix · Users",
"admin / users",
`<h1>Users</h1><p class="sub"><b>${users.length}</b> accounts · <b>${pending}</b> awaiting approval · set a role to grant or revoke access</p>
${n ? `<div class="notice ${n.kind}">${esc(n.text)}</div>` : ""}
<div class="card full"><table><thead><tr><th>email</th><th>role</th><th>joined</th><th>approved</th><th></th></tr></thead>
<tbody>${rows}</tbody></table></div>
<a class="back" href="/admin/audit">View audit log →</a>`,
userNav(viewer),
);
}
export function renderAudit(viewer: User, rows: AuditRow[]): string {
const body = rows.length
? `<table><thead><tr><th>when</th><th>actor</th><th>action</th><th>target</th><th>detail</th></tr></thead><tbody>${rows
.map(
(r) =>
`<tr><td class="n">${esc(r.at.slice(0, 19).replace("T", " "))}</td><td>${esc(r.actor_email)}</td><td><span class="pill">${esc(r.action)}</span></td><td>${esc(r.target)}</td><td>${esc(r.detail)}</td></tr>`,
)
.join("")}</tbody></table>`
: `<div class="empty">No actions logged yet</div>`;
return page(
"Reasonix · Audit",
"admin / audit",
`<h1>Audit log</h1><p class="sub">Permission and crash-data changes, newest first</p>
<div class="card full">${body}</div>
<a class="back" href="/admin">← Back to users</a>`,
userNav(viewer),
);
}
+145
View File
@@ -0,0 +1,145 @@
import type { Env } from "./env";
import { esc } from "./shell";
export type Role = "pending" | "viewer" | "admin";
export interface User {
id: number;
email: string;
role: Role;
created_at: string;
approved_at: string | null;
}
const RANK: Record<Role, number> = { pending: 0, viewer: 1, admin: 2 };
export function atLeast(role: Role, min: Role): boolean {
return RANK[role] >= RANK[min];
}
const COOKIE = "rxsess";
const SESSION_TTL_MS = 30 * 86_400_000;
const PBKDF2_ITERS = 100_000;
function b64(bytes: Uint8Array): string {
let s = "";
for (const byte of bytes) s += String.fromCharCode(byte);
return btoa(s);
}
function unb64(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256);
return new Uint8Array(bits);
}
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await deriveBits(password, salt, PBKDF2_ITERS);
return `pbkdf2$${PBKDF2_ITERS}$${b64(salt)}$${b64(hash)}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [scheme, iters, saltB64, hashB64] = stored.split("$");
if (scheme !== "pbkdf2") return false;
const got = await deriveBits(password, unb64(saltB64), Number(iters));
const want = unb64(hashB64);
return got.byteLength === want.byteLength && crypto.subtle.timingSafeEqual(got, want);
}
function randomToken(): string {
const b = crypto.getRandomValues(new Uint8Array(32));
return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
}
export async function createSession(env: Env, userId: number): Promise<string> {
const token = randomToken();
const now = Date.now();
await env.DB.prepare("INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)")
.bind(token, userId, new Date(now).toISOString(), new Date(now + SESSION_TTL_MS).toISOString())
.run();
return token;
}
export async function destroySession(env: Env, token: string): Promise<void> {
await env.DB.prepare("DELETE FROM sessions WHERE token = ?1").bind(token).run();
}
export async function endSession(request: Request, env: Env): Promise<void> {
const token = getCookie(request, COOKIE);
if (token) await destroySession(env, token);
}
export function sessionCookie(token: string): string {
return `${COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
}
export function clearCookie(): string {
return `${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
}
export function getCookie(request: Request, name: string): string | null {
const header = request.headers.get("cookie") ?? "";
for (const part of header.split(";")) {
const [k, ...v] = part.trim().split("=");
if (k === name) return v.join("=");
}
return null;
}
export async function currentUser(request: Request, env: Env): Promise<User | null> {
const token = getCookie(request, COOKIE);
if (!token) return null;
const row = await env.DB.prepare(
`SELECT u.id, u.email, u.role, u.created_at, u.approved_at, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?1`,
)
.bind(token)
.first<{ id: number; email: string; role: Role; created_at: string; approved_at: string | null; expires_at: string }>();
if (!row) return null;
if (row.expires_at < new Date().toISOString()) {
await destroySession(env, token);
return null;
}
return { id: row.id, email: row.email, role: row.role, created_at: row.created_at, approved_at: row.approved_at };
}
export function isAdminEmail(env: Env, email: string): boolean {
const list = (env.ADMIN_EMAILS ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
return list.includes(email.toLowerCase());
}
// CSRF guard for cookie-authed POSTs: a missing Origin is rejected, not waved
// through as same-site.
export function sameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (!origin) return false;
try {
return new URL(origin).host === new URL(request.url).host;
} catch {
return false;
}
}
export async function logAction(env: Env, actor: User, action: string, target = "", detail = ""): Promise<void> {
await env.DB.prepare(
"INSERT INTO audit_log (at, actor_id, actor_email, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(new Date().toISOString(), actor.id, actor.email, action, target, detail)
.run();
}
export function userNav(user: User): string {
const admin = user.role === "admin" ? `<a class="navlink" href="/admin">Admin</a>` : "";
return `<span class="chip"><span class="badge ${user.role}">${user.role}</span>${esc(user.email)}</span><a class="navlink" href="/account">Account</a>${admin}<form method="post" action="/logout" class="inline"><button class="btn ghost sm">Sign out</button></form>`;
}
+58
View File
@@ -0,0 +1,58 @@
import { esc, page } from "./shell";
import { type User, userNav } from "./auth";
function notice(n?: { kind: "err" | "ok"; text: string }): string {
return n ? `<div class="notice ${n.kind}">${esc(n.text)}</div>` : "";
}
function authShell(title: string, crumb: string, body: string): string {
return page(title, crumb, `<div class="authwrap"><div class="authcard">${body}</div></div>`);
}
export function renderLogin(n?: { kind: "err" | "ok"; text: string }): string {
return authShell(
"Reasonix · Sign in",
"sign in",
`<h1>Sign in</h1><p class="sub">Crash &amp; telemetry dashboard</p>${notice(n)}
<form method="post" action="/login">
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
<div class="field"><label>Password</label><input type="password" name="password" autocomplete="current-password" required></div>
<button class="btn block" type="submit">Sign in</button>
</form>
<div class="alt">No account? <a href="/register">Register</a></div>`,
);
}
export function renderRegister(n?: { kind: "err" | "ok"; text: string }): string {
return authShell(
"Reasonix · Register",
"register",
`<h1>Create account</h1><p class="sub">New accounts start with no access until an admin approves them.</p>${notice(n)}
<form method="post" action="/register">
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
<div class="field"><label>Password <span style="color:var(--ink-3)">— at least 8 characters</span></label><input type="password" name="password" autocomplete="new-password" minlength="8" required></div>
<button class="btn block" type="submit">Register</button>
</form>
<div class="alt">Already have an account? <a href="/login">Sign in</a></div>`,
);
}
export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string }): string {
const pending = user.role === "pending";
const status = pending
? `<div class="notice err">Your account is awaiting approval. An admin needs to grant you access before the crash dashboard becomes visible.</div>`
: `<div class="notice ok">You have <b>${esc(user.role)}</b> access. <a href="/stats">Open the dashboard →</a></div>`;
return page(
"Reasonix · Account",
"account",
`<h1>Account</h1><p class="sub">${esc(user.email)} · joined ${esc(user.created_at.slice(0, 10))}</p>
${notice(n)}${status}
<div class="card" style="max-width:480px;margin-top:24px"><h2>Change password</h2>
<form method="post" action="/account/password">
<div class="field"><label>Current password</label><input type="password" name="current" autocomplete="current-password" required></div>
<div class="field"><label>New password</label><input type="password" name="next" autocomplete="new-password" minlength="8" required></div>
<button class="btn" type="submit">Update password</button>
</form></div>`,
userNav(user),
);
}
+11
View File
@@ -0,0 +1,11 @@
export interface RateLimiter {
limit(opts: { key: string }): Promise<{ success: boolean }>;
}
export interface Env {
DB: D1Database;
RATE_LIMITER: RateLimiter;
PING_LIMITER: RateLimiter;
METRICS_LIMITER: RateLimiter;
ADMIN_EMAILS?: string;
}
+247 -63
View File
@@ -1,19 +1,26 @@
// Ingest + stats for desktop crash/feedback reports and the anonymous launch
// Ingest + dashboard for desktop crash/feedback reports and the anonymous launch
// ping. Reports are user-initiated; pings are opt-out (desktop.telemetry).
import { z } from "zod";
import { renderGroup, renderStats } from "./stats";
interface RateLimiter {
limit(opts: { key: string }): Promise<{ success: boolean }>;
}
interface Env {
DB: D1Database;
RATE_LIMITER: RateLimiter;
PING_LIMITER: RateLimiter;
METRICS_LIMITER: RateLimiter;
STATS_PASSWORD?: string;
}
import type { Env } from "./env";
import { html, redirect } from "./shell";
import { renderGroup, renderStats, type Group } from "./stats";
import { renderLogin, renderRegister, renderAccount } from "./auth_pages";
import { renderUsers, renderAudit, type UserRow, type AuditRow } from "./admin";
import {
atLeast,
createSession,
currentUser,
endSession,
hashPassword,
isAdminEmail,
logAction,
sameOrigin,
sessionCookie,
clearCookie,
verifyPassword,
type Role,
type User,
} from "./auth";
const MAX_BODY_BYTES = 32 * 1024;
const SAMPLES_PER_GROUP = 5;
@@ -187,29 +194,92 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
return new Response("ok", { status: 202 });
}
function statsAuthorized(request: Request, env: Env): boolean {
// trim: secrets piped in via PowerShell arrive with a trailing newline.
const want = (env.STATS_PASSWORD ?? "").trim();
if (!want) return false;
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Basic ")) return false;
let pass: string;
try {
pass = atob(header.slice(6)).split(":").slice(1).join(":");
} catch {
return false;
}
const enc = new TextEncoder();
const a = enc.encode(pass);
const b = enc.encode(want);
return a.byteLength === b.byteLength && crypto.subtle.timingSafeEqual(a, b);
const Credentials = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(200),
});
const PasswordChange = z.object({
current: z.string().min(1).max(200),
next: z.string().min(8).max(200),
});
const UserAction = z.object({
action: z.enum(["role", "delete"]),
userId: z.coerce.number().int().positive(),
role: z.enum(["pending", "viewer", "admin"]).optional(),
});
const GroupAction = z.object({
action: z.enum(["status", "delete", "note"]),
status: z.enum(["open", "resolved", "ignored"]).optional(),
note: z.string().max(500).optional(),
});
async function formObject(request: Request): Promise<Record<string, string>> {
const form = await request.formData();
const out: Record<string, string> = {};
for (const [k, v] of form) out[k] = typeof v === "string" ? v : "";
return out;
}
function html(body: string): Response {
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
async function handleRegister(request: Request, env: Env): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const parsed = Credentials.safeParse(await formObject(request));
if (!parsed.success)
return html(renderRegister({ kind: "err", text: "Enter a valid email and a password of at least 8 characters." }));
const email = parsed.data.email.toLowerCase();
const existing = await env.DB.prepare("SELECT id FROM users WHERE email = ?1").bind(email).first();
if (existing) return html(renderRegister({ kind: "err", text: "That email is already registered — try signing in." }));
const role: Role = isAdminEmail(env, email) ? "admin" : "pending";
const now = new Date().toISOString();
const hash = await hashPassword(parsed.data.password);
const res = await env.DB.prepare(
"INSERT INTO users (email, password_hash, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(email, hash, role, now, role === "admin" ? now : null)
.run();
const token = await createSession(env, res.meta.last_row_id);
return redirect(role === "pending" ? "/account" : "/stats", sessionCookie(token));
}
async function handleStats(env: Env): Promise<Response> {
async function handleLogin(request: Request, env: Env): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const parsed = Credentials.safeParse(await formObject(request));
if (!parsed.success) return html(renderLogin({ kind: "err", text: "Enter a valid email and password." }));
const email = parsed.data.email.toLowerCase();
const row = await env.DB.prepare("SELECT id, password_hash, role FROM users WHERE email = ?1")
.bind(email)
.first<{ id: number; password_hash: string; role: Role }>();
const ok = row ? await verifyPassword(parsed.data.password, row.password_hash) : false;
if (!row || !ok) return html(renderLogin({ kind: "err", text: "Wrong email or password." }));
const token = await createSession(env, row.id);
return redirect(atLeast(row.role, "viewer") ? "/stats" : "/account", sessionCookie(token));
}
async function handleAccountPassword(request: Request, env: Env, user: User): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const parsed = PasswordChange.safeParse(await formObject(request));
if (!parsed.success) return html(renderAccount(user, { kind: "err", text: "New password must be at least 8 characters." }));
const row = await env.DB.prepare("SELECT password_hash FROM users WHERE id = ?1")
.bind(user.id)
.first<{ password_hash: string }>();
if (!row || !(await verifyPassword(parsed.data.current, row.password_hash)))
return html(renderAccount(user, { kind: "err", text: "Current password is incorrect." }));
await env.DB.prepare("UPDATE users SET password_hash = ?1 WHERE id = ?2")
.bind(await hashPassword(parsed.data.next), user.id)
.run();
return html(renderAccount(user, { kind: "ok", text: "Password updated." }));
}
async function handleStats(env: Env, user: User): Promise<Response> {
const [daily, versions, platforms, crashes, metrics] = await Promise.all([
env.DB.prepare(
"SELECT date, COUNT(*) AS users, SUM(opens) AS opens FROM pings WHERE date >= date('now', '-29 day') GROUP BY date",
@@ -221,58 +291,172 @@ async function handleStats(env: Env): Promise<Response> {
"SELECT os || ' ' || arch AS label, COUNT(DISTINCT install_id) AS users FROM pings WHERE date >= date('now', '-6 day') GROUP BY label ORDER BY users DESC",
).all<{ label: string; users: number }>(),
env.DB.prepare(
"SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen FROM groups ORDER BY last_seen DESC LIMIT 25",
).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string }>(),
"SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen, status FROM groups ORDER BY last_seen DESC LIMIT 25",
).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string }>(),
env.DB.prepare(
"SELECT signal, bucket, SUM(count) AS total FROM metrics WHERE date >= date('now', '-6 day') GROUP BY signal, bucket ORDER BY signal, total DESC",
).all<{ signal: string; bucket: string; total: number }>(),
]);
return html(
renderStats({
daily: daily.results,
versions: versions.results,
platforms: platforms.results,
crashes: crashes.results,
metrics: metrics.results,
}),
renderStats(
{
daily: daily.results,
versions: versions.results,
platforms: platforms.results,
crashes: crashes.results,
metrics: metrics.results,
},
user,
),
);
}
async function handleGroup(env: Env, fingerprint: string): Promise<Response> {
const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1")
.bind(fingerprint)
.first<{ fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string }>();
async function handleGroup(env: Env, fingerprint: string, user: User): Promise<Response> {
const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1").bind(fingerprint).first<Group>();
if (!group) return new Response("not found", { status: 404 });
const reports = await env.DB.prepare(
"SELECT version, os, arch, message, device, created_at FROM reports WHERE fingerprint = ?1 ORDER BY id DESC",
)
.bind(fingerprint)
.all<{ version: string; os: string; arch: string; message: string; device: string; created_at: string }>();
return html(renderGroup(group, reports.results));
return html(renderGroup(group, reports.results, user));
}
async function handleGroupAction(request: Request, env: Env, admin: User, fingerprint: string): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const parsed = GroupAction.safeParse(await formObject(request));
if (!parsed.success) return redirect(`/stats/group/${fingerprint}`);
const a = parsed.data;
if (a.action === "delete") {
await env.DB.batch([
env.DB.prepare("DELETE FROM reports WHERE fingerprint = ?1").bind(fingerprint),
env.DB.prepare("DELETE FROM groups WHERE fingerprint = ?1").bind(fingerprint),
]);
await logAction(env, admin, "delete_group", fingerprint.slice(0, 8));
return redirect("/stats");
}
if (a.action === "status") {
const status = a.status ?? "open";
await env.DB.prepare("UPDATE groups SET status = ?1 WHERE fingerprint = ?2").bind(status, fingerprint).run();
await logAction(env, admin, "set_status", fingerprint.slice(0, 8), status);
return redirect(`/stats/group/${fingerprint}`);
}
await env.DB.prepare("UPDATE groups SET note = ?1 WHERE fingerprint = ?2").bind(a.note ?? "", fingerprint).run();
await logAction(env, admin, "set_note", fingerprint.slice(0, 8));
return redirect(`/stats/group/${fingerprint}`);
}
async function handleAdminUsers(request: Request, env: Env, admin: User): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const parsed = UserAction.safeParse(await formObject(request));
if (!parsed.success) return redirect("/admin");
const a = parsed.data;
if (a.userId === admin.id) return redirect("/admin");
const target = await env.DB.prepare("SELECT email, role FROM users WHERE id = ?1")
.bind(a.userId)
.first<{ email: string; role: Role }>();
if (!target) return redirect("/admin");
if (a.action === "delete") {
await env.DB.batch([
env.DB.prepare("DELETE FROM sessions WHERE user_id = ?1").bind(a.userId),
env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(a.userId),
]);
await logAction(env, admin, "delete_user", target.email);
return redirect("/admin");
}
const role: Role = a.role ?? "pending";
const now = new Date().toISOString();
await env.DB.prepare("UPDATE users SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4")
.bind(role, role === "pending" ? null : now, admin.id, a.userId)
.run();
await logAction(env, admin, "set_role", target.email, `${target.role} → ${role}`);
return redirect("/admin");
}
async function handleAdminList(env: Env, admin: User): Promise<Response> {
const users = await env.DB.prepare(
"SELECT id, email, role, created_at, approved_at FROM users ORDER BY (role = 'pending') DESC, created_at DESC",
).all<UserRow>();
return html(renderUsers(admin, users.results));
}
async function handleAdminAudit(env: Env, admin: User): Promise<Response> {
const rows = await env.DB.prepare(
"SELECT at, actor_email, action, target, detail FROM audit_log ORDER BY id DESC LIMIT 200",
).all<AuditRow>();
return html(renderAudit(admin, rows.results));
}
function requireViewer(user: User | null): Response | null {
if (!user) return redirect("/login");
if (!atLeast(user.role, "viewer")) return redirect("/account");
return null;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/v1/report" && request.method === "POST") return handleReport(request, env);
if (url.pathname === "/v1/ping" && request.method === "POST") return handlePing(request, env);
if (url.pathname === "/v1/metrics" && request.method === "POST") return handleMetrics(request, env);
const path = url.pathname;
const method = request.method;
const group = url.pathname.match(/^\/stats\/group\/([0-9a-f]{64})$/);
if ((url.pathname === "/stats" || group) && request.method === "GET") {
if (!statsAuthorized(request, env)) {
return new Response("auth required", {
status: 401,
headers: { "www-authenticate": 'Basic realm="reasonix-stats"' },
});
}
return group ? handleGroup(env, group[1]) : handleStats(env);
if (path === "/v1/report" && method === "POST") return handleReport(request, env);
if (path === "/v1/ping" && method === "POST") return handlePing(request, env);
if (path === "/v1/metrics" && method === "POST") return handleMetrics(request, env);
if (path === "/register" && method === "GET") return html(renderRegister());
if (path === "/register" && method === "POST") return handleRegister(request, env);
if (path === "/login" && method === "GET") return html(renderLogin());
if (path === "/login" && method === "POST") return handleLogin(request, env);
if (path === "/logout" && method === "POST") {
await endSession(request, env);
return redirect("/login", clearCookie());
}
const user = await currentUser(request, env);
if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : "/login");
if (path === "/account" && method === "GET")
return user ? html(renderAccount(user)) : redirect("/login");
if (path === "/account/password" && method === "POST")
return user ? handleAccountPassword(request, env, user) : redirect("/login");
const groupMatch = path.match(/^\/stats\/group\/([0-9a-f]{64})$/);
if (path === "/stats" && method === "GET") return requireViewer(user) ?? handleStats(env, user as User);
if (groupMatch && method === "GET") return requireViewer(user) ?? handleGroup(env, groupMatch[1], user as User);
if (groupMatch && method === "POST") {
if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
return handleGroupAction(request, env, user, groupMatch[1]);
}
if (path === "/admin" && method === "GET") {
if (!user) return redirect("/login");
return user.role === "admin" ? handleAdminList(env, user) : redirect("/account");
}
if (path === "/admin/audit" && method === "GET") {
if (!user) return redirect("/login");
return user.role === "admin" ? handleAdminAudit(env, user) : redirect("/account");
}
if (path === "/admin/users" && method === "POST") {
if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
return handleAdminUsers(request, env, user);
}
if (
url.pathname === "/v1/report" ||
url.pathname === "/v1/ping" ||
url.pathname === "/v1/metrics" ||
url.pathname.startsWith("/stats")
path === "/v1/report" ||
path === "/v1/ping" ||
path === "/v1/metrics" ||
path === "/login" ||
path === "/register" ||
path === "/logout" ||
path === "/account" ||
path === "/account/password" ||
path.startsWith("/stats") ||
path.startsWith("/admin")
) {
return new Response("method not allowed", { status: 405 });
}
+106
View File
@@ -0,0 +1,106 @@
// Shared page shell + UI kit. Visual language mirrors site/src/styles/global.css
// — white, blue accent, large radii; crash stacks use the site's dark terminal.
export function esc(s: unknown): string {
return String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
const LOGO = `<svg viewBox="0 0 64 64" class="logo"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f9dff"/><stop offset=".55" stop-color="#7a6bff"/><stop offset="1" stop-color="#c46bff"/></linearGradient></defs><rect width="64" height="64" rx="15" fill="url(#g)"/><text x="32" y="46" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="42" font-weight="800" fill="#fff" text-anchor="middle">R</text></svg>`;
const CSS = `
:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white);
--bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260);
--ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260);
--shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08);
--sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif;
--mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased}
.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px}
header{display:flex;align-items:center;gap:10px;height:68px}
.logo{width:28px;height:28px;border-radius:8px}
header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none}
header .crumb{color:var(--ink-3);font-size:15px}
header .nav{margin-left:auto;display:flex;align-items:center;gap:14px}
.navlink{color:var(--accent);text-decoration:none;font-size:14px;font-weight:500}
.navlink:hover{text-decoration:underline}
.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--ink-2)}
.badge{font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;padding:2px 8px;border-radius:999px}
.badge.pending{background:oklch(0.95 0.04 80);color:oklch(0.5 0.12 80)}
.badge.viewer{background:var(--accent-soft);color:var(--accent)}
.badge.admin{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)}
h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px}
.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)}
.card.full{grid-column:1/-1}
.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px}
.card h2 b{color:var(--ink)}
.empty{color:var(--ink-3);font-size:14px;padding:14px 0}
svg.chart{width:100%;height:auto;display:block}
.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px}
.row+.row{border-top:1px solid var(--line)}
.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px}
.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)}
table{border-collapse:collapse;width:100%;font-size:14px}
th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0}
td{padding:8px 14px 8px 0;border-top:1px solid var(--line);vertical-align:middle}
td.n{font-family:var(--mono);font-size:13px}
a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none}
a.fp:hover{text-decoration:underline}
.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
.pill.resolved{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)}
.pill.ignored{background:var(--bg-soft);color:var(--ink-3)}
.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px}
.report{margin-top:20px}
.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px}
.report .meta b{color:var(--ink);font-weight:500}
pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono);
font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word}
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px}
.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px}
form.inline{display:inline}
.authwrap{max-width:400px;margin:6vh auto 0}
.authcard{background:#fff;border:1px solid var(--line);border-radius:24px;padding:32px 34px;box-shadow:var(--shadow-1)}
.authcard h1{font-size:23px;margin:0 0 4px}
.authcard .sub{margin-bottom:22px}
.field{display:block;margin-bottom:14px}
.field label{display:block;font-size:13px;color:var(--ink-2);margin-bottom:6px}
.field input,select{width:100%;font:inherit;font-size:14.5px;color:var(--ink);background:#fff;border:1px solid var(--line);border-radius:12px;padding:10px 13px;outline:none}
.field input:focus,select:focus{border-color:var(--accent)}
.btn{font:inherit;font-size:14.5px;font-weight:600;color:#fff;background:var(--accent);border:none;border-radius:12px;padding:10px 18px;cursor:pointer}
.btn:hover{filter:brightness(1.05)}
.btn.block{width:100%}
.btn.ghost{background:var(--accent-soft);color:var(--accent)}
.btn.danger{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
.btn.sm{font-size:13px;padding:6px 12px;border-radius:10px}
.notice{font-size:13.5px;padding:10px 14px;border-radius:12px;margin-bottom:16px}
.notice.err{background:oklch(0.96 0.03 25);color:oklch(0.5 0.18 25)}
.notice.ok{background:oklch(0.95 0.05 150);color:oklch(0.45 0.13 150)}
.alt{margin-top:18px;font-size:13.5px;color:var(--ink-3);text-align:center}
.alt a{color:var(--accent);text-decoration:none}
.actions{display:flex;gap:8px;align-items:center}
td select{width:auto;padding:6px 10px;border-radius:10px}
.note-edit{margin-top:12px;display:flex;gap:8px;max-width:560px}
.note-edit input{flex:1}
@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
`;
export function page(title: string, crumb: string, body: string, nav = ""): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>${esc(title)}</title><style>${CSS}</style></head><body><div class="wrap">
<header><a class="brand" href="https://reasonix.io">${LOGO}</a><a class="brand" href="https://reasonix.io">Reasonix</a><span class="crumb">/ ${esc(crumb)}</span>${nav ? `<span class="nav">${nav}</span>` : ""}</header>
${body}</div></body></html>`;
}
export function html(body: string): Response {
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
}
export function redirect(location: string, cookie?: string): Response {
const headers: Record<string, string> = { location };
if (cookie) headers["set-cookie"] = cookie;
return new Response(null, { status: 303, headers });
}
+53 -74
View File
@@ -1,65 +1,5 @@
// Stats pages. Visual language mirrors site/src/styles/global.css — white, blue
// accent, large radii; crash stacks render in the site's dark terminal style.
export function esc(s: unknown): string {
return String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
const LOGO = `<svg viewBox="0 0 64 64" class="logo"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f9dff"/><stop offset=".55" stop-color="#7a6bff"/><stop offset="1" stop-color="#c46bff"/></linearGradient></defs><rect width="64" height="64" rx="15" fill="url(#g)"/><text x="32" y="46" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="42" font-weight="800" fill="#fff" text-anchor="middle">R</text></svg>`;
const CSS = `
:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white);
--bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260);
--ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260);
--shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08);
--sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif;
--mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased}
.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px}
header{display:flex;align-items:center;gap:10px;height:68px}
.logo{width:28px;height:28px;border-radius:8px}
header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none}
header .crumb{color:var(--ink-3);font-size:15px}
h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px}
.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)}
.card.full{grid-column:1/-1}
.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px}
.card h2 b{color:var(--ink)}
.empty{color:var(--ink-3);font-size:14px;padding:14px 0}
svg.chart{width:100%;height:auto;display:block}
.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px}
.row+.row{border-top:1px solid var(--line)}
.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px}
.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)}
table{border-collapse:collapse;width:100%;font-size:14px}
th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0}
td{padding:8px 14px 8px 0;border-top:1px solid var(--line)}
td.n{font-family:var(--mono);font-size:13px}
a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none}
a.fp:hover{text-decoration:underline}
.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px}
.report{margin-top:20px}
.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px}
.report .meta b{color:var(--ink);font-weight:500}
pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono);
font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word}
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px}
.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px}
@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
`;
function page(title: string, crumb: string, body: string): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>${esc(title)}</title><style>${CSS}</style></head><body><div class="wrap">
<header><a class="brand" href="https://reasonix.io">${LOGO}</a><a class="brand" href="https://reasonix.io">Reasonix</a><span class="crumb">/ ${esc(crumb)}</span></header>
${body}</div></body></html>`;
}
import { esc, page } from "./shell";
import { type User, userNav } from "./auth";
type Daily = { date: string; users: number; opens: number };
@@ -120,21 +60,32 @@ function metricsCards(rows: { signal: string; bucket: string; total: number }[])
.join("")}</div>`;
}
export function renderStats(data: {
daily: Daily[];
versions: { label: string; users: number }[];
platforms: { label: string; users: number }[];
crashes: { fingerprint: string; kind: string; count: number; last_version: string; seen: string }[];
metrics: { signal: string; bucket: string; total: number }[];
}): string {
function statusPill(status: string): string {
if (status === "resolved") return `<span class="pill resolved">resolved</span>`;
if (status === "ignored") return `<span class="pill ignored">ignored</span>`;
return "";
}
type CrashRow = { fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string };
export function renderStats(
data: {
daily: Daily[];
versions: { label: string; users: number }[];
platforms: { label: string; users: number }[];
crashes: CrashRow[];
metrics: { signal: string; bucket: string; total: number }[];
},
user: User,
): string {
const days = last30Days(data.daily);
const totalUsers = days.at(-1)?.users ?? 0;
const anyPing = days.some((d) => d.opens > 0);
const crashRows = data.crashes.length
? `<table><thead><tr><th>fingerprint</th><th>kind</th><th>count</th><th>last version</th><th>last seen</th></tr></thead><tbody>${data.crashes
? `<table><thead><tr><th>fingerprint</th><th>kind</th><th>status</th><th>count</th><th>last version</th><th>last seen</th></tr></thead><tbody>${data.crashes
.map(
(c) =>
`<tr><td><a class="fp" href="/stats/group/${esc(c.fingerprint)}">${esc(c.fingerprint.slice(0, 8))}</a></td><td><span class="pill ${c.kind === "crash" ? "crash" : ""}">${esc(c.kind)}</span></td><td class="n">${c.count}</td><td class="n">${esc(c.last_version)}</td><td class="n">${esc(c.seen)}</td></tr>`,
`<tr><td><a class="fp" href="/stats/group/${esc(c.fingerprint)}">${esc(c.fingerprint.slice(0, 8))}</a></td><td><span class="pill ${c.kind === "crash" ? "crash" : ""}">${esc(c.kind)}</span></td><td>${statusPill(c.status)}</td><td class="n">${c.count}</td><td class="n">${esc(c.last_version)}</td><td class="n">${esc(c.seen)}</td></tr>`,
)
.join("")}</tbody></table>`
: `<div class="empty">No crash reports yet — that's the good kind of empty · 还没有崩溃报告</div>`;
@@ -151,6 +102,7 @@ ${anyPing ? dailyChart(days) : `<div class="empty">No pings yet — data starts
<div class="card full"><h2>Agent signals · 运行指标 <b>— 7 days, opt-in aggregate</b></h2>${metricsCards(data.metrics)}</div>
<div class="card full"><h2>Crash groups · 崩溃分组 <b>— click a fingerprint for stacks</b></h2>${crashRows}</div>
</div>`,
userNav(user),
);
}
@@ -165,9 +117,33 @@ function fmtDevice(deviceJSON: string): string {
}
}
export type Group = {
fingerprint: string;
kind: string;
count: number;
first_seen: string;
last_seen: string;
last_version: string;
status: string;
note: string;
};
function manageGroup(group: Group): string {
const fp = esc(group.fingerprint);
const setStatus = (s: string, label: string, cls: string) =>
group.status === s
? ""
: `<form method="post" action="/stats/group/${fp}" class="inline"><input type="hidden" name="action" value="status"><input type="hidden" name="status" value="${s}"><button class="btn ${cls} sm" type="submit">${label}</button></form>`;
return `<div class="card full" style="margin-top:20px"><h2>Manage <b>— admin</b></h2>
<div class="actions">${setStatus("resolved", "Mark resolved", "ghost")}${setStatus("ignored", "Ignore", "ghost")}${setStatus("open", "Reopen", "ghost")}
<form method="post" action="/stats/group/${fp}" class="inline" onsubmit="return confirm('Delete this crash group and all its samples?')"><input type="hidden" name="action" value="delete"><button class="btn danger sm" type="submit">Delete group</button></form></div>
<form method="post" action="/stats/group/${fp}" class="note-edit"><input type="hidden" name="action" value="note"><input type="text" name="note" placeholder="Add a note…" value="${esc(group.note)}"><button class="btn sm" type="submit">Save note</button></form></div>`;
}
export function renderGroup(
group: { fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string },
group: Group,
reports: { version: string; os: string; arch: string; message: string; device: string; created_at: string }[],
user: User,
): string {
const samples = reports.length
? reports
@@ -179,13 +155,16 @@ export function renderGroup(
})
.join("")
: `<div class="empty">No raw samples stored for this group</div>`;
const noteLine = group.note ? ` · note: ${esc(group.note)}` : "";
return page(
`Reasonix · ${group.fingerprint.slice(0, 8)}`,
`stats / ${group.fingerprint.slice(0, 8)}`,
`<h1><span class="pill ${group.kind === "crash" ? "crash" : ""}">${esc(group.kind)}</span> ${esc(group.fingerprint.slice(0, 8))}</h1>
<p class="sub"><b>${group.count}</b> occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}</p>
`<h1><span class="pill ${group.kind === "crash" ? "crash" : ""}">${esc(group.kind)}</span> ${esc(group.fingerprint.slice(0, 8))} ${statusPill(group.status)}</h1>
<p class="sub"><b>${group.count}</b> occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}${noteLine}</p>
<div class="card full"><h2>Samples <b>— newest first, up to 5 kept</b></h2>${samples}</div>
${user.role === "admin" ? manageGroup(group) : ""}
<a class="back" href="/stats">← Back to stats</a>`,
userNav(user),
);
}