Build private incident notebook MVP
This commit is contained in:
37
src/server.mjs
Normal file
37
src/server.mjs
Normal 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('&','&').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){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}`));
|
||||
Reference in New Issue
Block a user