Split server.mjs into modules: db, views, routes

server.mjs mixed SQL schema, HTML-rendering functions, and route
handling in one 476-line file. Split by concern:

- src/db.mjs — schema + migrations (openDb())
- src/views.mjs — pure render functions: layout, incidentForm,
  publicMarkdown/Incident/Detail, no db or network access
- src/style.mjs — the stylesheet, now genuinely shared instead of
  duplicated (receipts.mjs's /events page had its own near-copy,
  which is exactly what let their <nav> definitions drift out of
  sync a few commits ago)
- src/http-utils.mjs — form()/redirect(), used by more than one route
- src/routes/incidents.mjs — the private notebook + Git inbox +
  publish/unpublish, auth-gated once at the top instead of per-route
- src/routes/public-failures.mjs — the read-only /api/v1/public/failures*
  projection

server.mjs is now 79 lines of composition: open the db, build each
route module, wire the dispatch order, listen.

Also dropped the old POST /api/receipts endpoint and its `receipts`
table — dead since the v1 events API replaced it, already flagged as
"scheduled for removal" in the README.

Verified behavior is unchanged, not just "looks the same": full route
smoke test (401/200/404 in the right places, incident create→publish→
public-projection round trip, receipt ingestion), tests pass, and
before/after screenshots of every page are pixel-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-21 05:27:27 +02:00
co-authored by Claude Sonnet 5
parent 4b7b5a42d3
commit 0d825b4458
9 changed files with 606 additions and 451 deletions
+17 -1
View File
@@ -1 +1,17 @@
{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs","format":"npx --yes prettier@3.9.5 --write \"src/*.mjs\" \"test/*.mjs\"","format:check":"npx --yes prettier@3.9.5 --check \"src/*.mjs\" \"test/*.mjs\""},"engines":{"node":">=24"}}
{
"name": "trace",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/server.mjs",
"start": "node --env-file-if-exists=.env src/server.mjs",
"test": "node --test",
"build": "node --check src/server.mjs",
"format": "npx --yes prettier@3.9.5 --write \"src/**/*.mjs\" \"test/*.mjs\"",
"format:check": "npx --yes prettier@3.9.5 --check \"src/**/*.mjs\" \"test/*.mjs\""
},
"engines": {
"node": ">=24"
}
}
+79
View File
@@ -0,0 +1,79 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
// Schema for the private incident notebook and the Git fix-candidate inbox.
// Deployment-receipt tables live in receipts.mjs, next to the code that
// actually owns that data — this file only owns the notebook's own shape.
const SCHEMA = `
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS incidents(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
project TEXT,
status TEXT NOT NULL DEFAULT 'investigating',
severity TEXT NOT NULL DEFAULT 'annoyance',
detected_at TEXT NOT NULL,
symptom TEXT NOT NULL,
impact TEXT DEFAULT '',
root_cause TEXT DEFAULT '',
confidence TEXT DEFAULT 'unknown',
fix TEXT DEFAULT '',
verification TEXT DEFAULT '',
prevention TEXT DEFAULT '',
remaining_risk TEXT DEFAULT '',
lesson TEXT DEFAULT '',
public_summary TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS timeline(
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
at TEXT NOT NULL,
kind TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hypotheses(
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
statement TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'untested',
evidence TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS candidates(
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo TEXT NOT NULL,
sha TEXT NOT NULL,
committed_at TEXT NOT NULL,
subject TEXT NOT NULL,
paths TEXT DEFAULT '',
reason TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'new',
incident_id TEXT,
UNIQUE(repo, sha)
);`;
// Additive, best-effort migrations for columns that shipped after the
// initial schema. SQLite has no "ADD COLUMN IF NOT EXISTS", so each one is
// tried and a "duplicate column" failure is the expected steady state once
// it's already applied everywhere.
const MIGRATIONS = [
"ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''",
"ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''",
];
export function openDb(path = 'data/trace.db') {
mkdirSync('data', { recursive: true });
mkdirSync('data/exports', { recursive: true });
const db = new DatabaseSync(path);
db.exec(SCHEMA);
for (const sql of MIGRATIONS) {
try {
db.exec(sql);
} catch (error) {
if (!String(error.message).includes('duplicate column')) throw error;
}
}
return db;
}
+9
View File
@@ -0,0 +1,9 @@
export const form = async (req) => {
let b = '';
for await (const c of req) b += c;
return Object.fromEntries(new URLSearchParams(b));
};
export function redirect(res, to) {
res.writeHead(303, { Location: to }).end();
}
+1 -1
View File
@@ -147,7 +147,7 @@ export function renderEventsPage(db) {
'SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result',
)
.all();
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>operational events · trace</title><style>:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff;--ok:#3fb950;--bad:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.summary{display:flex;gap:8px;flex-wrap:wrap}.badge{border:1px solid var(--l);padding:3px 8px;border-radius:99px;color:var(--m)}.event{background:var(--p);border:1px solid var(--l);border-left:3px solid var(--a);padding:16px;border-radius:8px;margin:10px 0}.event.failed{border-left-color:var(--bad)}.event.success{border-left-color:var(--ok)}.top{display:flex;justify-content:space-between;gap:12px;align-items:start}.meta{color:var(--m);font-size:12px}.result.success{color:var(--ok)}.result.failed{color:var(--bad)}code,pre{background:var(--b);border:1px solid var(--l);border-radius:5px}code{padding:2px 5px}pre{padding:12px;overflow:auto;max-height:360px;white-space:pre-wrap}details{margin-top:10px}h1{margin-bottom:5px}@media(max-width:600px){.top{display:block}}</style></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main><h1>Operational events</h1><p class=meta>Builds say what CI produced. Deployments say what a host actually ran.</p><div class=summary>${counts.map((x) => `<span class=badge>${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}</span>`).join('')}</div>${
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>operational events · trace</title><link rel=stylesheet href=/style.css></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main><h1>Operational events</h1><p class=meta>Builds say what CI produced. Deployments say what a host actually ran.</p><div class=summary>${counts.map((x) => `<span class=pill>${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}</span>`).join('')}</div>${
rows
.map((row) => {
const payload = JSON.parse(row.payload),
+283
View File
@@ -0,0 +1,283 @@
// The private incident notebook and the Git fix-candidate inbox that feeds
// it. Everything here is gated by the shared `auth` check up front — the
// caller passes the same Basic Auth gate used everywhere else in trace so
// there's exactly one place that decides who gets in, not one per route.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import crypto from 'node:crypto';
import { scanGiteaCandidates } from '../gitea.mjs';
import { form, redirect } from '../http-utils.mjs';
import { esc, fields, layout, incidentForm, slugify } from '../views.mjs';
const exec = promisify(execFile);
const FIX_KEYWORDS = /fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i;
export function createIncidentRoutes({
db,
auth,
repos = new Map(),
giteaUrl = 'https://repo.explewd.com',
giteaRepos = [],
giteaToken = '',
}) {
async function scan() {
for (const [id, path] of repos) {
let stdout;
try {
stdout = (
await exec(
'git',
[
'-C',
path,
'log',
'--all',
'--no-merges',
'-n',
'300',
'--pretty=format:%H%x1f%ad%x1f%s',
'--date=iso-strict',
],
{ maxBuffer: 2e6 },
)
).stdout;
} catch (error) {
console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`);
continue;
}
for (const line of stdout.split('\n')) {
const [sha, date, subject] = line.split('\x1f');
if (!FIX_KEYWORDS.test(subject || '')) continue;
let paths = '';
try {
paths = (
await exec('git', ['-C', path, 'show', '--name-only', '--pretty=format:', sha], {
maxBuffer: 2e5,
})
).stdout.trim();
} catch {}
db.prepare(
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
).run(id, sha, date, subject, paths, 'local git keyword');
}
}
const remote = await scanGiteaCandidates({
baseUrl: giteaUrl,
repositories: giteaRepos,
token: giteaToken,
});
for (const candidate of remote.candidates)
db.prepare(
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
).run(
candidate.repo,
candidate.sha,
candidate.committedAt,
candidate.subject,
candidate.paths,
candidate.reason,
);
for (const error of remote.errors)
console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`);
}
function renderHome() {
const rows = db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();
const counts = db.prepare('SELECT status,count(*) n FROM incidents GROUP BY status').all();
return layout(
'incidents',
`<h1>Incident notebook</h1><p class=meta>${counts.map((x) => `${x.status}: ${x.n}`).join(' · ') || 'No incidents yet.'}</p>${rows.map((x) => `<a class=card style=display:block href=/incidents/${x.id}><b>${esc(x.title)}</b><p>${esc(x.symptom)}</p><span class=badge>${x.status}</span> <span class=meta>${esc(x.project)} · ${esc(x.detected_at)}</span></a>`).join('')}`,
);
}
function renderIncident(x) {
const timeline = db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id),
hyps = db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id),
commits = db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);
return layout(
x.title,
`<h1>${esc(x.title)}</h1><div class=actions><form method=post action=/incidents/${x.id}/export><button>export public draft</button></form></div>${incidentForm(x)}<section class=card><h2>Timeline</h2><div class=timeline>${timeline.map((t) => `<p><b>${esc(t.kind)}</b> ${esc(t.at)}<br>${esc(t.body)}</p>`).join('')}</div><form method=post action=/incidents/${x.id}/timeline><div class=cols><input type=datetime-local name=at required><select name=kind>${['observation', 'hypothesis', 'action', 'result', 'decision', 'deploy', 'note'].map((k) => `<option>${k}</option>`)}</select></div><textarea name=body required></textarea><button>append</button></form></section><section class=card><h2>Hypotheses</h2>${hyps.map((h) => `<p><b>${esc(h.statement)}</b> <span class=badge>${h.status}</span><br>${esc(h.evidence)}</p>`).join('')}<form method=post action=/incidents/${x.id}/hypotheses><input name=statement required placeholder="We think…"><select name=status>${['untested', 'supported', 'rejected', 'inconclusive'].map((k) => `<option>${k}</option>`)}</select><textarea name=evidence placeholder=evidence></textarea><button>add</button></form></section><section class=card><h2>Linked commits</h2>${commits.map((c) => `<p><code>${c.sha.slice(0, 7)}</code> ${esc(c.subject)}</p>`).join('') || '<p class=meta>None.</p>'}</section>`,
);
}
// Reviewed-draft export, written to disk for hand-editing before it ever
// becomes a real /failures entry. Deliberately not the same template as
// the machine-served public Markdown (views.mjs's publicMarkdown) — this
// one keeps raw severity/status values and an explicit placeholder
// section, because it's meant to be edited by a human, not served as-is.
function exportDraft(x) {
const slug = slugify(x.title);
const md = `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n<!-- Add reviewed hypothesis -->\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
writeFileSync(join('data/exports', slug + '.md'), md);
return { slug, md };
}
async function handlePublication(req, res, path) {
const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/);
if (!match || req.method !== 'POST') return false;
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
if (!x) {
res.writeHead(404).end();
return true;
}
if (match[2] === 'unpublish') {
db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run(
new Date().toISOString(),
x.id,
);
} else {
let slug = x.public_slug || slugify(x.title);
const collision = db
.prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?')
.get(slug, x.id);
if (collision) slug = `${slug}-${x.id.slice(0, 8)}`;
const now = new Date().toISOString();
db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run(
slug,
now,
now,
x.id,
);
}
redirect(res, `/incidents/${x.id}`);
return true;
}
// The last stop in the dispatch chain (see server.mjs) — everything not
// claimed by the public API, the publish/unpublish action, /events, or
// the receipts API ends up here, auth-gated as a whole rather than
// per-route, then either matched to a real route or rendered as 404.
async function handle(req, res, path) {
if (!auth(req, res)) return true;
if (await handlePublication(req, res, path)) return true;
if (path === '/' && req.method === 'GET') {
res.end(renderHome());
return true;
}
if (path === '/incidents/new' && req.method === 'GET') {
res.end(layout('new incident', '<h1>Record what happened</h1>' + incidentForm()));
return true;
}
if (path === '/incidents/new' && req.method === 'POST') {
const f = await form(req),
id = crypto.randomUUID(),
now = new Date().toISOString();
db.prepare(
`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`,
).run(
id,
f.title,
f.project || '',
f.status,
f.severity,
f.detected_at,
f.symptom,
...fields.slice(0, 2).map((x) => f[x] || ''),
f.confidence || 'unknown',
...fields.slice(2).map((x) => f[x] || ''),
now,
now,
);
redirect(res, '/incidents/' + id);
return true;
}
const match = path.match(/^\/incidents\/([^/]+)$/);
if (match) {
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
if (!x) {
res.writeHead(404).end();
return true;
}
if (req.method === 'POST') {
const f = await form(req);
db.prepare(
`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map((x) => x + '=?').join(',')},confidence=?,updated_at=? WHERE id=?`,
).run(
f.title,
f.project || '',
f.status,
f.severity,
f.detected_at,
f.symptom,
...fields.map((x) => f[x] || ''),
f.confidence || 'unknown',
new Date().toISOString(),
x.id,
);
redirect(res, path);
return true;
}
res.end(renderIncident(x));
return true;
}
const sub = path.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);
if (req.method === 'POST' && sub) {
const f = await form(req),
id = sub[1];
if (sub[2] === 'timeline')
db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(
id,
f.at,
f.kind,
f.body,
);
if (sub[2] === 'hypotheses')
db.prepare(
'INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)',
).run(id, f.statement, f.status, f.evidence);
if (sub[2] === 'export') {
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(id);
const { slug, md } = exportDraft(x);
res.end(
layout(
'export',
`<h1>Public draft exported</h1><p><code>data/exports/${esc(slug)}.md</code></p><pre>${esc(md)}</pre>`,
),
);
return true;
}
redirect(res, '/incidents/' + id);
return true;
}
if (path === '/candidates' && req.method === 'POST') {
await scan();
redirect(res, '/candidates');
return true;
}
if (path === '/candidates' && req.method === 'GET') {
const rows = db
.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC")
.all();
res.end(
layout(
'git inbox',
`<h1>Git inbox</h1><form method=post><button>scan configured repositories</button></form>${rows.map((c) => `<div class=card><b>${esc(c.subject)}</b><p class=meta>${esc(c.repo)} · ${c.sha.slice(0, 10)} · ${esc(c.committed_at)}</p><details><summary>changed paths</summary><pre>${esc(c.paths)}</pre></details><form method=post action=/candidates/${c.id}/link><input name=incident_id placeholder="incident UUID"><button>link</button></form></div>`).join('')}`,
),
);
return true;
}
const link = path.match(/^\/candidates\/(\d+)\/link$/);
if (link && req.method === 'POST') {
const f = await form(req);
db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(
f.incident_id,
link[1],
);
redirect(res, '/candidates');
return true;
}
return false;
}
return { handle };
}
+71
View File
@@ -0,0 +1,71 @@
// The read-only, unauthenticated projection of published incidents —
// consumed by goonk's /failures page at build and runtime. Never touches
// an incident until it has been explicitly published (see
// routes/incidents.mjs's publish/unpublish action); an unpublished
// incident is invisible here regardless of how much of it is filled in.
import { publicIncident, publicDetail, publicMarkdown } from '../views.mjs';
const jsonHeaders = {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=60',
};
export function createPublicFailuresRoutes(db) {
async function handle(req, res, path) {
if (req.method !== 'GET') return false;
if (path === '/api/v1/public/failures') {
const rows = db
.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC")
.all();
res.writeHead(200, jsonHeaders).end(
JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
failures: rows.map(publicIncident),
}),
);
return true;
}
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
if (md) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(md[1]));
if (!x) {
res.writeHead(404).end('Not found');
return true;
}
res
.writeHead(200, { ...jsonHeaders, 'content-type': 'text/markdown; charset=utf-8' })
.end(publicMarkdown(x));
return true;
}
const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/);
if (detail) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(detail[1]));
if (!x) {
res
.writeHead(404, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
})
.end('{"error":"not_found"}');
return true;
}
res
.writeHead(200, jsonHeaders)
.end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) }));
return true;
}
return false;
}
return { handle };
}
+51 -448
View File
@@ -1,62 +1,14 @@
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import crypto from 'node:crypto';
import { openDb } from './db.mjs';
import { STYLE_CSS } from './style.mjs';
import { layout } from './views.mjs';
import { createReceiptStore, renderEventsPage } from './receipts.mjs';
import { scanGiteaCandidates } from './gitea.mjs';
import { createIncidentRoutes } from './routes/incidents.mjs';
import { createPublicFailuresRoutes } from './routes/public-failures.mjs';
const port = Number(process.env.PORT || 3082);
const password = process.env.TRACE_PASSWORD || '';
const exec = promisify(execFile),
port = Number(process.env.PORT || 3082),
password = process.env.TRACE_PASSWORD || '';
mkdirSync('data', { recursive: true });
mkdirSync('data/exports', { recursive: true });
const db = new DatabaseSync('data/trace.db');
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,title TEXT NOT NULL,project TEXT,status TEXT NOT NULL DEFAULT 'investigating',severity TEXT NOT NULL DEFAULT 'annoyance',detected_at TEXT NOT NULL,symptom TEXT NOT NULL,impact TEXT DEFAULT '',root_cause TEXT DEFAULT '',confidence TEXT DEFAULT 'unknown',fix TEXT DEFAULT '',verification TEXT DEFAULT '',prevention TEXT DEFAULT '',remaining_risk TEXT DEFAULT '',lesson TEXT DEFAULT '',public_summary TEXT DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,at TEXT NOT NULL,kind TEXT NOT NULL,body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha));
CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`);
for (const sql of [
"ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''",
"ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''",
]) {
try {
db.exec(sql);
} catch (error) {
if (!String(error.message).includes('duplicate column')) throw error;
}
}
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
const repos = new Map(
(process.env.TRACE_REPOS || '')
.split(',')
.filter(Boolean)
.map((x) => {
const i = x.indexOf(':');
return [x.slice(0, i), x.slice(i + 1)];
}),
);
const giteaRepos = (process.env.TRACE_GITEA_REPOS || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean),
giteaUrl = process.env.TRACE_GITEA_URL || 'https://repo.explewd.com',
giteaToken = process.env.TRACE_GITEA_TOKEN || '';
const esc = (s) =>
String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const form = async (req) => {
let b = '';
for await (const c of req) b += c;
return Object.fromEntries(new URLSearchParams(b));
};
function auth(req, res) {
if (!password) return true;
const expected = 'Basic ' + Buffer.from('trace:' + password).toString('base64');
@@ -64,409 +16,60 @@ function auth(req, res) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="trace"' }).end('Authentication required');
return false;
}
function layout(title, body) {
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${esc(title)} · trace</title><link rel=stylesheet href=/style.css></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main>${body}</main><footer>Private by default. Git discovers; a human explains.</footer><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){document.querySelector('form[action$="/export"]')?.remove();const controls=document.createElement('div');controls.className='actions publication-actions';controls.innerHTML='<form method="post" action="/incidents/'+incident[1]+'/publish"><button>publish / update reviewed article</button></form><form method="post" action="/incidents/'+incident[1]+'/unpublish"><button>unpublish</button></form>';document.querySelector('main h1')?.after(controls)}</script></body></html>`;
}
const fields = [
'impact',
'root_cause',
'fix',
'verification',
'prevention',
'remaining_risk',
'lesson',
'public_summary',
];
const slugify = (s) =>
String(s || 'failure')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'failure';
const publicSeverity = {
annoyance: 'paper-cut',
degraded: 'minor',
unavailable: 'significant',
'data-risk': 'significant',
'security-risk': 'significant',
};
const publicStatus = {
investigating: 'open',
mitigated: 'worked-around',
resolved: 'resolved',
abandoned: 'abandoned',
};
function publicMarkdown(x) {
return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${publicSeverity[x.severity] || 'minor'}\nstatus: ${publicStatus[x.status] || 'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
}
function publicIncident(x) {
return {
id: x.id,
slug: x.public_slug,
title: x.title,
summary: x.public_summary || x.symptom,
date: x.detected_at.slice(0, 10),
projectSlug: x.project || '',
kind: 'bug',
severity: publicSeverity[x.severity] || 'minor',
status: publicStatus[x.status] || 'open',
publishedAt: x.published_at,
updatedAt: x.updated_at,
markdownUrl: `/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`,
};
}
function publicDetail(x) {
return {
...publicIncident(x),
sections: [
['Symptom', x.symptom],
['What was actually happening', x.root_cause],
['How we proved it', x.verification],
['The fix', x.fix],
['What changed afterward', x.prevention],
['Remaining risk', x.remaining_risk],
['Lesson', x.lesson],
].filter(([, body]) => body),
};
}
function incidentForm(v = {}) {
return `<form method=post class=stack><label>Title<input name=title required value="${esc(v.title)}"></label><div class=cols><label>Project<input name=project value="${esc(v.project)}"></label><label>Detected<input type=datetime-local name=detected_at required value="${esc((v.detected_at || new Date().toISOString()).slice(0, 16))}"></label><label>Status<select name=status>${['investigating', 'mitigated', 'resolved', 'abandoned'].map((x) => `<option ${v.status === x ? 'selected' : ''}>${x}</option>`)}</select></label><label>Severity<select name=severity>${['annoyance', 'degraded', 'unavailable', 'data-risk', 'security-risk'].map((x) => `<option ${v.severity === x ? 'selected' : ''}>${x}</option>`)}</select></label></div><label>Symptom<textarea name=symptom required>${esc(v.symptom)}</textarea></label>${fields.map((x) => `<label>${x.replaceAll('_', ' ')}<textarea name=${x}>${esc(v[x])}</textarea></label>`).join('')}<label>Confidence<select name=confidence>${['unknown', 'low', 'medium', 'high'].map((x) => `<option ${v.confidence === x ? 'selected' : ''}>${x}</option>`)}</select></label><button>save</button></form>`;
}
function redirect(res, to) {
res.writeHead(303, { Location: to }).end();
}
async function scan() {
for (const [id, path] of repos) {
let stdout;
try {
stdout = (
await exec(
'git',
[
'-C',
path,
'log',
'--all',
'--no-merges',
'-n',
'300',
'--pretty=format:%H%x1f%ad%x1f%s',
'--date=iso-strict',
],
{ maxBuffer: 2e6 },
)
).stdout;
} catch (error) {
console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`);
continue;
}
for (const line of stdout.split('\n')) {
const [sha, date, subject] = line.split('\x1f');
if (
!/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test(
subject || '',
)
)
continue;
let paths = '';
try {
paths = (
await exec('git', ['-C', path, 'show', '--name-only', '--pretty=format:', sha], {
maxBuffer: 2e5,
})
).stdout.trim();
} catch {}
db.prepare(
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
).run(id, sha, date, subject, paths, 'local git keyword');
}
}
const remote = await scanGiteaCandidates({
baseUrl: giteaUrl,
repositories: giteaRepos,
token: giteaToken,
});
for (const candidate of remote.candidates)
db.prepare(
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
).run(
candidate.repo,
candidate.sha,
candidate.committedAt,
candidate.subject,
candidate.paths,
candidate.reason,
);
for (const error of remote.errors)
console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`);
}
async function handler(req, res) {
if (!auth(req, res)) return;
const u = new URL(req.url, 'http://x');
if (u.pathname === '/style.css') {
res
.writeHead(200, { 'content-type': 'text/css' })
.end(
`:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`,
);
return;
}
if (req.method === 'GET' && u.pathname === '/') {
const rows = db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();
const counts = db.prepare('SELECT status,count(*) n FROM incidents GROUP BY status').all();
res.end(
layout(
'incidents',
`<h1>Incident notebook</h1><p class=meta>${counts.map((x) => `${x.status}: ${x.n}`).join(' · ') || 'No incidents yet.'}</p>${rows.map((x) => `<a class=card style=display:block href=/incidents/${x.id}><b>${esc(x.title)}</b><p>${esc(x.symptom)}</p><span class=badge>${x.status}</span> <span class=meta>${esc(x.project)} · ${esc(x.detected_at)}</span></a>`).join('')}`,
),
);
return;
}
if (req.method === 'GET' && u.pathname === '/incidents/new') {
res.end(layout('new incident', '<h1>Record what happened</h1>' + incidentForm()));
return;
}
if (req.method === 'POST' && u.pathname === '/incidents/new') {
const f = await form(req),
id = crypto.randomUUID(),
now = new Date().toISOString();
db.prepare(
`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`,
).run(
id,
f.title,
f.project || '',
f.status,
f.severity,
f.detected_at,
f.symptom,
...fields.slice(0, 2).map((x) => f[x] || ''),
f.confidence || 'unknown',
...fields.slice(2).map((x) => f[x] || ''),
now,
now,
);
redirect(res, '/incidents/' + id);
return;
}
const match = u.pathname.match(/^\/incidents\/([^/]+)$/);
if (match) {
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
if (!x) {
res.writeHead(404).end();
return;
}
if (req.method === 'POST') {
const f = await form(req);
db.prepare(
`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map((x) => x + '=?').join(',')},confidence=?,updated_at=? WHERE id=?`,
).run(
f.title,
f.project || '',
f.status,
f.severity,
f.detected_at,
f.symptom,
...fields.map((x) => f[x] || ''),
f.confidence || 'unknown',
new Date().toISOString(),
x.id,
);
redirect(res, u.pathname);
return;
}
const timeline = db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id),
hyps = db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id),
commits = db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);
res.end(
layout(
x.title,
`<h1>${esc(x.title)}</h1><div class=actions><form method=post action=/incidents/${x.id}/export><button>export public draft</button></form></div>${incidentForm(x)}<section class=card><h2>Timeline</h2><div class=timeline>${timeline.map((t) => `<p><b>${esc(t.kind)}</b> ${esc(t.at)}<br>${esc(t.body)}</p>`).join('')}</div><form method=post action=/incidents/${x.id}/timeline><div class=cols><input type=datetime-local name=at required><select name=kind>${['observation', 'hypothesis', 'action', 'result', 'decision', 'deploy', 'note'].map((k) => `<option>${k}</option>`)}</select></div><textarea name=body required></textarea><button>append</button></form></section><section class=card><h2>Hypotheses</h2>${hyps.map((h) => `<p><b>${esc(h.statement)}</b> <span class=badge>${h.status}</span><br>${esc(h.evidence)}</p>`).join('')}<form method=post action=/incidents/${x.id}/hypotheses><input name=statement required placeholder="We think…"><select name=status>${['untested', 'supported', 'rejected', 'inconclusive'].map((k) => `<option>${k}</option>`)}</select><textarea name=evidence placeholder=evidence></textarea><button>add</button></form></section><section class=card><h2>Linked commits</h2>${commits.map((c) => `<p><code>${c.sha.slice(0, 7)}</code> ${esc(c.subject)}</p>`).join('') || '<p class=meta>None.</p>'}</section>`,
),
);
return;
}
const sub = u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);
if (req.method === 'POST' && sub) {
const f = await form(req),
id = sub[1];
if (sub[2] === 'timeline')
db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(
id,
f.at,
f.kind,
f.body,
);
if (sub[2] === 'hypotheses')
db.prepare(
'INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)',
).run(id, f.statement, f.status, f.evidence);
if (sub[2] === 'export') {
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(id);
const slug = x.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const md = `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n<!-- Add reviewed hypothesis -->\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
writeFileSync(join('data/exports', slug + '.md'), md);
res.end(
layout(
'export',
`<h1>Public draft exported</h1><p><code>data/exports/${esc(slug)}.md</code></p><pre>${esc(md)}</pre>`,
),
);
return;
}
redirect(res, '/incidents/' + id);
return;
}
if (u.pathname === '/candidates' && req.method === 'POST') {
await scan();
redirect(res, '/candidates');
return;
}
if (u.pathname === '/candidates') {
const rows = db
.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC")
.all();
res.end(
layout(
'git inbox',
`<h1>Git inbox</h1><form method=post><button>scan configured repositories</button></form>${rows.map((c) => `<div class=card><b>${esc(c.subject)}</b><p class=meta>${esc(c.repo)} · ${c.sha.slice(0, 10)} · ${esc(c.committed_at)}</p><details><summary>changed paths</summary><pre>${esc(c.paths)}</pre></details><form method=post action=/candidates/${c.id}/link><input name=incident_id placeholder="incident UUID"><button>link</button></form></div>`).join('')}`,
),
);
return;
}
const link = u.pathname.match(/^\/candidates\/(\d+)\/link$/);
if (link && req.method === 'POST') {
const f = await form(req);
db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(
f.incident_id,
link[1],
);
redirect(res, '/candidates');
return;
}
if (u.pathname === '/api/receipts' && req.method === 'POST') {
let raw = '';
for await (const c of req) raw += c;
const x = JSON.parse(raw);
db.prepare(
'INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)',
).run(x.project, x.commit, x.deployedAt, x.environment || '', x.health || '', raw);
res.writeHead(201, { 'content-type': 'application/json' }).end('{"ok":true}');
return;
}
res.writeHead(404).end(layout('not found', '<h1>Not found</h1>'));
}
async function publicContent(req, res, path) {
if (req.method === 'GET' && path === '/api/v1/public/failures') {
const rows = db
.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC")
.all();
res
.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=60',
})
.end(
JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
failures: rows.map(publicIncident),
const db = openDb();
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
const publicFailures = createPublicFailuresRoutes(db);
const incidents = createIncidentRoutes({
db,
auth,
repos: new Map(
(process.env.TRACE_REPOS || '')
.split(',')
.filter(Boolean)
.map((x) => {
const i = x.indexOf(':');
return [x.slice(0, i), x.slice(i + 1)];
}),
);
return true;
}
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
if (req.method === 'GET' && md) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(md[1]));
if (!x) {
res.writeHead(404).end('Not found');
return true;
}
res
.writeHead(200, {
'content-type': 'text/markdown; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=60',
})
.end(publicMarkdown(x));
return true;
}
const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/);
if (req.method === 'GET' && detail) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(detail[1]));
if (!x) {
res
.writeHead(404, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
})
.end('{"error":"not_found"}');
return true;
}
res
.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=60',
})
.end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) }));
return true;
}
return false;
}
async function publicationAction(req, res, path) {
const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/);
if (!match || req.method !== 'POST') return false;
if (!auth(req, res)) return true;
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
if (!x) {
res.writeHead(404).end();
return true;
}
if (match[2] === 'unpublish')
db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run(
new Date().toISOString(),
x.id,
);
else {
let slug = x.public_slug || slugify(x.title);
const collision = db
.prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?')
.get(slug, x.id);
if (collision) slug = `${slug}-${x.id.slice(0, 8)}`;
const now = new Date().toISOString();
db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run(
slug,
now,
now,
x.id,
);
}
redirect(res, `/incidents/${x.id}`);
return true;
}
),
giteaUrl: process.env.TRACE_GITEA_URL || 'https://repo.explewd.com',
giteaRepos: (process.env.TRACE_GITEA_REPOS || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean),
giteaToken: process.env.TRACE_GITEA_TOKEN || '',
});
// Request dispatch order matters: public API and receipt ingestion are
// checked first since neither uses Basic Auth (the public API is meant to
// be open, receipts carry their own bearer token) — putting them behind
// incidents.handle's auth() gate would needlessly 401 a legitimate CI
// runner or a public /failures reader. incidents.handle is the true
// fallback: it auth-gates and owns everything else, including the final
// 404.
http
.createServer(async (req, res) => {
try {
const path = new URL(req.url, 'http://x').pathname;
if (await publicContent(req, res, path)) return;
if (await publicationAction(req, res, path)) return;
if (path === '/style.css') {
if (!auth(req, res)) return;
res.writeHead(200, { 'content-type': 'text/css' }).end(STYLE_CSS);
return;
}
if (await publicFailures.handle(req, res, path)) return;
if (path === '/events') {
if (auth(req, res)) res.end(renderEventsPage(db));
return;
}
const handled = await receiptStore.handle(req, res, path);
if (handled === false) await handler(req, res);
if ((await receiptStore.handle(req, res, path)) !== false) return;
if (await incidents.handle(req, res, path)) return;
res.writeHead(404).end(layout('not found', '<h1>Not found</h1>'));
} catch (e) {
console.error(e);
if (!res.headersSent) res.writeHead(500);
+7
View File
@@ -0,0 +1,7 @@
// Single shared stylesheet for every HTML page trace serves (the private
// notebook and the operational-events feed used to carry their own nearly
// identical copies — that duplication is exactly what let their <nav>
// definitions drift out of sync). Anything page-specific that would collide
// under a shared selector (the events feed's small count pills vs. the
// notebook's status badges) gets its own class instead of overloading .badge.
export const STYLE_CSS = `:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff;--ok:#3fb950;--bad:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}.summary{display:flex;gap:8px;flex-wrap:wrap}.pill{border:1px solid var(--l);padding:3px 8px;border-radius:99px;color:var(--m)}.event{background:var(--p);border:1px solid var(--l);border-left:3px solid var(--a);padding:16px;border-radius:8px;margin:10px 0}.event.failed{border-left-color:var(--bad)}.event.success{border-left-color:var(--ok)}.top{display:flex;justify-content:space-between;gap:12px;align-items:start}.result.success{color:var(--ok)}.result.failed{color:var(--bad)}code,pre{background:var(--b);border:1px solid var(--l);border-radius:5px}code{padding:2px 5px}pre{padding:12px;overflow:auto;max-height:360px;white-space:pre-wrap}details{margin-top:10px}h1{margin-bottom:5px}@media(max-width:650px){.cols{grid-template-columns:1fr}}@media(max-width:600px){.top{display:block}}`;
+87
View File
@@ -0,0 +1,87 @@
// Pure rendering helpers for the private incident notebook and its public
// projection. Nothing here touches the database or the network — every
// function takes plain data in and returns a string out, which is what
// keeps this file safe to read (and test) without a running server.
export const esc = (s) =>
String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
export const fields = [
'impact',
'root_cause',
'fix',
'verification',
'prevention',
'remaining_risk',
'lesson',
'public_summary',
];
export const publicSeverity = {
annoyance: 'paper-cut',
degraded: 'minor',
unavailable: 'significant',
'data-risk': 'significant',
'security-risk': 'significant',
};
export const publicStatus = {
investigating: 'open',
mitigated: 'worked-around',
resolved: 'resolved',
abandoned: 'abandoned',
};
export const slugify = (s) =>
String(s || 'failure')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'failure';
export function layout(title, body) {
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${esc(title)} · trace</title><link rel=stylesheet href=/style.css></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main>${body}</main><footer>Private by default. Git discovers; a human explains.</footer><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){document.querySelector('form[action$="/export"]')?.remove();const controls=document.createElement('div');controls.className='actions publication-actions';controls.innerHTML='<form method="post" action="/incidents/'+incident[1]+'/publish"><button>publish / update reviewed article</button></form><form method="post" action="/incidents/'+incident[1]+'/unpublish"><button>unpublish</button></form>';document.querySelector('main h1')?.after(controls)}</script></body></html>`;
}
export function incidentForm(v = {}) {
return `<form method=post class=stack><label>Title<input name=title required value="${esc(v.title)}"></label><div class=cols><label>Project<input name=project value="${esc(v.project)}"></label><label>Detected<input type=datetime-local name=detected_at required value="${esc((v.detected_at || new Date().toISOString()).slice(0, 16))}"></label><label>Status<select name=status>${['investigating', 'mitigated', 'resolved', 'abandoned'].map((x) => `<option ${v.status === x ? 'selected' : ''}>${x}</option>`)}</select></label><label>Severity<select name=severity>${['annoyance', 'degraded', 'unavailable', 'data-risk', 'security-risk'].map((x) => `<option ${v.severity === x ? 'selected' : ''}>${x}</option>`)}</select></label></div><label>Symptom<textarea name=symptom required>${esc(v.symptom)}</textarea></label>${fields.map((x) => `<label>${x.replaceAll('_', ' ')}<textarea name=${x}>${esc(v[x])}</textarea></label>`).join('')}<label>Confidence<select name=confidence>${['unknown', 'low', 'medium', 'high'].map((x) => `<option ${v.confidence === x ? 'selected' : ''}>${x}</option>`)}</select></label><button>save</button></form>`;
}
export function publicMarkdown(x) {
return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${publicSeverity[x.severity] || 'minor'}\nstatus: ${publicStatus[x.status] || 'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
}
export function publicIncident(x) {
return {
id: x.id,
slug: x.public_slug,
title: x.title,
summary: x.public_summary || x.symptom,
date: x.detected_at.slice(0, 10),
projectSlug: x.project || '',
kind: 'bug',
severity: publicSeverity[x.severity] || 'minor',
status: publicStatus[x.status] || 'open',
publishedAt: x.published_at,
updatedAt: x.updated_at,
markdownUrl: `/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`,
};
}
export function publicDetail(x) {
return {
...publicIncident(x),
sections: [
['Symptom', x.symptom],
['What was actually happening', x.root_cause],
['How we proved it', x.verification],
['The fix', x.fix],
['What changed afterward', x.prevention],
['Remaining risk', x.remaining_risk],
['Lesson', x.lesson],
].filter(([, body]) => body),
};
}