@wopr-network/defcon 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export declare const UI_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>DEFCON Dashboard</title>\n<style>\n*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\nbody { background: #0d1117; color: #c9d1d9; font-family: 'Courier New', monospace; font-size: 14px; }\n#auth-overlay { position: fixed; inset: 0; background: #0d1117; display: flex; align-items: center; justify-content: center; z-index: 100; }\n#auth-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; }\n#auth-box h2 { color: #58a6ff; margin-bottom: 16px; font-size: 18px; }\n#auth-box input { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 12px; }\n#auth-box button { width: 100%; background: #238636; border: none; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; font-size: 14px; }\n#auth-box button:hover { background: #2ea043; }\nnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; align-items: center; gap: 0; }\nnav h1 { color: #58a6ff; font-size: 16px; margin-right: 32px; padding: 14px 0; }\n.tab { background: none; border: none; color: #8b949e; padding: 14px 16px; cursor: pointer; font-family: inherit; font-size: 14px; border-bottom: 2px solid transparent; }\n.tab:hover { color: #c9d1d9; }\n.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }\n.tab-content { display: none; padding: 24px; }\n.tab-content.active { display: block; }\n.search-row { display: flex; gap: 8px; margin-bottom: 20px; }\n.search-row input { flex: 1; background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; }\n.search-row button, .btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 14px; }\n.search-row button:hover, .btn:hover { background: #30363d; }\n.timeline { display: flex; flex-direction: column; gap: 0; }\n.timeline-item { display: flex; gap: 16px; padding: 12px 0; border-bottom: 1px solid #21262d; }\n.timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: #58a6ff; margin-top: 5px; flex-shrink: 0; }\n.timeline-dot.gate-pass { background: #3fb950; }\n.timeline-dot.gate-fail { background: #f85149; }\n.timeline-dot.invocation { background: #d2a8ff; }\n.timeline-body { flex: 1; }\n.timeline-ts { color: #8b949e; font-size: 12px; margin-bottom: 4px; }\n.timeline-label { font-weight: bold; color: #e6edf3; }\n.timeline-sub { color: #8b949e; font-size: 12px; margin-top: 2px; }\n.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold; }\n.badge-pass { background: #0d4429; color: #3fb950; }\n.badge-fail { background: #490202; color: #f85149; }\n.badge-pending { background: #1c2a3a; color: #58a6ff; }\n.badge-complete { background: #0d2d0d; color: #3fb950; }\n.badge-amber { background: #2d1f00; color: #e3b341; }\n.flow-select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 20px; }\n.flow-graph { position: relative; display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; min-height: 200px; }\n.state-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; min-width: 140px; }\n.state-box.initial { border-color: #58a6ff; }\n.state-box.terminal { border-color: #3fb950; }\n.state-name { font-weight: bold; color: #e6edf3; margin-bottom: 4px; }\n.state-count { color: #8b949e; font-size: 12px; }\n.workers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }\n.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }\n.stat-card .label { color: #8b949e; font-size: 12px; margin-bottom: 4px; }\n.stat-card .value { color: #e6edf3; font-size: 24px; font-weight: bold; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; padding: 8px 12px; color: #8b949e; font-size: 12px; border-bottom: 1px solid #30363d; }\ntd { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; }\ntd.ts { color: #8b949e; font-size: 12px; white-space: nowrap; }\ntd.type-cell { color: #d2a8ff; font-size: 12px; white-space: nowrap; }\ntd.entity-cell { color: #58a6ff; font-size: 12px; font-family: monospace; }\ntd.payload-cell { color: #8b949e; font-size: 11px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }\ntd.payload-cell.expanded { white-space: pre-wrap; word-break: break-all; }\n.filter-row { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }\n.filter-row select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-family: inherit; font-size: 13px; }\n.filter-row label { color: #8b949e; font-size: 13px; }\n#sse-status { position: fixed; bottom: 12px; right: 16px; font-size: 11px; color: #8b949e; }\n#sse-status.connected { color: #3fb950; }\n#sse-status.error { color: #f85149; }\n.empty { color: #8b949e; text-align: center; padding: 32px; }\n.error-msg { color: #f85149; margin: 8px 0; font-size: 13px; }\n</style>\n</head>\n<body>\n\n<div id=\"auth-overlay\">\n <div id=\"auth-box\">\n <h2>DEFCON</h2>\n <p style=\"color:#8b949e;margin-bottom:16px;font-size:13px;\">Enter your admin token to continue.</p>\n <input type=\"password\" id=\"token-input\" placeholder=\"Admin token\" autocomplete=\"off\">\n <div id=\"auth-error\" class=\"error-msg\" style=\"display:none\"></div>\n <button onclick=\"doLogin()\">Connect</button>\n </div>\n</div>\n\n<nav>\n <h1>DEFCON</h1>\n <button class=\"tab active\" onclick=\"showTab('entity-timeline', this)\">Timeline</button>\n <button class=\"tab\" onclick=\"showTab('flow-graph', this)\">Flow Graph</button>\n <button class=\"tab\" onclick=\"showTab('worker-dashboard', this)\">Workers</button>\n <button class=\"tab\" onclick=\"showTab('event-log', this)\">Event Log</button>\n</nav>\n\n<!-- Entity Timeline -->\n<div id=\"entity-timeline\" class=\"tab-content active\">\n <div class=\"search-row\">\n <input id=\"entity-id-input\" type=\"text\" placeholder=\"Entity ID...\" onkeydown=\"if(event.key==='Enter')loadTimeline()\">\n <button onclick=\"loadTimeline()\">Load</button>\n </div>\n <div id=\"timeline-container\"><p class=\"empty\">Enter an entity ID to view its timeline.</p></div>\n</div>\n\n<!-- Flow Graph -->\n<div id=\"flow-graph\" class=\"tab-content\">\n <select id=\"flow-select\" class=\"flow-select\" onchange=\"loadFlowGraph()\">\n <option value=\"\">-- Select a flow --</option>\n </select>\n <div id=\"graph-container\"><p class=\"empty\">Select a flow to visualize its state graph.</p></div>\n</div>\n\n<!-- Worker Dashboard -->\n<div id=\"worker-dashboard\" class=\"tab-content\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;\">\n <h2 style=\"color:#e6edf3;font-size:16px;\">Worker Dashboard</h2>\n <button class=\"btn\" onclick=\"loadDashboard()\">Refresh</button>\n </div>\n <div id=\"dashboard-container\"><p class=\"empty\">Loading...</p></div>\n</div>\n\n<!-- Event Log -->\n<div id=\"event-log\" class=\"tab-content\">\n <div class=\"filter-row\">\n <label>Filter by type:</label>\n <select id=\"event-type-filter\" onchange=\"filterEventLog()\">\n <option value=\"\">All</option>\n </select>\n <button class=\"btn\" onclick=\"loadEventLog()\">Refresh</button>\n </div>\n <div id=\"event-log-container\"><p class=\"empty\">Loading events...</p></div>\n</div>\n\n<div id=\"sse-status\">SSE: disconnected</div>\n\n<script>\nlet TOKEN = '';\nlet sseSource = null;\nlet allEvents = [];\nlet dashboardDebounceTimer = null;\n\nfunction scheduleDashboardRefresh() {\n if (dashboardDebounceTimer) clearTimeout(dashboardDebounceTimer);\n dashboardDebounceTimer = setTimeout(() => {\n dashboardDebounceTimer = null;\n if (document.getElementById('worker-dashboard').classList.contains('active')) {\n loadDashboard();\n }\n }, 100);\n}\n\nfunction ts(ms) {\n return new Date(typeof ms === 'number' ? ms : ms).toLocaleString();\n}\n\nfunction doLogin() {\n const v = document.getElementById('token-input').value.trim();\n if (!v) { showAuthError('Token required'); return; }\n TOKEN = v;\n sessionStorage.setItem('defcon-token', v);\n verifyToken();\n}\n\nfunction showAuthError(msg) {\n const el = document.getElementById('auth-error');\n el.textContent = msg;\n el.style.display = 'block';\n}\n\nfunction verifyToken() {\n fetch('/api/ui/events/recent?limit=1', { headers: { Authorization: 'Bearer ' + TOKEN } })\n .then(r => {\n if (r.ok) {\n document.getElementById('auth-overlay').style.display = 'none';\n initApp();\n } else {\n showAuthError('Invalid token');\n TOKEN = '';\n sessionStorage.removeItem('defcon-token');\n }\n })\n .catch(() => showAuthError('Connection failed'));\n}\n\nfunction initApp() {\n connectSSE();\n loadEventLog();\n loadFlowList();\n loadDashboard();\n}\n\nfunction connectSSE() {\n if (sseSource) sseSource.close();\n // Pass token via Authorization header using a fetch-based SSE reader to\n // avoid exposing it in the URL (which appears in server logs).\n // EventSource does not support custom headers, so we use fetch + ReadableStream.\n const ctrl = new AbortController();\n sseSource = ctrl; // store for close()\n fetch('/api/ui/events', { headers: { Authorization: 'Bearer ' + TOKEN }, signal: ctrl.signal })\n .then(r => {\n if (!r.ok) { handleSseError(); return; }\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: connected';\n el.className = 'connected';\n const reader = r.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n function pump() {\n reader.read().then(({ done, value }) => {\n if (done) { handleSseError(); return; }\n buf += decoder.decode(value, { stream: true });\n const lines = buf.split('\n');\n buf = lines.pop();\n for (const line of lines) {\n if (line.startsWith('data: ')) {\n try {\n const ev = JSON.parse(line.slice(6));\n prependEventRow(ev);\n scheduleDashboardRefresh();\n } catch (_) {}\n }\n }\n pump();\n }).catch(handleSseError);\n }\n pump();\n })\n .catch(handleSseError);\n function handleSseError() {\n const el = document.getElementById('sse-status');\n if (el) { el.textContent = 'SSE: reconnecting...'; el.className = 'error'; }\n setTimeout(() => connectSSE(), 5000);\n }\n}\n sseSource.onopen = () => {\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: connected';\n el.className = 'connected';\n };\n sseSource.onerror = () => {\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: reconnecting...';\n el.className = 'error';\n };\n sseSource.onmessage = (e) => {\n try {\n const ev = JSON.parse(e.data);\n prependEventRow(ev);\n if (document.getElementById('worker-dashboard').classList.contains('active')) {\n loadDashboard();\n }\n } catch (_) {}\n };\n}\n\nfunction api(path) {\n return fetch(path, { headers: { Authorization: 'Bearer ' + TOKEN } }).then(r => {\n if (!r.ok) throw new Error('HTTP ' + r.status);\n return r.json();\n });\n}\n\nfunction showTab(id, btn) {\n document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));\n document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));\n document.getElementById(id).classList.add('active');\n btn.classList.add('active');\n if (id === 'worker-dashboard') loadDashboard();\n if (id === 'flow-graph') loadFlowList();\n}\n\n// \u2500\u2500 Entity Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadTimeline() {\n const id = document.getElementById('entity-id-input').value.trim();\n if (!id) return;\n const el = document.getElementById('timeline-container');\n el.innerHTML = '<p class=\"empty\">Loading...</p>';\n try {\n const [entity, events, invocations, gates] = await Promise.all([\n api('/api/entities/' + encodeURIComponent(id)),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/events'),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/invocations'),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/gates'),\n ]);\n\n // Build merged timeline\n const rows = [];\n\n if (entity && entity.id) {\n rows.push({ t: new Date(entity.createdAt).getTime(), kind: 'entity', label: 'Entity created', sub: 'Flow: ' + entity.flowId + ' | State: ' + entity.state });\n }\n\n for (const ev of (Array.isArray(events) ? events : [])) {\n rows.push({ t: ev.emittedAt, kind: ev.type, label: ev.type, sub: JSON.stringify(ev.payload || {}).slice(0, 120) });\n }\n\n for (const inv of (Array.isArray(invocations) ? invocations : [])) {\n const st = inv.startedAt ? new Date(inv.startedAt).getTime() : (inv.createdAt ? new Date(inv.createdAt).getTime() : 0);\n rows.push({ t: st, kind: 'invocation', label: 'Invocation: ' + inv.stage, sub: 'Status: ' + (inv.completedAt ? 'completed' : inv.failedAt ? 'failed' : 'pending') + (inv.signal ? ' | signal: ' + inv.signal : '') });\n }\n\n for (const g of (Array.isArray(gates) ? gates : [])) {\n rows.push({ t: g.evaluatedAt ? new Date(g.evaluatedAt).getTime() : 0, kind: g.passed ? 'gate-pass' : 'gate-fail', label: 'Gate: ' + g.gateId, sub: g.passed ? 'PASSED' : 'FAILED' + (g.output ? ' \u2014 ' + g.output.slice(0, 80) : '') });\n }\n\n rows.sort((a, b) => a.t - b.t);\n\n if (rows.length === 0) { el.innerHTML = '<p class=\"empty\">No data for this entity.</p>'; return; }\n\n el.innerHTML = '<div class=\"timeline\">' + rows.map(r => {\n let dotClass = 'timeline-dot';\n if (r.kind === 'gate-pass') dotClass += ' gate-pass';\n else if (r.kind === 'gate-fail') dotClass += ' gate-fail';\n else if (r.kind === 'invocation') dotClass += ' invocation';\n return '<div class=\"timeline-item\"><div class=\"' + dotClass + '\"></div><div class=\"timeline-body\"><div class=\"timeline-ts\">' + (r.t ? ts(r.t) : '\u2014') + '</div><div class=\"timeline-label\">' + esc(r.label) + '</div><div class=\"timeline-sub\">' + esc(r.sub || '') + '</div></div></div>';\n }).join('') + '</div>';\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Flow Graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadFlowList() {\n try {\n const flows = await api('/api/flows');\n const sel = document.getElementById('flow-select');\n const prev = sel.value;\n sel.innerHTML = '<option value=\"\">-- Select a flow --</option>';\n for (const f of (Array.isArray(flows) ? flows : [])) {\n const opt = document.createElement('option');\n opt.value = f.id;\n opt.textContent = f.name;\n sel.appendChild(opt);\n }\n if (prev) { sel.value = prev; loadFlowGraph(); }\n } catch (_) {}\n}\n\nasync function loadFlowGraph() {\n const id = document.getElementById('flow-select').value;\n const el = document.getElementById('graph-container');\n if (!id) { el.innerHTML = '<p class=\"empty\">Select a flow.</p>'; return; }\n el.innerHTML = '<p class=\"empty\">Loading...</p>';\n try {\n const [flow, status] = await Promise.all([api('/api/flows/' + encodeURIComponent(id)), api('/api/status')]);\n const counts = {};\n if (status && status.flows) {\n for (const fstat of status.flows) {\n if (fstat.flowId === id && fstat.states) {\n for (const s of fstat.states) counts[s.state] = s.count;\n }\n }\n }\n const states = flow.states || [];\n const transitions = flow.transitions || [];\n const initial = flow.initialState;\n const terminalSet = new Set();\n for (const s of states) {\n const hasOut = transitions.some(t => t.fromState === s.name);\n if (!hasOut) terminalSet.add(s.name);\n }\n\n const boxes = states.map(s => {\n let cls = 'state-box';\n if (s.name === initial) cls += ' initial';\n if (terminalSet.has(s.name)) cls += ' terminal';\n return '<div class=\"' + cls + '\"><div class=\"state-name\">' + esc(s.name) + '</div><div class=\"state-count\">' + (counts[s.name] || 0) + ' entities</div>' + (s.agentRole ? '<div class=\"state-count\" style=\"color:#d2a8ff\">' + esc(s.agentRole) + '</div>' : '') + '</div>';\n });\n\n el.innerHTML = '<div class=\"flow-graph\">' + boxes.join('') + '</div>';\n if (transitions.length) {\n const list = transitions.map(t => '<tr><td>' + esc(t.fromState) + '</td><td style=\"color:#8b949e\">\u2192</td><td>' + esc(t.toState) + '</td><td style=\"color:#d2a8ff\">' + esc(t.trigger) + '</td><td>' + (t.gateId ? '<span class=\"badge badge-amber\">gated</span>' : '') + '</td></tr>').join('');\n el.innerHTML += '<h3 style=\"color:#8b949e;font-size:13px;margin:16px 0 8px\">Transitions</h3><table><thead><tr><th>From</th><th></th><th>To</th><th>Trigger</th><th>Gate</th></tr></thead><tbody>' + list + '</tbody></table>';\n }\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Worker Dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadDashboard() {\n const el = document.getElementById('dashboard-container');\n try {\n const status = await api('/api/status');\n let html = '<div class=\"workers-grid\">';\n html += '<div class=\"stat-card\"><div class=\"label\">Active Invocations</div><div class=\"value\">' + (status.activeInvocations || 0) + '</div></div>';\n html += '<div class=\"stat-card\"><div class=\"label\">Pending Claims</div><div class=\"value\">' + (status.pendingClaims || 0) + '</div></div>';\n html += '<div class=\"stat-card\"><div class=\"label\">Total Entities</div><div class=\"value\">' + (status.totalEntities || 0) + '</div></div>';\n html += '</div>';\n\n if (status.flows && status.flows.length) {\n html += '<h3 style=\"color:#e6edf3;font-size:14px;margin-bottom:12px;\">Flows</h3><table><thead><tr><th>Flow</th><th>State</th><th>Count</th></tr></thead><tbody>';\n for (const f of status.flows) {\n for (const s of (f.states || [])) {\n html += '<tr><td style=\"color:#58a6ff\">' + esc(f.flowName || f.flowId) + '</td><td>' + esc(s.state) + '</td><td>' + s.count + '</td></tr>';\n }\n }\n html += '</tbody></table>';\n }\n el.innerHTML = html;\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Event Log \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadEventLog() {\n const el = document.getElementById('event-log-container');\n try {\n const fetched = await api('/api/ui/events/recent?limit=200');\n const fetchedRows = Array.isArray(fetched) ? fetched : [];\n // Merge: keep SSE-injected events not in the fetched set (by id), then prepend fetched\n const fetchedIds = new Set(fetchedRows.map(e => e.id));\n const sseOnly = allEvents.filter(e => !fetchedIds.has(e.id));\n allEvents = [...sseOnly, ...fetchedRows];\n updateEventTypeFilter();\n renderEventLog();\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\nfunction prependEventRow(ev) {\n // Convert SSE event to EventRow format\n const row = { id: ev.id || '', type: ev.type || '', entityId: ev.entityId || null, flowId: ev.flowId || null, payload: ev, emittedAt: ev.timestamp ? new Date(ev.timestamp).getTime() : Date.now() };\n allEvents.unshift(row);\n if (allEvents.length > 500) allEvents.pop();\n updateEventTypeFilter();\n renderEventLog();\n}\n\nfunction updateEventTypeFilter() {\n const sel = document.getElementById('event-type-filter');\n const cur = sel.value;\n const types = [...new Set(allEvents.map(e => e.type))].sort();\n sel.innerHTML = '<option value=\"\">All</option>' + types.map(t => '<option value=\"' + esc(t) + '\">' + esc(t) + '</option>').join('');\n if (cur) sel.value = cur;\n}\n\nfunction filterEventLog() { renderEventLog(); }\n\nfunction renderEventLog() {\n const filter = document.getElementById('event-type-filter').value;\n const el = document.getElementById('event-log-container');\n const filtered = filter ? allEvents.filter(e => e.type === filter) : allEvents;\n if (!filtered.length) { el.innerHTML = '<p class=\"empty\">No events.</p>'; return; }\n const rows = filtered.map(e => '<tr><td class=\"ts\">' + ts(e.emittedAt) + '</td><td class=\"type-cell\">' + esc(e.type) + '</td><td class=\"entity-cell\">' + esc(e.entityId || '\u2014') + \"</td><td class=\"payload-cell\" onclick=\"this.classList.toggle('expanded')\">\" + esc(JSON.stringify(e.payload || {})) + '</td></tr>').join('');\n el.innerHTML = '<table><thead><tr><th>Time</th><th>Type</th><th>Entity</th><th>Payload</th></tr></thead><tbody>' + rows + '</tbody></table>';\n}\n\nfunction esc(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');\n}\n\n// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst savedToken = sessionStorage.getItem('defcon-token');\nif (savedToken) {\n TOKEN = savedToken;\n document.getElementById('token-input').value = savedToken;\n verifyToken();\n}\n</script>\n</body>\n</html>";
@@ -0,0 +1,465 @@
1
+ export const UI_HTML = `<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DEFCON Dashboard</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { background: #0d1117; color: #c9d1d9; font-family: 'Courier New', monospace; font-size: 14px; }
10
+ #auth-overlay { position: fixed; inset: 0; background: #0d1117; display: flex; align-items: center; justify-content: center; z-index: 100; }
11
+ #auth-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; }
12
+ #auth-box h2 { color: #58a6ff; margin-bottom: 16px; font-size: 18px; }
13
+ #auth-box input { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 12px; }
14
+ #auth-box button { width: 100%; background: #238636; border: none; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; font-size: 14px; }
15
+ #auth-box button:hover { background: #2ea043; }
16
+ nav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; align-items: center; gap: 0; }
17
+ nav h1 { color: #58a6ff; font-size: 16px; margin-right: 32px; padding: 14px 0; }
18
+ .tab { background: none; border: none; color: #8b949e; padding: 14px 16px; cursor: pointer; font-family: inherit; font-size: 14px; border-bottom: 2px solid transparent; }
19
+ .tab:hover { color: #c9d1d9; }
20
+ .tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
21
+ .tab-content { display: none; padding: 24px; }
22
+ .tab-content.active { display: block; }
23
+ .search-row { display: flex; gap: 8px; margin-bottom: 20px; }
24
+ .search-row input { flex: 1; background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; }
25
+ .search-row button, .btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 14px; }
26
+ .search-row button:hover, .btn:hover { background: #30363d; }
27
+ .timeline { display: flex; flex-direction: column; gap: 0; }
28
+ .timeline-item { display: flex; gap: 16px; padding: 12px 0; border-bottom: 1px solid #21262d; }
29
+ .timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: #58a6ff; margin-top: 5px; flex-shrink: 0; }
30
+ .timeline-dot.gate-pass { background: #3fb950; }
31
+ .timeline-dot.gate-fail { background: #f85149; }
32
+ .timeline-dot.invocation { background: #d2a8ff; }
33
+ .timeline-body { flex: 1; }
34
+ .timeline-ts { color: #8b949e; font-size: 12px; margin-bottom: 4px; }
35
+ .timeline-label { font-weight: bold; color: #e6edf3; }
36
+ .timeline-sub { color: #8b949e; font-size: 12px; margin-top: 2px; }
37
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold; }
38
+ .badge-pass { background: #0d4429; color: #3fb950; }
39
+ .badge-fail { background: #490202; color: #f85149; }
40
+ .badge-pending { background: #1c2a3a; color: #58a6ff; }
41
+ .badge-complete { background: #0d2d0d; color: #3fb950; }
42
+ .badge-amber { background: #2d1f00; color: #e3b341; }
43
+ .flow-select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 20px; }
44
+ .flow-graph { position: relative; display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; min-height: 200px; }
45
+ .state-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; min-width: 140px; }
46
+ .state-box.initial { border-color: #58a6ff; }
47
+ .state-box.terminal { border-color: #3fb950; }
48
+ .state-name { font-weight: bold; color: #e6edf3; margin-bottom: 4px; }
49
+ .state-count { color: #8b949e; font-size: 12px; }
50
+ .workers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
51
+ .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
52
+ .stat-card .label { color: #8b949e; font-size: 12px; margin-bottom: 4px; }
53
+ .stat-card .value { color: #e6edf3; font-size: 24px; font-weight: bold; }
54
+ table { width: 100%; border-collapse: collapse; }
55
+ th { text-align: left; padding: 8px 12px; color: #8b949e; font-size: 12px; border-bottom: 1px solid #30363d; }
56
+ td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; }
57
+ td.ts { color: #8b949e; font-size: 12px; white-space: nowrap; }
58
+ td.type-cell { color: #d2a8ff; font-size: 12px; white-space: nowrap; }
59
+ td.entity-cell { color: #58a6ff; font-size: 12px; font-family: monospace; }
60
+ td.payload-cell { color: #8b949e; font-size: 11px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
61
+ td.payload-cell.expanded { white-space: pre-wrap; word-break: break-all; }
62
+ .filter-row { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
63
+ .filter-row select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-family: inherit; font-size: 13px; }
64
+ .filter-row label { color: #8b949e; font-size: 13px; }
65
+ #sse-status { position: fixed; bottom: 12px; right: 16px; font-size: 11px; color: #8b949e; }
66
+ #sse-status.connected { color: #3fb950; }
67
+ #sse-status.error { color: #f85149; }
68
+ .empty { color: #8b949e; text-align: center; padding: 32px; }
69
+ .error-msg { color: #f85149; margin: 8px 0; font-size: 13px; }
70
+ </style>
71
+ </head>
72
+ <body>
73
+
74
+ <div id="auth-overlay">
75
+ <div id="auth-box">
76
+ <h2>DEFCON</h2>
77
+ <p style="color:#8b949e;margin-bottom:16px;font-size:13px;">Enter your admin token to continue.</p>
78
+ <input type="password" id="token-input" placeholder="Admin token" autocomplete="off">
79
+ <div id="auth-error" class="error-msg" style="display:none"></div>
80
+ <button onclick="doLogin()">Connect</button>
81
+ </div>
82
+ </div>
83
+
84
+ <nav>
85
+ <h1>DEFCON</h1>
86
+ <button class="tab active" onclick="showTab('entity-timeline', this)">Timeline</button>
87
+ <button class="tab" onclick="showTab('flow-graph', this)">Flow Graph</button>
88
+ <button class="tab" onclick="showTab('worker-dashboard', this)">Workers</button>
89
+ <button class="tab" onclick="showTab('event-log', this)">Event Log</button>
90
+ </nav>
91
+
92
+ <!-- Entity Timeline -->
93
+ <div id="entity-timeline" class="tab-content active">
94
+ <div class="search-row">
95
+ <input id="entity-id-input" type="text" placeholder="Entity ID..." onkeydown="if(event.key==='Enter')loadTimeline()">
96
+ <button onclick="loadTimeline()">Load</button>
97
+ </div>
98
+ <div id="timeline-container"><p class="empty">Enter an entity ID to view its timeline.</p></div>
99
+ </div>
100
+
101
+ <!-- Flow Graph -->
102
+ <div id="flow-graph" class="tab-content">
103
+ <select id="flow-select" class="flow-select" onchange="loadFlowGraph()">
104
+ <option value="">-- Select a flow --</option>
105
+ </select>
106
+ <div id="graph-container"><p class="empty">Select a flow to visualize its state graph.</p></div>
107
+ </div>
108
+
109
+ <!-- Worker Dashboard -->
110
+ <div id="worker-dashboard" class="tab-content">
111
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
112
+ <h2 style="color:#e6edf3;font-size:16px;">Worker Dashboard</h2>
113
+ <button class="btn" onclick="loadDashboard()">Refresh</button>
114
+ </div>
115
+ <div id="dashboard-container"><p class="empty">Loading...</p></div>
116
+ </div>
117
+
118
+ <!-- Event Log -->
119
+ <div id="event-log" class="tab-content">
120
+ <div class="filter-row">
121
+ <label>Filter by type:</label>
122
+ <select id="event-type-filter" onchange="filterEventLog()">
123
+ <option value="">All</option>
124
+ </select>
125
+ <button class="btn" onclick="loadEventLog()">Refresh</button>
126
+ </div>
127
+ <div id="event-log-container"><p class="empty">Loading events...</p></div>
128
+ </div>
129
+
130
+ <div id="sse-status">SSE: disconnected</div>
131
+
132
+ <script>
133
+ let TOKEN = '';
134
+ let sseSource = null;
135
+ let allEvents = [];
136
+ let dashboardDebounceTimer = null;
137
+
138
+ function scheduleDashboardRefresh() {
139
+ if (dashboardDebounceTimer) clearTimeout(dashboardDebounceTimer);
140
+ dashboardDebounceTimer = setTimeout(() => {
141
+ dashboardDebounceTimer = null;
142
+ if (document.getElementById('worker-dashboard').classList.contains('active')) {
143
+ loadDashboard();
144
+ }
145
+ }, 100);
146
+ }
147
+
148
+ function ts(ms) {
149
+ return new Date(typeof ms === 'number' ? ms : ms).toLocaleString();
150
+ }
151
+
152
+ function doLogin() {
153
+ const v = document.getElementById('token-input').value.trim();
154
+ if (!v) { showAuthError('Token required'); return; }
155
+ TOKEN = v;
156
+ sessionStorage.setItem('defcon-token', v);
157
+ verifyToken();
158
+ }
159
+
160
+ function showAuthError(msg) {
161
+ const el = document.getElementById('auth-error');
162
+ el.textContent = msg;
163
+ el.style.display = 'block';
164
+ }
165
+
166
+ function verifyToken() {
167
+ fetch('/api/ui/events/recent?limit=1', { headers: { Authorization: 'Bearer ' + TOKEN } })
168
+ .then(r => {
169
+ if (r.ok) {
170
+ document.getElementById('auth-overlay').style.display = 'none';
171
+ initApp();
172
+ } else {
173
+ showAuthError('Invalid token');
174
+ TOKEN = '';
175
+ sessionStorage.removeItem('defcon-token');
176
+ }
177
+ })
178
+ .catch(() => showAuthError('Connection failed'));
179
+ }
180
+
181
+ function initApp() {
182
+ connectSSE();
183
+ loadEventLog();
184
+ loadFlowList();
185
+ loadDashboard();
186
+ }
187
+
188
+ function connectSSE() {
189
+ if (sseSource) sseSource.close();
190
+ // Pass token via Authorization header using a fetch-based SSE reader to
191
+ // avoid exposing it in the URL (which appears in server logs).
192
+ // EventSource does not support custom headers, so we use fetch + ReadableStream.
193
+ const ctrl = new AbortController();
194
+ sseSource = ctrl; // store for close()
195
+ fetch('/api/ui/events', { headers: { Authorization: 'Bearer ' + TOKEN }, signal: ctrl.signal })
196
+ .then(r => {
197
+ if (!r.ok) { handleSseError(); return; }
198
+ const el = document.getElementById('sse-status');
199
+ el.textContent = 'SSE: connected';
200
+ el.className = 'connected';
201
+ const reader = r.body.getReader();
202
+ const decoder = new TextDecoder();
203
+ let buf = '';
204
+ function pump() {
205
+ reader.read().then(({ done, value }) => {
206
+ if (done) { handleSseError(); return; }
207
+ buf += decoder.decode(value, { stream: true });
208
+ const lines = buf.split('\n');
209
+ buf = lines.pop();
210
+ for (const line of lines) {
211
+ if (line.startsWith('data: ')) {
212
+ try {
213
+ const ev = JSON.parse(line.slice(6));
214
+ prependEventRow(ev);
215
+ scheduleDashboardRefresh();
216
+ } catch (_) {}
217
+ }
218
+ }
219
+ pump();
220
+ }).catch(handleSseError);
221
+ }
222
+ pump();
223
+ })
224
+ .catch(handleSseError);
225
+ function handleSseError() {
226
+ const el = document.getElementById('sse-status');
227
+ if (el) { el.textContent = 'SSE: reconnecting...'; el.className = 'error'; }
228
+ setTimeout(() => connectSSE(), 5000);
229
+ }
230
+ }
231
+ sseSource.onopen = () => {
232
+ const el = document.getElementById('sse-status');
233
+ el.textContent = 'SSE: connected';
234
+ el.className = 'connected';
235
+ };
236
+ sseSource.onerror = () => {
237
+ const el = document.getElementById('sse-status');
238
+ el.textContent = 'SSE: reconnecting...';
239
+ el.className = 'error';
240
+ };
241
+ sseSource.onmessage = (e) => {
242
+ try {
243
+ const ev = JSON.parse(e.data);
244
+ prependEventRow(ev);
245
+ if (document.getElementById('worker-dashboard').classList.contains('active')) {
246
+ loadDashboard();
247
+ }
248
+ } catch (_) {}
249
+ };
250
+ }
251
+
252
+ function api(path) {
253
+ return fetch(path, { headers: { Authorization: 'Bearer ' + TOKEN } }).then(r => {
254
+ if (!r.ok) throw new Error('HTTP ' + r.status);
255
+ return r.json();
256
+ });
257
+ }
258
+
259
+ function showTab(id, btn) {
260
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
261
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
262
+ document.getElementById(id).classList.add('active');
263
+ btn.classList.add('active');
264
+ if (id === 'worker-dashboard') loadDashboard();
265
+ if (id === 'flow-graph') loadFlowList();
266
+ }
267
+
268
+ // ── Entity Timeline ──────────────────────────────────────────────
269
+
270
+ async function loadTimeline() {
271
+ const id = document.getElementById('entity-id-input').value.trim();
272
+ if (!id) return;
273
+ const el = document.getElementById('timeline-container');
274
+ el.innerHTML = '<p class="empty">Loading...</p>';
275
+ try {
276
+ const [entity, events, invocations, gates] = await Promise.all([
277
+ api('/api/entities/' + encodeURIComponent(id)),
278
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/events'),
279
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/invocations'),
280
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/gates'),
281
+ ]);
282
+
283
+ // Build merged timeline
284
+ const rows = [];
285
+
286
+ if (entity && entity.id) {
287
+ rows.push({ t: new Date(entity.createdAt).getTime(), kind: 'entity', label: 'Entity created', sub: 'Flow: ' + entity.flowId + ' | State: ' + entity.state });
288
+ }
289
+
290
+ for (const ev of (Array.isArray(events) ? events : [])) {
291
+ rows.push({ t: ev.emittedAt, kind: ev.type, label: ev.type, sub: JSON.stringify(ev.payload || {}).slice(0, 120) });
292
+ }
293
+
294
+ for (const inv of (Array.isArray(invocations) ? invocations : [])) {
295
+ const st = inv.startedAt ? new Date(inv.startedAt).getTime() : (inv.createdAt ? new Date(inv.createdAt).getTime() : 0);
296
+ rows.push({ t: st, kind: 'invocation', label: 'Invocation: ' + inv.stage, sub: 'Status: ' + (inv.completedAt ? 'completed' : inv.failedAt ? 'failed' : 'pending') + (inv.signal ? ' | signal: ' + inv.signal : '') });
297
+ }
298
+
299
+ for (const g of (Array.isArray(gates) ? gates : [])) {
300
+ rows.push({ t: g.evaluatedAt ? new Date(g.evaluatedAt).getTime() : 0, kind: g.passed ? 'gate-pass' : 'gate-fail', label: 'Gate: ' + g.gateId, sub: g.passed ? 'PASSED' : 'FAILED' + (g.output ? ' — ' + g.output.slice(0, 80) : '') });
301
+ }
302
+
303
+ rows.sort((a, b) => a.t - b.t);
304
+
305
+ if (rows.length === 0) { el.innerHTML = '<p class="empty">No data for this entity.</p>'; return; }
306
+
307
+ el.innerHTML = '<div class="timeline">' + rows.map(r => {
308
+ let dotClass = 'timeline-dot';
309
+ if (r.kind === 'gate-pass') dotClass += ' gate-pass';
310
+ else if (r.kind === 'gate-fail') dotClass += ' gate-fail';
311
+ else if (r.kind === 'invocation') dotClass += ' invocation';
312
+ return '<div class="timeline-item"><div class="' + dotClass + '"></div><div class="timeline-body"><div class="timeline-ts">' + (r.t ? ts(r.t) : '—') + '</div><div class="timeline-label">' + esc(r.label) + '</div><div class="timeline-sub">' + esc(r.sub || '') + '</div></div></div>';
313
+ }).join('') + '</div>';
314
+ } catch (e) {
315
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
316
+ }
317
+ }
318
+
319
+ // ── Flow Graph ───────────────────────────────────────────────────
320
+
321
+ async function loadFlowList() {
322
+ try {
323
+ const flows = await api('/api/flows');
324
+ const sel = document.getElementById('flow-select');
325
+ const prev = sel.value;
326
+ sel.innerHTML = '<option value="">-- Select a flow --</option>';
327
+ for (const f of (Array.isArray(flows) ? flows : [])) {
328
+ const opt = document.createElement('option');
329
+ opt.value = f.id;
330
+ opt.textContent = f.name;
331
+ sel.appendChild(opt);
332
+ }
333
+ if (prev) { sel.value = prev; loadFlowGraph(); }
334
+ } catch (_) {}
335
+ }
336
+
337
+ async function loadFlowGraph() {
338
+ const id = document.getElementById('flow-select').value;
339
+ const el = document.getElementById('graph-container');
340
+ if (!id) { el.innerHTML = '<p class="empty">Select a flow.</p>'; return; }
341
+ el.innerHTML = '<p class="empty">Loading...</p>';
342
+ try {
343
+ const [flow, status] = await Promise.all([api('/api/flows/' + encodeURIComponent(id)), api('/api/status')]);
344
+ const counts = {};
345
+ if (status && status.flows) {
346
+ for (const fstat of status.flows) {
347
+ if (fstat.flowId === id && fstat.states) {
348
+ for (const s of fstat.states) counts[s.state] = s.count;
349
+ }
350
+ }
351
+ }
352
+ const states = flow.states || [];
353
+ const transitions = flow.transitions || [];
354
+ const initial = flow.initialState;
355
+ const terminalSet = new Set();
356
+ for (const s of states) {
357
+ const hasOut = transitions.some(t => t.fromState === s.name);
358
+ if (!hasOut) terminalSet.add(s.name);
359
+ }
360
+
361
+ const boxes = states.map(s => {
362
+ let cls = 'state-box';
363
+ if (s.name === initial) cls += ' initial';
364
+ if (terminalSet.has(s.name)) cls += ' terminal';
365
+ return '<div class="' + cls + '"><div class="state-name">' + esc(s.name) + '</div><div class="state-count">' + (counts[s.name] || 0) + ' entities</div>' + (s.agentRole ? '<div class="state-count" style="color:#d2a8ff">' + esc(s.agentRole) + '</div>' : '') + '</div>';
366
+ });
367
+
368
+ el.innerHTML = '<div class="flow-graph">' + boxes.join('') + '</div>';
369
+ if (transitions.length) {
370
+ const list = transitions.map(t => '<tr><td>' + esc(t.fromState) + '</td><td style="color:#8b949e">→</td><td>' + esc(t.toState) + '</td><td style="color:#d2a8ff">' + esc(t.trigger) + '</td><td>' + (t.gateId ? '<span class="badge badge-amber">gated</span>' : '') + '</td></tr>').join('');
371
+ el.innerHTML += '<h3 style="color:#8b949e;font-size:13px;margin:16px 0 8px">Transitions</h3><table><thead><tr><th>From</th><th></th><th>To</th><th>Trigger</th><th>Gate</th></tr></thead><tbody>' + list + '</tbody></table>';
372
+ }
373
+ } catch (e) {
374
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
375
+ }
376
+ }
377
+
378
+ // ── Worker Dashboard ─────────────────────────────────────────────
379
+
380
+ async function loadDashboard() {
381
+ const el = document.getElementById('dashboard-container');
382
+ try {
383
+ const status = await api('/api/status');
384
+ let html = '<div class="workers-grid">';
385
+ html += '<div class="stat-card"><div class="label">Active Invocations</div><div class="value">' + (status.activeInvocations || 0) + '</div></div>';
386
+ html += '<div class="stat-card"><div class="label">Pending Claims</div><div class="value">' + (status.pendingClaims || 0) + '</div></div>';
387
+ html += '<div class="stat-card"><div class="label">Total Entities</div><div class="value">' + (status.totalEntities || 0) + '</div></div>';
388
+ html += '</div>';
389
+
390
+ if (status.flows && status.flows.length) {
391
+ html += '<h3 style="color:#e6edf3;font-size:14px;margin-bottom:12px;">Flows</h3><table><thead><tr><th>Flow</th><th>State</th><th>Count</th></tr></thead><tbody>';
392
+ for (const f of status.flows) {
393
+ for (const s of (f.states || [])) {
394
+ html += '<tr><td style="color:#58a6ff">' + esc(f.flowName || f.flowId) + '</td><td>' + esc(s.state) + '</td><td>' + s.count + '</td></tr>';
395
+ }
396
+ }
397
+ html += '</tbody></table>';
398
+ }
399
+ el.innerHTML = html;
400
+ } catch (e) {
401
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
402
+ }
403
+ }
404
+
405
+ // ── Event Log ────────────────────────────────────────────────────
406
+
407
+ async function loadEventLog() {
408
+ const el = document.getElementById('event-log-container');
409
+ try {
410
+ const fetched = await api('/api/ui/events/recent?limit=200');
411
+ const fetchedRows = Array.isArray(fetched) ? fetched : [];
412
+ // Merge: keep SSE-injected events not in the fetched set (by id), then prepend fetched
413
+ const fetchedIds = new Set(fetchedRows.map(e => e.id));
414
+ const sseOnly = allEvents.filter(e => !fetchedIds.has(e.id));
415
+ allEvents = [...sseOnly, ...fetchedRows];
416
+ updateEventTypeFilter();
417
+ renderEventLog();
418
+ } catch (e) {
419
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
420
+ }
421
+ }
422
+
423
+ function prependEventRow(ev) {
424
+ // Convert SSE event to EventRow format
425
+ const row = { id: ev.id || '', type: ev.type || '', entityId: ev.entityId || null, flowId: ev.flowId || null, payload: ev, emittedAt: ev.timestamp ? new Date(ev.timestamp).getTime() : Date.now() };
426
+ allEvents.unshift(row);
427
+ if (allEvents.length > 500) allEvents.pop();
428
+ updateEventTypeFilter();
429
+ renderEventLog();
430
+ }
431
+
432
+ function updateEventTypeFilter() {
433
+ const sel = document.getElementById('event-type-filter');
434
+ const cur = sel.value;
435
+ const types = [...new Set(allEvents.map(e => e.type))].sort();
436
+ sel.innerHTML = '<option value="">All</option>' + types.map(t => '<option value="' + esc(t) + '">' + esc(t) + '</option>').join('');
437
+ if (cur) sel.value = cur;
438
+ }
439
+
440
+ function filterEventLog() { renderEventLog(); }
441
+
442
+ function renderEventLog() {
443
+ const filter = document.getElementById('event-type-filter').value;
444
+ const el = document.getElementById('event-log-container');
445
+ const filtered = filter ? allEvents.filter(e => e.type === filter) : allEvents;
446
+ if (!filtered.length) { el.innerHTML = '<p class="empty">No events.</p>'; return; }
447
+ const rows = filtered.map(e => '<tr><td class="ts">' + ts(e.emittedAt) + '</td><td class="type-cell">' + esc(e.type) + '</td><td class="entity-cell">' + esc(e.entityId || '—') + "</td><td class="payload-cell" onclick="this.classList.toggle('expanded')">" + esc(JSON.stringify(e.payload || {})) + '</td></tr>').join('');
448
+ el.innerHTML = '<table><thead><tr><th>Time</th><th>Type</th><th>Entity</th><th>Payload</th></tr></thead><tbody>' + rows + '</tbody></table>';
449
+ }
450
+
451
+ function esc(s) {
452
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
453
+ }
454
+
455
+ // ── Init ─────────────────────────────────────────────────────────
456
+
457
+ const savedToken = sessionStorage.getItem('defcon-token');
458
+ if (savedToken) {
459
+ TOKEN = savedToken;
460
+ document.getElementById('token-input').value = savedToken;
461
+ verifyToken();
462
+ }
463
+ </script>
464
+ </body>
465
+ </html>`;
@@ -0,0 +1,8 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import type { EngineEvent, IEventBusAdapter } from "../engine/event-types.js";
3
+ export declare class UiSseAdapter implements IEventBusAdapter {
4
+ private clients;
5
+ addClient(res: ServerResponse): void;
6
+ get clientCount(): number;
7
+ emit(event: EngineEvent): Promise<void>;
8
+ }
@@ -0,0 +1,23 @@
1
+ export class UiSseAdapter {
2
+ clients = new Set();
3
+ addClient(res) {
4
+ this.clients.add(res);
5
+ res.on("close", () => this.clients.delete(res));
6
+ }
7
+ get clientCount() {
8
+ return this.clients.size;
9
+ }
10
+ async emit(event) {
11
+ const { emittedAt, ...rest } = event;
12
+ const msg = JSON.stringify({ ...rest, timestamp: emittedAt.toISOString() });
13
+ const frame = `data: ${msg}\n\n`;
14
+ for (const client of this.clients) {
15
+ try {
16
+ client.write(frame);
17
+ }
18
+ catch {
19
+ this.clients.delete(client);
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ CREATE INDEX `events_entity_id_idx` ON `events` (`entity_id`);--> statement-breakpoint
2
+ CREATE INDEX `events_emitted_at_idx` ON `events` (`emitted_at`);
@@ -0,0 +1,11 @@
1
+ CREATE TABLE `domain_events` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `type` text NOT NULL,
4
+ `entity_id` text NOT NULL,
5
+ `payload` text NOT NULL,
6
+ `sequence` integer NOT NULL,
7
+ `emitted_at` integer NOT NULL
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE UNIQUE INDEX `domain_events_entity_seq_idx` ON `domain_events` (`entity_id`,`sequence`);--> statement-breakpoint
11
+ CREATE INDEX `domain_events_type_idx` ON `domain_events` (`type`,`emitted_at`);