Link /events from the main nav
All checks were successful
Docker / test-build-push (push) Successful in 36s

The operational-events page existed and worked but was never linked
from the incident notebook (or vice versa) — reachable only if you
already knew the URL. Both layouts now share the same three-item nav.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-21 03:58:45 +02:00
parent 31b2372ad3
commit 8e033358c4
2 changed files with 2 additions and 2 deletions

View File

@@ -16,4 +16,4 @@ export function createReceiptStore(db,config=''){
} }
const escapeHtml=value=>String(value??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;'); 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>`} 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=/incidents/new>new incident</a><a href=/candidates>git inbox</a><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

@@ -24,7 +24,7 @@ const giteaRepos=(process.env.TRACE_GITEA_REPOS||'').split(',').map(x=>x.trim())
const esc=s=>String(s??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;'); 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));}; 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><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){document.querySelector('form[action$="/export"]')?.remove();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>`;} 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><a href=/events>operational events</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){document.querySelector('form[action$="/export"]')?.remove();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 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 publicSeverity={annoyance:'paper-cut',degraded:'minor',unavailable:'significant','data-risk':'significant','security-risk':'significant'};