mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
9 files changed
+761
-138
No files matched your search
@@ -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 ''
|
||||
);
|
||||
@@ -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 ''
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 & 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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user