hookherald 0.3.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,633 @@
1
+ <!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>HookHerald</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0d1117;
12
+ --bg-card: #161b22;
13
+ --bg-hover: #1c2128;
14
+ --border: #30363d;
15
+ --text: #e6edf3;
16
+ --text-dim: #8b949e;
17
+ --green: #3fb950;
18
+ --red: #f85149;
19
+ --yellow: #d29922;
20
+ --blue: #58a6ff;
21
+ --purple: #bc8cff;
22
+ --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
23
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
24
+ }
25
+
26
+ body {
27
+ font-family: var(--sans);
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ line-height: 1.5;
31
+ min-height: 100vh;
32
+ }
33
+
34
+ /* Header */
35
+ .header {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ padding: 12px 20px;
40
+ border-bottom: 1px solid var(--border);
41
+ background: var(--bg-card);
42
+ }
43
+ .header h1 { font-size: 18px; font-weight: 600; }
44
+ .header-stats {
45
+ display: flex;
46
+ gap: 20px;
47
+ font-family: var(--mono);
48
+ font-size: 13px;
49
+ color: var(--text-dim);
50
+ }
51
+ .header-stats .value { color: var(--text); font-weight: 600; }
52
+ .connection-dot {
53
+ display: inline-block;
54
+ width: 8px; height: 8px;
55
+ border-radius: 50%;
56
+ background: var(--red);
57
+ margin-right: 6px;
58
+ vertical-align: middle;
59
+ }
60
+ .connection-dot.connected { background: var(--green); }
61
+
62
+ /* Layout */
63
+ .main {
64
+ display: flex;
65
+ flex-direction: column;
66
+ height: calc(100vh - 49px);
67
+ overflow: hidden;
68
+ }
69
+
70
+ /* Sessions table */
71
+ .sessions-section {
72
+ border-bottom: 1px solid var(--border);
73
+ flex-shrink: 0;
74
+ max-height: 40%;
75
+ overflow-y: auto;
76
+ }
77
+ .section-header {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ padding: 10px 16px 6px;
82
+ }
83
+ .section-header h2 {
84
+ font-size: 13px;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.5px;
87
+ color: var(--text-dim);
88
+ }
89
+ .sessions-table {
90
+ width: 100%;
91
+ border-collapse: collapse;
92
+ font-family: var(--mono);
93
+ font-size: 12px;
94
+ }
95
+ .sessions-table th {
96
+ text-align: left;
97
+ padding: 6px 12px;
98
+ color: var(--text-dim);
99
+ font-weight: 500;
100
+ font-size: 11px;
101
+ text-transform: uppercase;
102
+ letter-spacing: 0.3px;
103
+ border-bottom: 1px solid var(--border);
104
+ position: sticky;
105
+ top: 0;
106
+ background: var(--bg);
107
+ }
108
+ .sessions-table td {
109
+ padding: 8px 12px;
110
+ border-bottom: 1px solid var(--border);
111
+ }
112
+ .sessions-table tr { cursor: pointer; }
113
+ .sessions-table tr:hover td { background: var(--bg-hover); }
114
+ .sessions-table tr.selected td { background: #1a2636; border-color: var(--blue); }
115
+ .sessions-table tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--blue); }
116
+
117
+ .status-dot {
118
+ display: inline-block;
119
+ width: 8px; height: 8px;
120
+ border-radius: 50%;
121
+ margin-right: 6px;
122
+ vertical-align: middle;
123
+ }
124
+ .status-dot.up { background: var(--green); }
125
+ .status-dot.down { background: var(--red); }
126
+ .status-dot.unknown { background: var(--text-dim); }
127
+
128
+ .btn-kill {
129
+ background: none;
130
+ border: 1px solid var(--border);
131
+ color: var(--red);
132
+ font-family: var(--mono);
133
+ font-size: 11px;
134
+ padding: 2px 8px;
135
+ border-radius: 4px;
136
+ cursor: pointer;
137
+ }
138
+ .btn-kill:hover { background: #3a1a1a; border-color: var(--red); }
139
+
140
+ .no-data {
141
+ color: var(--text-dim);
142
+ font-size: 13px;
143
+ text-align: center;
144
+ padding: 24px 0;
145
+ }
146
+
147
+ /* Events section */
148
+ .events-section {
149
+ flex: 1;
150
+ display: flex;
151
+ flex-direction: column;
152
+ overflow: hidden;
153
+ min-height: 0;
154
+ }
155
+
156
+ .events-list {
157
+ flex: 1;
158
+ overflow-y: auto;
159
+ padding: 0 8px 8px;
160
+ }
161
+
162
+ .event-row {
163
+ display: grid;
164
+ grid-template-columns: 80px 1fr 80px 80px 50px;
165
+ gap: 8px;
166
+ align-items: center;
167
+ padding: 6px 12px;
168
+ border-radius: 4px;
169
+ cursor: pointer;
170
+ font-family: var(--mono);
171
+ font-size: 12px;
172
+ }
173
+ .event-row:hover { background: var(--bg-hover); }
174
+ .event-time { color: var(--text-dim); }
175
+ .event-slug { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
176
+
177
+ .badge {
178
+ font-size: 11px;
179
+ padding: 2px 6px;
180
+ border-radius: 3px;
181
+ text-align: center;
182
+ font-weight: 600;
183
+ display: inline-block;
184
+ }
185
+ .badge.webhook { background: #1f3a5f; color: var(--blue); }
186
+ .badge.register { background: #1a3a2a; color: var(--green); }
187
+ .badge.unregister { background: #3a1a1a; color: var(--red); }
188
+ .badge.forwarded { background: #1a3a2a; color: var(--green); }
189
+ .badge.no_route { background: #3a2a1a; color: var(--yellow); }
190
+ .badge.unauthorized { background: #3a1a1a; color: var(--red); }
191
+ .badge.invalid { background: #3a1a1a; color: var(--red); }
192
+
193
+ .event-latency { color: var(--text-dim); text-align: right; }
194
+
195
+ /* Event detail (inline expand) */
196
+ .event-detail {
197
+ background: var(--bg-card);
198
+ border: 1px solid var(--border);
199
+ border-radius: 6px;
200
+ margin: 4px 8px 8px;
201
+ padding: 12px;
202
+ font-family: var(--mono);
203
+ font-size: 12px;
204
+ }
205
+ .detail-section { margin-bottom: 12px; }
206
+ .detail-section:last-child { margin-bottom: 0; }
207
+ .detail-section h3 {
208
+ font-size: 11px;
209
+ text-transform: uppercase;
210
+ letter-spacing: 0.5px;
211
+ color: var(--text-dim);
212
+ margin-bottom: 6px;
213
+ }
214
+ .detail-kv {
215
+ display: grid;
216
+ grid-template-columns: 120px 1fr;
217
+ gap: 3px 12px;
218
+ }
219
+ .detail-kv .key { color: var(--text-dim); }
220
+ .detail-kv .val { color: var(--text); word-break: break-all; }
221
+
222
+ /* Trace waterfall */
223
+ .trace-row {
224
+ display: flex;
225
+ align-items: center;
226
+ gap: 8px;
227
+ margin-bottom: 3px;
228
+ font-size: 11px;
229
+ }
230
+ .trace-label {
231
+ width: 120px;
232
+ text-align: right;
233
+ color: var(--text-dim);
234
+ flex-shrink: 0;
235
+ }
236
+ .trace-bar-track {
237
+ flex: 1;
238
+ height: 14px;
239
+ background: var(--bg);
240
+ border-radius: 3px;
241
+ position: relative;
242
+ }
243
+ .trace-bar {
244
+ position: absolute;
245
+ height: 100%;
246
+ border-radius: 3px;
247
+ background: var(--blue);
248
+ min-width: 2px;
249
+ }
250
+ .trace-duration {
251
+ width: 45px;
252
+ color: var(--text-dim);
253
+ font-size: 11px;
254
+ flex-shrink: 0;
255
+ }
256
+
257
+ /* Payload */
258
+ .payload-block {
259
+ background: var(--bg);
260
+ border: 1px solid var(--border);
261
+ border-radius: 4px;
262
+ padding: 8px;
263
+ white-space: pre-wrap;
264
+ word-break: break-all;
265
+ max-height: 200px;
266
+ overflow-y: auto;
267
+ }
268
+
269
+ /* Filter bar */
270
+ .filter-bar {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 0 16px 6px;
275
+ font-size: 12px;
276
+ }
277
+ .filter-bar select, .filter-bar input {
278
+ background: var(--bg-card);
279
+ border: 1px solid var(--border);
280
+ color: var(--text);
281
+ font-family: var(--mono);
282
+ font-size: 12px;
283
+ padding: 3px 8px;
284
+ border-radius: 4px;
285
+ }
286
+ .filter-bar label { color: var(--text-dim); }
287
+ </style>
288
+ </head>
289
+ <body>
290
+
291
+ <div class="header">
292
+ <h1>HookHerald</h1>
293
+ <div class="header-stats">
294
+ <span><span class="connection-dot" id="connDot"></span><span id="connStatus">disconnected</span></span>
295
+ <span>uptime: <span class="value" id="uptime">-</span></span>
296
+ <span>events: <span class="value" id="totalEvents">0</span></span>
297
+ <span>sessions: <span class="value" id="totalSessions">0</span></span>
298
+ </div>
299
+ </div>
300
+
301
+ <div class="main">
302
+ <div class="sessions-section">
303
+ <div class="section-header">
304
+ <h2>Sessions</h2>
305
+ </div>
306
+ <div id="sessionsContent">
307
+ <div class="no-data">No active sessions</div>
308
+ </div>
309
+ </div>
310
+
311
+ <div class="events-section">
312
+ <div class="section-header">
313
+ <h2>Events</h2>
314
+ </div>
315
+ <div class="filter-bar">
316
+ <span id="filterHint" style="color:var(--text-dim)">Showing all sessions</span>
317
+ <select id="filterType">
318
+ <option value="">All types</option>
319
+ <option value="webhook">webhook</option>
320
+ <option value="register">register</option>
321
+ <option value="unregister">unregister</option>
322
+ </select>
323
+ </div>
324
+ <div class="events-list" id="eventsList">
325
+ <div class="no-data">Waiting for events...</div>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <script>
331
+ const state = {
332
+ sessions: [],
333
+ events: [],
334
+ stats: {},
335
+ expandedEventId: null,
336
+ selectedSlugs: new Set(),
337
+ filterType: '',
338
+ };
339
+
340
+ // --- SSE ---
341
+
342
+ let es;
343
+ function connectSSE() {
344
+ es = new EventSource('/api/stream');
345
+
346
+ es.addEventListener('init', (e) => {
347
+ const data = JSON.parse(e.data);
348
+ state.sessions = data.sessions || [];
349
+ state.stats = data.stats || {};
350
+ state.events = data.recentEvents || [];
351
+ renderAll();
352
+ });
353
+
354
+ es.addEventListener('webhook', (e) => {
355
+ const ev = JSON.parse(e.data);
356
+ state.events.unshift(ev);
357
+ if (state.events.length > 200) state.events.length = 200;
358
+ renderEvents();
359
+ updateHeader();
360
+ });
361
+
362
+ es.addEventListener('session', (e) => {
363
+ const data = JSON.parse(e.data);
364
+ state.sessions = data.sessions || [];
365
+ renderSessions();
366
+ updateHeader();
367
+ });
368
+
369
+ es.addEventListener('stats', (e) => {
370
+ state.stats = JSON.parse(e.data);
371
+ updateHeader();
372
+ });
373
+
374
+ es.onopen = () => {
375
+ document.getElementById('connDot').classList.add('connected');
376
+ document.getElementById('connStatus').textContent = 'connected';
377
+ };
378
+
379
+ es.onerror = () => {
380
+ document.getElementById('connDot').classList.remove('connected');
381
+ document.getElementById('connStatus').textContent = 'reconnecting...';
382
+ };
383
+ }
384
+
385
+ // --- Rendering ---
386
+
387
+ function renderAll() {
388
+ renderSessions();
389
+ renderEvents();
390
+ updateHeader();
391
+ updateFilterHint();
392
+ }
393
+
394
+ function updateHeader() {
395
+ const s = state.stats;
396
+ document.getElementById('totalSessions').textContent = state.sessions.length;
397
+ document.getElementById('totalEvents').textContent = s.webhooks ? s.webhooks.total : state.events.length;
398
+ if (s.uptimeSeconds !== undefined) {
399
+ document.getElementById('uptime').textContent = fmtUptime(s.uptimeSeconds);
400
+ }
401
+ }
402
+
403
+ function fmtUptime(sec) {
404
+ if (sec < 60) return sec + 's';
405
+ if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
406
+ const h = Math.floor(sec / 3600);
407
+ const m = Math.floor((sec % 3600) / 60);
408
+ return h + 'h ' + m + 'm';
409
+ }
410
+
411
+ function renderSessions() {
412
+ const el = document.getElementById('sessionsContent');
413
+ if (!state.sessions.length) {
414
+ el.innerHTML = '<div class="no-data">No active sessions</div>';
415
+ return;
416
+ }
417
+
418
+ let html = '<table class="sessions-table"><thead><tr>';
419
+ html += '<th>Status</th><th>Slug</th><th>Port</th><th>Events</th><th>Errors</th><th>Avg Latency</th><th>Last Event</th><th></th>';
420
+ html += '</tr></thead><tbody>';
421
+
422
+ for (const s of state.sessions) {
423
+ const sel = state.selectedSlugs.has(s.slug) ? ' selected' : '';
424
+ html += `<tr class="${sel}" onclick="selectSession('${esc(s.slug)}', event)">
425
+ <td><span class="status-dot ${s.status}"></span>${s.status}</td>
426
+ <td>${esc(s.slug)}</td>
427
+ <td>${s.port}</td>
428
+ <td>${s.eventCount}</td>
429
+ <td>${s.errorCount}</td>
430
+ <td>${s.avgLatencyMs}ms</td>
431
+ <td class="last-event-cell" data-ts="${s.lastEventAt || ''}">${s.lastEventAt ? timeAgo(s.lastEventAt) : 'never'}</td>
432
+ <td><button class="btn-kill" onclick="event.stopPropagation(); killSession('${esc(s.slug)}')">kill</button></td>
433
+ </tr>`;
434
+ }
435
+
436
+ html += '</tbody></table>';
437
+ el.innerHTML = html;
438
+ }
439
+
440
+ function selectSession(slug, ev) {
441
+ if (ev.ctrlKey || ev.metaKey) {
442
+ // Toggle this slug in selection
443
+ if (state.selectedSlugs.has(slug)) {
444
+ state.selectedSlugs.delete(slug);
445
+ } else {
446
+ state.selectedSlugs.add(slug);
447
+ }
448
+ } else if (ev.shiftKey && state.selectedSlugs.size > 0) {
449
+ // Range select: from last selected to this one
450
+ const slugs = state.sessions.map(s => s.slug);
451
+ const lastSelected = [...state.selectedSlugs].pop();
452
+ const from = slugs.indexOf(lastSelected);
453
+ const to = slugs.indexOf(slug);
454
+ if (from !== -1 && to !== -1) {
455
+ const start = Math.min(from, to);
456
+ const end = Math.max(from, to);
457
+ for (let i = start; i <= end; i++) {
458
+ state.selectedSlugs.add(slugs[i]);
459
+ }
460
+ }
461
+ } else {
462
+ // Plain click: toggle single
463
+ if (state.selectedSlugs.size === 1 && state.selectedSlugs.has(slug)) {
464
+ state.selectedSlugs.clear();
465
+ } else {
466
+ state.selectedSlugs.clear();
467
+ state.selectedSlugs.add(slug);
468
+ }
469
+ }
470
+ renderSessions();
471
+ renderEvents();
472
+ updateFilterHint();
473
+ }
474
+
475
+ function updateFilterHint() {
476
+ const el = document.getElementById('filterHint');
477
+ const n = state.selectedSlugs.size;
478
+ if (n === 0) {
479
+ el.textContent = 'Showing all sessions';
480
+ } else if (n === 1) {
481
+ el.textContent = 'Filtered: ' + [...state.selectedSlugs][0];
482
+ } else {
483
+ el.textContent = 'Filtered: ' + n + ' sessions';
484
+ }
485
+ }
486
+
487
+ function getFilteredEvents() {
488
+ return state.events.filter(ev => {
489
+ if (state.selectedSlugs.size > 0 && !state.selectedSlugs.has(ev.slug)) return false;
490
+ if (state.filterType && ev.type !== state.filterType) return false;
491
+ return true;
492
+ });
493
+ }
494
+
495
+ function renderEvents() {
496
+ const el = document.getElementById('eventsList');
497
+ const filtered = getFilteredEvents();
498
+
499
+ if (!filtered.length) {
500
+ el.innerHTML = '<div class="no-data">No events match filter</div>';
501
+ return;
502
+ }
503
+
504
+ let html = '';
505
+ for (const ev of filtered) {
506
+ const time = new Date(ev.timestamp).toLocaleTimeString('en-US', { hour12: false });
507
+ const decision = ev.routingDecision || '';
508
+ const isExpanded = state.expandedEventId === ev.id;
509
+
510
+ html += `<div class="event-row" onclick="toggleEvent('${ev.id}')">
511
+ <span class="event-time">${time}</span>
512
+ <span class="event-slug" title="${esc(ev.slug)}">${esc(ev.slug)}</span>
513
+ <span class="badge ${ev.type}">${ev.type}</span>
514
+ ${decision ? `<span class="badge ${decision}">${decision.replace('_', ' ')}</span>` : '<span></span>'}
515
+ <span class="event-latency">${ev.durationMs !== undefined ? ev.durationMs + 'ms' : ''}</span>
516
+ </div>`;
517
+
518
+ if (isExpanded) {
519
+ html += renderEventDetail(ev);
520
+ }
521
+ }
522
+
523
+ el.innerHTML = html;
524
+ }
525
+
526
+ function renderEventDetail(ev) {
527
+ let html = '<div class="event-detail">';
528
+
529
+ // Summary
530
+ html += '<div class="detail-section"><h3>Summary</h3><div class="detail-kv">';
531
+ html += `<span class="key">ID</span><span class="val">${esc(ev.id)}</span>`;
532
+ html += `<span class="key">Time</span><span class="val">${ev.timestamp}</span>`;
533
+ html += `<span class="key">Type</span><span class="val">${ev.type}</span>`;
534
+ html += `<span class="key">Slug</span><span class="val">${esc(ev.slug)}</span>`;
535
+ html += `<span class="key">Decision</span><span class="val">${ev.routingDecision || 'n/a'}</span>`;
536
+ html += `<span class="key">Response</span><span class="val">${ev.responseStatus}</span>`;
537
+ html += `<span class="key">Duration</span><span class="val">${ev.durationMs}ms</span>`;
538
+ if (ev.downstreamPort) {
539
+ html += `<span class="key">Downstream</span><span class="val">:${ev.downstreamPort} (${ev.downstreamStatus || '?'})</span>`;
540
+ }
541
+ if (ev.forwardDurationMs !== undefined) {
542
+ html += `<span class="key">Forward time</span><span class="val">${ev.forwardDurationMs}ms</span>`;
543
+ }
544
+ if (ev.error) {
545
+ html += `<span class="key">Error</span><span class="val" style="color:var(--red)">${esc(ev.error)}</span>`;
546
+ }
547
+ html += '</div></div>';
548
+
549
+ // Trace waterfall
550
+ if (ev.traceSpans && ev.traceSpans.length) {
551
+ const maxEnd = Math.max(...ev.traceSpans.map(s => s.endMs), 1);
552
+ html += '<div class="detail-section"><h3>Trace</h3>';
553
+ for (const s of ev.traceSpans) {
554
+ const left = (s.startMs / maxEnd * 100).toFixed(1);
555
+ const width = Math.max((s.durationMs / maxEnd * 100), 0.5).toFixed(1);
556
+ html += `<div class="trace-row">
557
+ <span class="trace-label">${esc(s.name)}</span>
558
+ <div class="trace-bar-track"><div class="trace-bar" style="left:${left}%;width:${width}%"></div></div>
559
+ <span class="trace-duration">${s.durationMs}ms</span>
560
+ </div>`;
561
+ }
562
+ html += '</div>';
563
+ }
564
+
565
+ // Payload
566
+ if (ev.payload) {
567
+ html += `<div class="detail-section"><h3>Payload</h3>
568
+ <div class="payload-block">${esc(JSON.stringify(ev.payload, null, 2))}</div></div>`;
569
+ }
570
+
571
+ html += '</div>';
572
+ return html;
573
+ }
574
+
575
+ // --- Actions ---
576
+
577
+ function toggleEvent(id) {
578
+ state.expandedEventId = state.expandedEventId === id ? null : id;
579
+ renderEvents();
580
+ }
581
+
582
+ async function killSession(slug) {
583
+ try {
584
+ const res = await fetch('/api/kill', {
585
+ method: 'POST',
586
+ headers: { 'Content-Type': 'application/json' },
587
+ body: JSON.stringify({ project_slug: slug }),
588
+ });
589
+ if (!res.ok) {
590
+ const data = await res.json();
591
+ console.error('Kill failed:', data.error);
592
+ }
593
+ } catch (err) {
594
+ console.error('Kill failed:', err);
595
+ }
596
+ }
597
+
598
+ // --- Filters ---
599
+
600
+ document.getElementById('filterType').addEventListener('change', (e) => {
601
+ state.filterType = e.target.value;
602
+ renderEvents();
603
+ });
604
+
605
+ // --- Helpers ---
606
+
607
+ function timeAgo(iso) {
608
+ const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
609
+ if (diff < 5) return 'just now';
610
+ if (diff < 60) return diff + 's ago';
611
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
612
+ return Math.floor(diff / 3600) + 'h ago';
613
+ }
614
+
615
+ function esc(s) {
616
+ const d = document.createElement('div');
617
+ d.textContent = String(s);
618
+ return d.innerHTML;
619
+ }
620
+
621
+ // --- Live timestamps ---
622
+ setInterval(() => {
623
+ document.querySelectorAll('.last-event-cell[data-ts]').forEach(el => {
624
+ const ts = el.getAttribute('data-ts');
625
+ if (ts) el.textContent = timeAgo(ts);
626
+ });
627
+ }, 5000);
628
+
629
+ // --- Init ---
630
+ connectSSE();
631
+ </script>
632
+ </body>
633
+ </html>