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:
co-authored by
Claude Sonnet 5
parent
4b7b5a42d3
commit
0d825b4458
+17
-1
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
'SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result',
|
||||||
)
|
)
|
||||||
.all();
|
.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
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const payload = JSON.parse(row.payload),
|
const payload = JSON.parse(row.payload),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -1,62 +1,14 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { openDb } from './db.mjs';
|
||||||
import { execFile } from 'node:child_process';
|
import { STYLE_CSS } from './style.mjs';
|
||||||
import { promisify } from 'node:util';
|
import { layout } from './views.mjs';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
import { createReceiptStore, renderEventsPage } from './receipts.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('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"');
|
|
||||||
const form = async (req) => {
|
|
||||||
let b = '';
|
|
||||||
for await (const c of req) b += c;
|
|
||||||
return Object.fromEntries(new URLSearchParams(b));
|
|
||||||
};
|
|
||||||
function auth(req, res) {
|
function auth(req, res) {
|
||||||
if (!password) return true;
|
if (!password) return true;
|
||||||
const expected = 'Basic ' + Buffer.from('trace:' + password).toString('base64');
|
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');
|
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="trace"' }).end('Authentication required');
|
||||||
return false;
|
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) {
|
const db = openDb();
|
||||||
if (req.method === 'GET' && path === '/api/v1/public/failures') {
|
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
|
||||||
const rows = db
|
const publicFailures = createPublicFailuresRoutes(db);
|
||||||
.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC")
|
const incidents = createIncidentRoutes({
|
||||||
.all();
|
db,
|
||||||
res
|
auth,
|
||||||
.writeHead(200, {
|
repos: new Map(
|
||||||
'content-type': 'application/json; charset=utf-8',
|
(process.env.TRACE_REPOS || '')
|
||||||
'access-control-allow-origin': '*',
|
.split(',')
|
||||||
'cache-control': 'public, max-age=60',
|
.filter(Boolean)
|
||||||
})
|
.map((x) => {
|
||||||
.end(
|
const i = x.indexOf(':');
|
||||||
JSON.stringify({
|
return [x.slice(0, i), x.slice(i + 1)];
|
||||||
schemaVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
failures: rows.map(publicIncident),
|
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
return true;
|
giteaUrl: process.env.TRACE_GITEA_URL || 'https://repo.explewd.com',
|
||||||
}
|
giteaRepos: (process.env.TRACE_GITEA_REPOS || '')
|
||||||
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
|
.split(',')
|
||||||
if (req.method === 'GET' && md) {
|
.map((x) => x.trim())
|
||||||
const x = db
|
.filter(Boolean),
|
||||||
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
|
giteaToken: process.env.TRACE_GITEA_TOKEN || '',
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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
|
http
|
||||||
.createServer(async (req, res) => {
|
.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const path = new URL(req.url, 'http://x').pathname;
|
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 (path === '/events') {
|
||||||
if (auth(req, res)) res.end(renderEventsPage(db));
|
if (auth(req, res)) res.end(renderEventsPage(db));
|
||||||
return;
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
if (!res.headersSent) res.writeHead(500);
|
if (!res.headersSent) res.writeHead(500);
|
||||||
|
|||||||
@@ -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}}`;
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user