Build private incident notebook MVP

This commit is contained in:
Fredrik Johansson
2026-07-20 20:25:17 +02:00
commit 97ac266cd0
7 changed files with 74 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=3082
TRACE_PASSWORD=replace-me
# Comma-separated id:absolute-path pairs. Mount paths read-only in Docker.
TRACE_REPOS=goonk:/repos/goonk,flit:/repos/flit

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
data/*.db*
data/exports/
.env

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:24-alpine
RUN apk add --no-cache git
WORKDIR /app
COPY package.json ./
COPY src ./src
RUN mkdir -p data/exports
EXPOSE 3082
CMD ["node","src/server.mjs"]

10
README.md Normal file
View File

@@ -0,0 +1,10 @@
# trace
Private incident notebook, Git fix-candidate inbox, deployment-receipt evidence store, and reviewed Astro Markdown exporter for goonk's `/failures` page.
```bash
cp .env.example .env
npm start
```
Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`; configure read-only repositories through `TRACE_REPOS`. Nothing publishes automatically: export creates a draft under `data/exports/` for manual review.

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
trace:
build: .
restart: unless-stopped
env_file: .env
ports: ["${HOST_PORT:-3082}:3082"]
volumes:
- trace-data:/app/data
- ../goonk:/repos/goonk:ro
volumes:
trace-data:

1
package.json Normal file
View File

@@ -0,0 +1 @@
{"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"},"engines":{"node":">=24"}}

37
src/server.mjs Normal file
View File

@@ -0,0 +1,37 @@
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';
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));`);
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 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');if(req.headers.authorization===expected)return true;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></nav></header><main>${body}</main><footer>Private by default. Git discovers; a human explains.</footer></body></html>`;}
const fields=['impact','root_cause','fix','verification','prevention','remaining_risk','lesson','public_summary'];
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){const {stdout}=await exec('git',['-C',path,'log','--all','--no-merges','-n','300','--pretty=format:%H%x1f%ad%x1f%s','--date=iso-strict'],{maxBuffer:2e6});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,'keyword');}}}
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>'));}
http.createServer((req,res)=>handler(req,res).catch(e=>{console.error(e);res.writeHead(500).end('Internal error');})).listen(port,()=>console.log(`trace listening on ${port}`));