Show operational events in private UI
All checks were successful
Docker / test-build-push (push) Successful in 36s

This commit is contained in:
Fredrik Johansson
2026-07-20 21:39:23 +02:00
parent 92dc59584b
commit 825cd4bb74
2 changed files with 5 additions and 2 deletions

View File

@@ -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; 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}; }return{handle,publicEvent};
} }
const escapeHtml=value=>String(value??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
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`<!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}.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=/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>${rows.map(row=>{const payload=JSON.parse(row.payload),artifacts=payload.artifacts||[];return`<article class="event ${escapeHtml(row.result)}"><div class=top><div><b>${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}</b><p class=meta><code>${escapeHtml(row.source_commit.slice(0,12))}</code> ${escapeHtml(row.source_branch)} ${row.environment?`· ${escapeHtml(row.environment)}`:''}</p></div><span class="result ${escapeHtml(row.result)}">${escapeHtml(row.result)}</span></div><p class=meta>${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}</p>${artifacts.map(x=>`<p><b>${escapeHtml(x.component)}</b> <code>${escapeHtml(x.digest||'digest unavailable')}</code></p>`).join('')}<details><summary>raw private receipt</summary><pre>${escapeHtml(JSON.stringify(payload,null,2))}</pre></details></article>`}).join('')||'<p>No operational events received yet.</p>'}</main></body></html>`}

View File

@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import crypto from 'node:crypto'; 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 || ''; const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || '';
mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true }); 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;} 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;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}`));