api-observe 1.0.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,568 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @file lib/dashboard/index.js
5
+ * @description Self-contained HTML dashboard for browsing API failures.
6
+ * No build step — everything is inlined in a single HTML string.
7
+ */
8
+
9
+ function getDashboardHtml() {
10
+ return `<!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
+ <title>ps-observe | API Failure Tracker</title>
16
+ <style>
17
+ * { margin: 0; padding: 0; box-sizing: border-box; }
18
+
19
+ body {
20
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
21
+ background: #0f1117;
22
+ color: #e1e4e8;
23
+ min-height: 100vh;
24
+ }
25
+
26
+ header {
27
+ background: #161b22;
28
+ border-bottom: 1px solid #30363d;
29
+ padding: 16px 24px;
30
+ display: flex;
31
+ justify-content: space-between;
32
+ align-items: center;
33
+ }
34
+
35
+ header h1 {
36
+ font-size: 20px;
37
+ font-weight: 600;
38
+ color: #58a6ff;
39
+ }
40
+
41
+ header .stats {
42
+ font-size: 13px;
43
+ color: #8b949e;
44
+ }
45
+
46
+ .stats .count {
47
+ color: #f85149;
48
+ font-weight: 700;
49
+ font-size: 16px;
50
+ }
51
+
52
+ .filters {
53
+ background: #161b22;
54
+ padding: 12px 24px;
55
+ display: flex;
56
+ gap: 12px;
57
+ flex-wrap: wrap;
58
+ align-items: center;
59
+ border-bottom: 1px solid #30363d;
60
+ }
61
+
62
+ .filters input, .filters select {
63
+ background: #0d1117;
64
+ border: 1px solid #30363d;
65
+ color: #e1e4e8;
66
+ padding: 6px 10px;
67
+ border-radius: 6px;
68
+ font-size: 13px;
69
+ }
70
+
71
+ .filters input:focus, .filters select:focus {
72
+ outline: none;
73
+ border-color: #58a6ff;
74
+ }
75
+
76
+ .filters button {
77
+ background: #238636;
78
+ color: #fff;
79
+ border: none;
80
+ padding: 6px 16px;
81
+ border-radius: 6px;
82
+ cursor: pointer;
83
+ font-size: 13px;
84
+ }
85
+
86
+ .filters button:hover { background: #2ea043; }
87
+
88
+ .filters button.danger {
89
+ background: #da3633;
90
+ }
91
+
92
+ .filters button.danger:hover { background: #f85149; }
93
+
94
+ .refresh-btn {
95
+ background: #1f6feb !important;
96
+ }
97
+
98
+ .refresh-btn:hover { background: #388bfd !important; }
99
+
100
+ main { padding: 16px 24px; }
101
+
102
+ .summary-cards {
103
+ display: grid;
104
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
105
+ gap: 12px;
106
+ margin-bottom: 20px;
107
+ }
108
+
109
+ .card {
110
+ background: #161b22;
111
+ border: 1px solid #30363d;
112
+ border-radius: 8px;
113
+ padding: 16px;
114
+ }
115
+
116
+ .card .label {
117
+ font-size: 11px;
118
+ text-transform: uppercase;
119
+ color: #8b949e;
120
+ margin-bottom: 4px;
121
+ }
122
+
123
+ .card .value {
124
+ font-size: 24px;
125
+ font-weight: 700;
126
+ }
127
+
128
+ .card .value.red { color: #f85149; }
129
+ .card .value.yellow { color: #d29922; }
130
+ .card .value.blue { color: #58a6ff; }
131
+
132
+ table {
133
+ width: 100%;
134
+ border-collapse: collapse;
135
+ font-size: 13px;
136
+ }
137
+
138
+ thead th {
139
+ background: #161b22;
140
+ padding: 10px 12px;
141
+ text-align: left;
142
+ font-weight: 600;
143
+ color: #8b949e;
144
+ border-bottom: 1px solid #30363d;
145
+ position: sticky;
146
+ top: 0;
147
+ }
148
+
149
+ tbody tr {
150
+ border-bottom: 1px solid #21262d;
151
+ cursor: pointer;
152
+ transition: background 0.15s;
153
+ }
154
+
155
+ tbody tr:hover { background: #1c2128; }
156
+
157
+ td { padding: 10px 12px; }
158
+
159
+ .status-badge {
160
+ display: inline-block;
161
+ padding: 2px 8px;
162
+ border-radius: 12px;
163
+ font-size: 12px;
164
+ font-weight: 600;
165
+ }
166
+
167
+ .status-4xx { background: #d2992233; color: #d29922; }
168
+ .status-5xx { background: #f8514933; color: #f85149; }
169
+ .status-timeout { background: #da363333; color: #ff7b72; }
170
+ .status-network { background: #8b949e33; color: #8b949e; }
171
+
172
+ .type-badge {
173
+ display: inline-block;
174
+ padding: 2px 8px;
175
+ border-radius: 12px;
176
+ font-size: 11px;
177
+ font-weight: 600;
178
+ }
179
+ .type-upstream { background: #388bfd33; color: #58a6ff; }
180
+ .type-controller { background: #a371f733; color: #bc8cff; }
181
+
182
+ .method-badge {
183
+ font-weight: 700;
184
+ font-size: 11px;
185
+ }
186
+
187
+ .method-GET { color: #58a6ff; }
188
+ .method-POST { color: #3fb950; }
189
+ .method-PUT { color: #d29922; }
190
+ .method-PATCH { color: #d29922; }
191
+ .method-DELETE { color: #f85149; }
192
+
193
+ .duration { color: #8b949e; }
194
+ .duration.slow { color: #d29922; }
195
+ .duration.very-slow { color: #f85149; }
196
+
197
+ /* Modal */
198
+ .modal-overlay {
199
+ display: none;
200
+ position: fixed;
201
+ inset: 0;
202
+ background: rgba(0,0,0,0.7);
203
+ z-index: 100;
204
+ justify-content: center;
205
+ align-items: flex-start;
206
+ padding: 40px 20px;
207
+ overflow-y: auto;
208
+ }
209
+
210
+ .modal-overlay.active { display: flex; }
211
+
212
+ .modal {
213
+ background: #161b22;
214
+ border: 1px solid #30363d;
215
+ border-radius: 12px;
216
+ width: 100%;
217
+ max-width: 900px;
218
+ padding: 24px;
219
+ }
220
+
221
+ .modal h2 {
222
+ font-size: 16px;
223
+ margin-bottom: 16px;
224
+ display: flex;
225
+ justify-content: space-between;
226
+ align-items: center;
227
+ }
228
+
229
+ .modal .close-btn {
230
+ background: none;
231
+ border: none;
232
+ color: #8b949e;
233
+ font-size: 20px;
234
+ cursor: pointer;
235
+ }
236
+
237
+ .modal .close-btn:hover { color: #e1e4e8; }
238
+
239
+ .detail-section {
240
+ margin-bottom: 16px;
241
+ }
242
+
243
+ .detail-section h3 {
244
+ font-size: 13px;
245
+ text-transform: uppercase;
246
+ color: #8b949e;
247
+ margin-bottom: 8px;
248
+ padding-bottom: 4px;
249
+ border-bottom: 1px solid #30363d;
250
+ }
251
+
252
+ .detail-grid {
253
+ display: grid;
254
+ grid-template-columns: 120px 1fr;
255
+ gap: 4px 12px;
256
+ font-size: 13px;
257
+ }
258
+
259
+ .detail-grid dt { color: #8b949e; }
260
+ .detail-grid dd { word-break: break-all; }
261
+
262
+ pre.json-block {
263
+ background: #0d1117;
264
+ border: 1px solid #30363d;
265
+ border-radius: 6px;
266
+ padding: 12px;
267
+ font-size: 12px;
268
+ overflow-x: auto;
269
+ max-height: 300px;
270
+ overflow-y: auto;
271
+ white-space: pre-wrap;
272
+ word-break: break-word;
273
+ }
274
+
275
+ .pagination {
276
+ display: flex;
277
+ justify-content: space-between;
278
+ align-items: center;
279
+ padding: 12px 0;
280
+ font-size: 13px;
281
+ color: #8b949e;
282
+ }
283
+
284
+ .pagination button {
285
+ background: #21262d;
286
+ color: #e1e4e8;
287
+ border: 1px solid #30363d;
288
+ padding: 4px 12px;
289
+ border-radius: 6px;
290
+ cursor: pointer;
291
+ }
292
+
293
+ .pagination button:disabled {
294
+ opacity: 0.4;
295
+ cursor: not-allowed;
296
+ }
297
+
298
+ .empty-state {
299
+ text-align: center;
300
+ padding: 60px 20px;
301
+ color: #8b949e;
302
+ }
303
+
304
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
305
+ </style>
306
+ </head>
307
+ <body>
308
+ <header>
309
+ <h1>ps-observe</h1>
310
+ <div class="stats">
311
+ <span class="count" id="totalCount">0</span> failures tracked
312
+ </div>
313
+ </header>
314
+
315
+ <div class="filters">
316
+ <select id="filterType">
317
+ <option value="">All types</option>
318
+ <option value="upstream">Upstream</option>
319
+ <option value="controller">Controller</option>
320
+ </select>
321
+ <input type="text" id="filterService" placeholder="Service name..." />
322
+ <select id="filterMethod">
323
+ <option value="">All methods</option>
324
+ <option value="GET">GET</option>
325
+ <option value="POST">POST</option>
326
+ <option value="PUT">PUT</option>
327
+ <option value="PATCH">PATCH</option>
328
+ <option value="DELETE">DELETE</option>
329
+ </select>
330
+ <input type="number" id="filterStatus" placeholder="Status code..." min="100" max="599" />
331
+ <input type="text" id="filterUrl" placeholder="URL contains..." />
332
+ <button class="refresh-btn" onclick="loadFailures()">Refresh</button>
333
+ <button class="danger" onclick="clearAll()">Clear All</button>
334
+ </div>
335
+
336
+ <main>
337
+ <div class="summary-cards" id="summaryCards"></div>
338
+ <table>
339
+ <thead>
340
+ <tr>
341
+ <th>Time</th>
342
+ <th>Type</th>
343
+ <th>Service</th>
344
+ <th>Method</th>
345
+ <th>URL</th>
346
+ <th>Status</th>
347
+ <th>Duration</th>
348
+ <th>Error</th>
349
+ </tr>
350
+ </thead>
351
+ <tbody id="failureTable"></tbody>
352
+ </table>
353
+ <div class="pagination" id="pagination"></div>
354
+ <div class="empty-state" id="emptyState" style="display:none">
355
+ <div class="icon">&#10003;</div>
356
+ <p>No API failures recorded. All systems operational.</p>
357
+ </div>
358
+ </main>
359
+
360
+ <div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
361
+ <div class="modal" id="modalContent"></div>
362
+ </div>
363
+
364
+ <script>
365
+ const BASE = window.location.pathname.replace(/\\/$/, '');
366
+ let currentOffset = 0;
367
+ const PAGE_SIZE = 50;
368
+
369
+ async function loadFailures() {
370
+ const params = new URLSearchParams();
371
+ const type = document.getElementById('filterType').value;
372
+ const service = document.getElementById('filterService').value;
373
+ const method = document.getElementById('filterMethod').value;
374
+ const statusCode = document.getElementById('filterStatus').value;
375
+ const url = document.getElementById('filterUrl').value;
376
+
377
+ if (type) params.set('type', type);
378
+ if (service) params.set('service', service);
379
+ if (method) params.set('method', method);
380
+ if (statusCode) params.set('statusCode', statusCode);
381
+ if (url) params.set('url', url);
382
+ params.set('limit', PAGE_SIZE);
383
+ params.set('offset', currentOffset);
384
+
385
+ const [failRes, summaryRes] = await Promise.all([
386
+ fetch(BASE + '/api/failures?' + params),
387
+ fetch(BASE + '/api/summary'),
388
+ ]);
389
+
390
+ const failures = await failRes.json();
391
+ const summary = await summaryRes.json();
392
+
393
+ renderSummary(summary);
394
+ renderTable(failures);
395
+ renderPagination(failures);
396
+ document.getElementById('totalCount').textContent = summary.totalFailures;
397
+ }
398
+
399
+ function renderSummary(summary) {
400
+ const el = document.getElementById('summaryCards');
401
+ const services = Object.entries(summary.byService || {}).sort((a, b) => b[1] - a[1]);
402
+ const statuses = Object.entries(summary.byStatusCode || {}).sort((a, b) => b[1] - a[1]);
403
+
404
+ var types = summary.byType || {};
405
+
406
+ el.innerHTML =
407
+ '<div class="card"><div class="label">Total Failures</div><div class="value red">' + summary.totalFailures + '</div></div>' +
408
+ '<div class="card"><div class="label">Upstream Errors</div><div class="value blue">' + (types.upstream || 0) + '</div></div>' +
409
+ '<div class="card"><div class="label">Controller Errors</div><div class="value yellow">' + (types.controller || 0) + '</div></div>' +
410
+ '<div class="card"><div class="label">Services Affected</div><div class="value yellow">' + services.length + '</div></div>' +
411
+ services.slice(0, 3).map(function(s) {
412
+ return '<div class="card"><div class="label">' + escHtml(s[0]) + '</div><div class="value blue">' + s[1] + '</div></div>';
413
+ }).join('');
414
+ }
415
+
416
+ function renderTable(result) {
417
+ const tbody = document.getElementById('failureTable');
418
+ const empty = document.getElementById('emptyState');
419
+
420
+ if (!result.data || result.data.length === 0) {
421
+ tbody.innerHTML = '';
422
+ empty.style.display = 'block';
423
+ return;
424
+ }
425
+ empty.style.display = 'none';
426
+
427
+ tbody.innerHTML = result.data.map(function(f) {
428
+ var statusClass = !f.statusCode ? 'status-timeout'
429
+ : f.statusCode >= 500 ? 'status-5xx'
430
+ : f.statusCode >= 400 ? 'status-4xx'
431
+ : 'status-network';
432
+ var statusText = f.statusCode || (f.errorCode || 'NETWORK');
433
+ var durClass = f.durationMs > 5000 ? 'very-slow' : f.durationMs > 2000 ? 'slow' : '';
434
+ var time = new Date(f.timestamp).toLocaleString();
435
+ var shortUrl = (f.url || '').replace(/https?:\\/\\/[^/]+/, '');
436
+
437
+ var errorType = f.type || 'upstream';
438
+ var typeClass = 'type-' + errorType;
439
+
440
+ return '<tr onclick="showDetail(' + f.id + ')">' +
441
+ '<td>' + escHtml(time) + '</td>' +
442
+ '<td><span class="type-badge ' + typeClass + '">' + errorType + '</span></td>' +
443
+ '<td>' + escHtml(f.service || '') + '</td>' +
444
+ '<td><span class="method-badge method-' + f.method + '">' + f.method + '</span></td>' +
445
+ '<td title="' + escHtml(f.url || '') + '">' + escHtml(shortUrl.substring(0, 60)) + '</td>' +
446
+ '<td><span class="status-badge ' + statusClass + '">' + statusText + '</span></td>' +
447
+ '<td><span class="duration ' + durClass + '">' + (f.durationMs ?? '-') + 'ms</span></td>' +
448
+ '<td>' + escHtml((f.errorMessage || '').substring(0, 50)) + '</td>' +
449
+ '</tr>';
450
+ }).join('');
451
+ }
452
+
453
+ function renderPagination(result) {
454
+ var el = document.getElementById('pagination');
455
+ var total = result.total || 0;
456
+ var from = result.offset + 1;
457
+ var to = Math.min(result.offset + result.limit, total);
458
+
459
+ el.innerHTML = '<span>Showing ' + from + '-' + to + ' of ' + total + '</span>' +
460
+ '<div>' +
461
+ '<button onclick="prevPage()" ' + (currentOffset === 0 ? 'disabled' : '') + '>Prev</button> ' +
462
+ '<button onclick="nextPage()" ' + (to >= total ? 'disabled' : '') + '>Next</button>' +
463
+ '</div>';
464
+ }
465
+
466
+ function prevPage() { currentOffset = Math.max(0, currentOffset - PAGE_SIZE); loadFailures(); }
467
+ function nextPage() { currentOffset += PAGE_SIZE; loadFailures(); }
468
+
469
+ async function showDetail(id) {
470
+ var res = await fetch(BASE + '/api/failures/' + id);
471
+ var f = await res.json();
472
+ var modal = document.getElementById('modalContent');
473
+
474
+ var errorType = f.type || 'upstream';
475
+ var typeClass = 'type-' + errorType;
476
+
477
+ modal.innerHTML =
478
+ '<h2>' +
479
+ '<span><span class="type-badge ' + typeClass + '" style="margin-right:8px">' + errorType + '</span>' + escHtml(f.method) + ' ' + escHtml(f.url || '') + '</span>' +
480
+ '<button class="close-btn" onclick="closeModal()">&times;</button>' +
481
+ '</h2>' +
482
+
483
+ '<div class="detail-section"><h3>Overview</h3>' +
484
+ '<dl class="detail-grid">' +
485
+ '<dt>Type</dt><dd>' + escHtml(errorType) + '</dd>' +
486
+ '<dt>Service</dt><dd>' + escHtml(f.service) + '</dd>' +
487
+ '<dt>Timestamp</dt><dd>' + escHtml(f.timestamp) + '</dd>' +
488
+ '<dt>Status Code</dt><dd>' + (f.statusCode || 'N/A') + '</dd>' +
489
+ '<dt>Duration</dt><dd>' + (f.durationMs != null ? f.durationMs + 'ms' : 'N/A') + '</dd>' +
490
+ '<dt>Correlation ID</dt><dd>' + escHtml(f.correlationId || 'N/A') + '</dd>' +
491
+ '<dt>Error</dt><dd>' + escHtml(f.errorMessage) + '</dd>' +
492
+ (f.errorName ? '<dt>Error Name</dt><dd>' + escHtml(f.errorName) + '</dd>' : '') +
493
+ (f.errorCode ? '<dt>Error Code</dt><dd>' + escHtml(f.errorCode) + '</dd>' : '') +
494
+ '</dl></div>' +
495
+
496
+ (f.stack
497
+ ? '<div class="detail-section"><h3>Stack Trace</h3>' +
498
+ '<pre class="json-block">' + escHtml(f.stack) + '</pre></div>'
499
+ : ''
500
+ ) +
501
+
502
+ '<div class="detail-section"><h3>Request Headers</h3>' +
503
+ '<pre class="json-block">' + escHtml(formatJson(f.request?.headers)) + '</pre></div>' +
504
+
505
+ (f.request?.query
506
+ ? '<div class="detail-section"><h3>Request Query Params</h3>' +
507
+ '<pre class="json-block">' + escHtml(formatJson(f.request?.query)) + '</pre></div>'
508
+ : ''
509
+ ) +
510
+
511
+ '<div class="detail-section"><h3>Request Body</h3>' +
512
+ '<pre class="json-block">' + escHtml(formatJson(f.request?.body)) + '</pre></div>' +
513
+
514
+ (f.response
515
+ ? '<div class="detail-section"><h3>Response Headers</h3>' +
516
+ '<pre class="json-block">' + escHtml(formatJson(f.response?.headers)) + '</pre></div>' +
517
+ '<div class="detail-section"><h3>Response Body</h3>' +
518
+ '<pre class="json-block">' + escHtml(formatJson(f.response?.body)) + '</pre></div>'
519
+ : '<div class="detail-section"><h3>Response</h3><p>No response received (timeout / network error)</p></div>'
520
+ );
521
+
522
+ document.getElementById('modalOverlay').classList.add('active');
523
+ }
524
+
525
+ function closeModal(e) {
526
+ if (!e || e.target === document.getElementById('modalOverlay') || e.target.classList.contains('close-btn')) {
527
+ document.getElementById('modalOverlay').classList.remove('active');
528
+ }
529
+ }
530
+
531
+ async function clearAll() {
532
+ if (!confirm('Clear all failure records?')) return;
533
+ await fetch(BASE + '/api/failures', { method: 'DELETE' });
534
+ currentOffset = 0;
535
+ loadFailures();
536
+ }
537
+
538
+ function formatJson(obj) {
539
+ if (obj == null) return 'null';
540
+ try { return JSON.stringify(obj, null, 2); }
541
+ catch { return String(obj); }
542
+ }
543
+
544
+ function escHtml(str) {
545
+ if (!str) return '';
546
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
547
+ }
548
+
549
+ // Auto-refresh every 30s
550
+ loadFailures();
551
+ setInterval(loadFailures, 30000);
552
+
553
+ // Keyboard: Escape closes modal
554
+ document.addEventListener('keydown', function(e) {
555
+ if (e.key === 'Escape') closeModal(e);
556
+ });
557
+
558
+ // Filter on Enter
559
+ document.querySelectorAll('.filters input, .filters select').forEach(function(el) {
560
+ el.addEventListener('change', function() { currentOffset = 0; loadFailures(); });
561
+ el.addEventListener('keydown', function(e) { if (e.key === 'Enter') { currentOffset = 0; loadFailures(); } });
562
+ });
563
+ </script>
564
+ </body>
565
+ </html>`;
566
+ }
567
+
568
+ module.exports = { getDashboardHtml };