Publish reviewed failure content feed
All checks were successful
Docker / test-build-push (push) Successful in 39s
All checks were successful
Docker / test-build-push (push) Successful in 39s
This commit is contained in:
@@ -7,7 +7,7 @@ cp .env.example .env
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`. Nothing publishes automatically: export creates a draft under `data/exports/` for manual review.
|
Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`. Incidents remain private until a human explicitly publishes them. Published, sanitized Markdown is available through `GET /api/v1/public/failures` and `GET /api/v1/public/failures/:slug.md` for build-time consumers.
|
||||||
|
|
||||||
Git candidate scanning is optional and disabled by default. If an installation deliberately mounts read-only repository mirrors, list their container paths in `TRACE_REPOS`; production receipt ingestion does not require repository access.
|
Git candidate scanning is optional and disabled by default. If an installation deliberately mounts read-only repository mirrors, list their container paths in `TRACE_REPOS`; production receipt ingestion does not require repository access.
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,20 @@ CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,inciden
|
|||||||
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 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 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));`);
|
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 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 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 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 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));};
|
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 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>`;}
|
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><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){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 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 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 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();}
|
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 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}`)}
|
||||||
@@ -38,4 +44,23 @@ if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/
|
|||||||
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;}
|
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;}
|
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>'));}
|
res.writeHead(404).end(layout('not found','<h1>Not found</h1>'));}
|
||||||
http.createServer(async(req,res)=>{try{const path=new URL(req.url,'http://x').pathname;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);}catch(e){console.error(e);if(!res.headersSent)res.writeHead(500);res.end('Internal error');}}).listen(port,()=>console.log(`trace listening on ${port}`));
|
|
||||||
|
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)}));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;}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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==='/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);}catch(e){console.error(e);if(!res.headersSent)res.writeHead(500);res.end('Internal error');}}).listen(port,()=>console.log(`trace listening on ${port}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user