diff --git a/src/receipts.mjs b/src/receipts.mjs
index ab7e2a0..262281b 100644
--- a/src/receipts.mjs
+++ b/src/receipts.mjs
@@ -14,3 +14,6 @@ export function createReceiptStore(db,config=''){
if(path==='/api/v1/public/projects'&&req.method==='GET'){const rows=db.prepare(`SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`).all();return send(res,200,{projects:rows.map(publicEvent)})}return false;
}return{handle,publicEvent};
}
+
+const escapeHtml=value=>String(value??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');
+export function renderEventsPage(db){const rows=db.prepare('SELECT * FROM deployment_events ORDER BY occurred_at DESC LIMIT 100').all();const counts=db.prepare('SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result').all();return`
operational events · traceOperational events
Builds say what CI produced. Deployments say what a host actually ran.
${counts.map(x=>`${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}`).join('')}
${rows.map(row=>{const payload=JSON.parse(row.payload),artifacts=payload.artifacts||[];return`${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}${escapeHtml(row.source_commit.slice(0,12))} ${escapeHtml(row.source_branch)} ${row.environment?`· ${escapeHtml(row.environment)}`:''}
${escapeHtml(row.result)}${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}
${artifacts.map(x=>`${escapeHtml(x.component)} ${escapeHtml(x.digest||'digest unavailable')}
`).join('')}raw private receipt
${escapeHtml(JSON.stringify(payload,null,2))} `}).join('')||'No operational events received yet.
'}`}
diff --git a/src/server.mjs b/src/server.mjs
index 436a6c5..c76c533 100644
--- a/src/server.mjs
+++ b/src/server.mjs
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import crypto from 'node:crypto';
-import { createReceiptStore } from './receipts.mjs';
+import { createReceiptStore, renderEventsPage } from './receipts.mjs';
const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || '';
mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true });
@@ -36,4 +36,4 @@ 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;}
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','Not found
'));}
-http.createServer(async(req,res)=>{try{const path=new URL(req.url,'http://x').pathname;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}`));
+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}`));