@stravigor/devtools 0.1.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,509 @@
1
+ import type Router from '@stravigor/core/http/router'
2
+ import type Context from '@stravigor/core/http/context'
3
+ import { dashboardAuth } from './middleware.ts'
4
+ import DevtoolsManager from '../devtools_manager.ts'
5
+ import type { EntryType, AggregateFunction } from '../types.ts'
6
+ import { PERIODS } from '../storage/aggregate_store.ts'
7
+
8
+ /**
9
+ * Register the devtools dashboard routes on a router.
10
+ *
11
+ * Mounts the API under `/_devtools` and serves the SPA dashboard.
12
+ *
13
+ * @example
14
+ * import { registerDashboard } from '@stravigor/devtools/dashboard/routes'
15
+ * registerDashboard(router)
16
+ *
17
+ * // With custom auth guard
18
+ * registerDashboard(router, (ctx) => ctx.get('user')?.isAdmin)
19
+ */
20
+ export function registerDashboard(
21
+ router: Router,
22
+ guard?: (ctx: Context) => boolean | Promise<boolean>
23
+ ): void {
24
+ router.group({ prefix: '/_devtools', middleware: [dashboardAuth(guard)] }, r => {
25
+ // ---- SPA entry point ----
26
+ r.get('', serveDashboard)
27
+
28
+ // ---- API: Entries (Inspector) ----
29
+ r.get('/api/entries', listEntries)
30
+ r.get('/api/entries/:uuid', showEntry)
31
+ r.get('/api/entries/:uuid/batch', showBatch)
32
+ r.get('/api/entries/tag/:tag', entriesByTag)
33
+
34
+ // ---- API: Aggregates (Metrics) ----
35
+ r.get('/api/metrics/:type', queryMetrics)
36
+ r.get('/api/metrics/:type/top', topKeys)
37
+
38
+ // ---- API: Stats ----
39
+ r.get('/api/stats', stats)
40
+
41
+ // ---- API: Prune ----
42
+ r.delete('/api/entries', pruneEntries)
43
+ })
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Handlers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function serveDashboard(ctx: Context): Response {
51
+ return ctx.html(dashboardHtml())
52
+ }
53
+
54
+ async function listEntries(ctx: Context): Promise<Response> {
55
+ const type = ctx.qs('type') as EntryType | null
56
+ const limit = ctx.qs('limit', 50)
57
+ const offset = ctx.qs('offset', 0)
58
+
59
+ const entries = await DevtoolsManager.entryStore.list(type ?? undefined, limit, offset)
60
+ return ctx.json({ data: entries })
61
+ }
62
+
63
+ async function showEntry(ctx: Context): Promise<Response> {
64
+ const entry = await DevtoolsManager.entryStore.find(ctx.params.uuid!)
65
+ if (!entry) return ctx.json({ error: 'Entry not found' }, 404)
66
+ return ctx.json({ data: entry })
67
+ }
68
+
69
+ async function showBatch(ctx: Context): Promise<Response> {
70
+ const entry = await DevtoolsManager.entryStore.find(ctx.params.uuid!)
71
+ if (!entry) return ctx.json({ error: 'Entry not found' }, 404)
72
+
73
+ const batch = await DevtoolsManager.entryStore.batch(entry.batchId)
74
+ return ctx.json({ data: batch })
75
+ }
76
+
77
+ async function entriesByTag(ctx: Context): Promise<Response> {
78
+ const limit = ctx.qs('limit', 50)
79
+ const entries = await DevtoolsManager.entryStore.byTag(ctx.params.tag!, limit)
80
+ return ctx.json({ data: entries })
81
+ }
82
+
83
+ async function queryMetrics(ctx: Context): Promise<Response> {
84
+ const type = ctx.params.type!
85
+ const period = ctx.qs('period', PERIODS.ONE_HOUR)
86
+ const aggregate = (ctx.qs('aggregate') ?? 'count') as AggregateFunction
87
+ const limit = ctx.qs('limit', 24)
88
+
89
+ const data = await DevtoolsManager.aggregateStore.query(type, period, aggregate, limit)
90
+ return ctx.json({ data })
91
+ }
92
+
93
+ async function topKeys(ctx: Context): Promise<Response> {
94
+ const type = ctx.params.type!
95
+ const period = ctx.qs('period', PERIODS.ONE_HOUR)
96
+ const aggregate = (ctx.qs('aggregate') ?? 'count') as AggregateFunction
97
+ const limit = ctx.qs('limit', 10)
98
+
99
+ const data = await DevtoolsManager.aggregateStore.topKeys(type, period, aggregate, limit)
100
+ return ctx.json({ data })
101
+ }
102
+
103
+ async function stats(ctx: Context): Promise<Response> {
104
+ const [requests, queries, exceptions, logs, jobs] = await Promise.all([
105
+ DevtoolsManager.entryStore.count('request'),
106
+ DevtoolsManager.entryStore.count('query'),
107
+ DevtoolsManager.entryStore.count('exception'),
108
+ DevtoolsManager.entryStore.count('log'),
109
+ DevtoolsManager.entryStore.count('job'),
110
+ ])
111
+
112
+ return ctx.json({
113
+ data: {
114
+ requests,
115
+ queries,
116
+ exceptions,
117
+ logs,
118
+ jobs,
119
+ total: requests + queries + exceptions + logs + jobs,
120
+ },
121
+ })
122
+ }
123
+
124
+ async function pruneEntries(ctx: Context): Promise<Response> {
125
+ const hours = ctx.qs('hours', DevtoolsManager.config.storage.pruneAfter)
126
+ const entries = await DevtoolsManager.entryStore.prune(hours)
127
+ const aggregates = await DevtoolsManager.aggregateStore.prune(hours)
128
+ return ctx.json({ data: { entries, aggregates } })
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Dashboard HTML
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function dashboardHtml(): string {
136
+ return `<!DOCTYPE html>
137
+ <html lang="en">
138
+ <head>
139
+ <meta charset="utf-8">
140
+ <meta name="viewport" content="width=device-width, initial-scale=1">
141
+ <title>Strav Devtools</title>
142
+ <style>
143
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
144
+ :root{--bg:#0f1117;--surface:#1a1d27;--border:#2a2d3a;--text:#e4e4e7;--text-muted:#71717a;--accent:#6366f1;--accent-hover:#818cf8;--success:#22c55e;--warning:#eab308;--danger:#ef4444;--info:#3b82f6;--radius:8px;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;--mono:"SF Mono","Fira Code","Fira Mono",monospace}
145
+ body{font-family:var(--font);background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
146
+ a{color:var(--accent);text-decoration:none}
147
+ a:hover{color:var(--accent-hover)}
148
+
149
+ .layout{display:flex;min-height:100vh}
150
+ .sidebar{width:220px;background:var(--surface);border-right:1px solid var(--border);padding:1rem 0;position:fixed;height:100vh;overflow-y:auto}
151
+ .sidebar h1{font-size:.875rem;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);padding:0 1rem;margin-bottom:1rem}
152
+ .sidebar .logo{font-size:1.125rem;font-weight:700;color:var(--accent);padding:0 1rem;margin-bottom:1.5rem;letter-spacing:-.02em;text-transform:none}
153
+ .sidebar nav a{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;color:var(--text-muted);font-size:.875rem;transition:all .15s}
154
+ .sidebar nav a:hover,.sidebar nav a.active{color:var(--text);background:rgba(99,102,241,.1)}
155
+ .sidebar nav a.active{border-right:2px solid var(--accent)}
156
+ .sidebar nav .section{margin-top:1.25rem;padding:0 1rem;font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin-bottom:.25rem}
157
+
158
+ .main{margin-left:220px;flex:1;padding:1.5rem}
159
+ .header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem}
160
+ .header h2{font-size:1.25rem;font-weight:600}
161
+ .header .actions{display:flex;gap:.5rem}
162
+
163
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.75rem;margin-bottom:1.5rem}
164
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem}
165
+ .stat .label{font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em}
166
+ .stat .value{font-size:1.5rem;font-weight:700;margin-top:.25rem}
167
+
168
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
169
+ .card-header{padding:.75rem 1rem;border-bottom:1px solid var(--border);font-size:.875rem;font-weight:600;display:flex;align-items:center;justify-content:space-between}
170
+
171
+ table{width:100%;border-collapse:collapse;font-size:.8125rem}
172
+ thead th{text-align:left;padding:.625rem 1rem;border-bottom:1px solid var(--border);color:var(--text-muted);font-weight:500;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
173
+ tbody tr{border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s}
174
+ tbody tr:hover{background:rgba(99,102,241,.05)}
175
+ tbody tr:last-child{border-bottom:none}
176
+ tbody td{padding:.625rem 1rem;vertical-align:middle}
177
+
178
+ .badge{display:inline-block;padding:.125rem .5rem;border-radius:100px;font-size:.6875rem;font-weight:600}
179
+ .badge-success{background:rgba(34,197,94,.15);color:var(--success)}
180
+ .badge-warning{background:rgba(234,179,8,.15);color:var(--warning)}
181
+ .badge-danger{background:rgba(239,68,68,.15);color:var(--danger)}
182
+ .badge-info{background:rgba(59,130,246,.15);color:var(--info)}
183
+ .badge-muted{background:rgba(113,113,122,.15);color:var(--text-muted)}
184
+
185
+ .tag{display:inline-block;padding:.0625rem .375rem;border-radius:4px;font-size:.6875rem;background:rgba(99,102,241,.15);color:var(--accent);margin-right:.25rem}
186
+
187
+ .btn{display:inline-flex;align-items:center;gap:.375rem;padding:.375rem .75rem;border-radius:6px;font-size:.8125rem;font-weight:500;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;transition:all .15s}
188
+ .btn:hover{border-color:var(--accent);color:var(--accent)}
189
+ .btn-sm{padding:.25rem .5rem;font-size:.75rem}
190
+ .btn-danger{border-color:var(--danger);color:var(--danger)}
191
+ .btn-danger:hover{background:rgba(239,68,68,.1)}
192
+
193
+ .tabs{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
194
+ .tab{padding:.5rem 1rem;font-size:.8125rem;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
195
+ .tab:hover{color:var(--text)}
196
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
197
+
198
+ .detail{padding:1rem}
199
+ .detail-row{display:flex;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--border)}
200
+ .detail-row:last-child{border-bottom:none}
201
+ .detail-label{width:120px;flex-shrink:0;font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em}
202
+ .detail-value{flex:1;font-family:var(--mono);font-size:.8125rem;word-break:break-all}
203
+
204
+ .sql{font-family:var(--mono);font-size:.8125rem;color:#a78bfa;white-space:pre-wrap}
205
+ .duration{font-family:var(--mono);font-size:.8125rem}
206
+ .duration.slow{color:var(--danger)}
207
+ .method{font-weight:600;font-size:.75rem;letter-spacing:.03em}
208
+ .method.GET{color:var(--success)}
209
+ .method.POST{color:var(--info)}
210
+ .method.PUT,.method.PATCH{color:var(--warning)}
211
+ .method.DELETE{color:var(--danger)}
212
+
213
+ .empty{text-align:center;padding:3rem;color:var(--text-muted);font-size:.875rem}
214
+ .loading{text-align:center;padding:2rem;color:var(--text-muted)}
215
+
216
+ .json-view{font-family:var(--mono);font-size:.8125rem;white-space:pre-wrap;background:var(--bg);padding:.75rem;border-radius:6px;max-height:400px;overflow-y:auto}
217
+
218
+ @media(max-width:768px){.sidebar{display:none}.main{margin-left:0}}
219
+ </style>
220
+ </head>
221
+ <body>
222
+ <div class="layout">
223
+ <aside class="sidebar">
224
+ <div class="logo">Strav Devtools</div>
225
+ <nav>
226
+ <div class="section">Inspector</div>
227
+ <a href="#" data-view="requests" class="active">Requests</a>
228
+ <a href="#" data-view="queries">Queries</a>
229
+ <a href="#" data-view="exceptions">Exceptions</a>
230
+ <a href="#" data-view="logs">Logs</a>
231
+ <a href="#" data-view="jobs">Jobs</a>
232
+ <div class="section">Metrics</div>
233
+ <a href="#" data-view="slow-requests">Slow Requests</a>
234
+ <a href="#" data-view="slow-queries">Slow Queries</a>
235
+ </nav>
236
+ </aside>
237
+ <main class="main" id="app">
238
+ <div class="loading">Loading...</div>
239
+ </main>
240
+ </div>
241
+ <script>
242
+ const API = '/_devtools/api'
243
+ let currentView = 'requests'
244
+ let polling = null
245
+
246
+ // ---- Navigation ----
247
+ document.querySelectorAll('.sidebar nav a').forEach(link => {
248
+ link.addEventListener('click', (e) => {
249
+ e.preventDefault()
250
+ document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active'))
251
+ link.classList.add('active')
252
+ currentView = link.dataset.view
253
+ render()
254
+ })
255
+ })
256
+
257
+ // ---- Fetch helpers ----
258
+ async function api(path) {
259
+ const res = await fetch(API + path)
260
+ return res.json()
261
+ }
262
+
263
+ function timeAgo(date) {
264
+ const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
265
+ if (seconds < 60) return seconds + 's ago'
266
+ if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
267
+ if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'
268
+ return Math.floor(seconds / 86400) + 'd ago'
269
+ }
270
+
271
+ function statusBadge(status) {
272
+ if (status >= 500) return '<span class="badge badge-danger">' + status + '</span>'
273
+ if (status >= 400) return '<span class="badge badge-warning">' + status + '</span>'
274
+ if (status >= 300) return '<span class="badge badge-info">' + status + '</span>'
275
+ return '<span class="badge badge-success">' + status + '</span>'
276
+ }
277
+
278
+ function durationEl(ms) {
279
+ const cls = ms > 1000 ? 'duration slow' : 'duration'
280
+ return '<span class="' + cls + '">' + ms.toFixed(1) + 'ms</span>'
281
+ }
282
+
283
+ function tagsHtml(tags) {
284
+ return (tags || []).map(t => '<span class="tag">' + escHtml(t) + '</span>').join('')
285
+ }
286
+
287
+ function escHtml(s) {
288
+ const d = document.createElement('div')
289
+ d.textContent = s
290
+ return d.innerHTML
291
+ }
292
+
293
+ function jsonView(obj) {
294
+ return '<div class="json-view">' + escHtml(JSON.stringify(obj, null, 2)) + '</div>'
295
+ }
296
+
297
+ // ---- Views ----
298
+ async function renderRequests() {
299
+ const { data } = await api('/entries?type=request&limit=100')
300
+ if (!data.length) return '<div class="empty">No requests recorded yet.</div>'
301
+
302
+ let html = '<div class="header"><h2>Requests</h2><div class="actions"><button class="btn btn-sm" onclick="render()">Refresh</button></div></div>'
303
+ html += '<div class="card"><table><thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Time</th></tr></thead><tbody>'
304
+ for (const e of data) {
305
+ const c = e.content
306
+ html += '<tr onclick="showDetail(\\'' + e.uuid + '\\')">'
307
+ html += '<td><span class="method ' + escHtml(c.method) + '">' + escHtml(c.method) + '</span></td>'
308
+ html += '<td>' + escHtml(c.path) + '</td>'
309
+ html += '<td>' + statusBadge(c.status) + '</td>'
310
+ html += '<td>' + durationEl(c.duration) + '</td>'
311
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(e.createdAt) + '</td>'
312
+ html += '</tr>'
313
+ }
314
+ html += '</tbody></table></div>'
315
+ return html
316
+ }
317
+
318
+ async function renderQueries() {
319
+ const { data } = await api('/entries?type=query&limit=100')
320
+ if (!data.length) return '<div class="empty">No queries recorded yet.</div>'
321
+
322
+ let html = '<div class="header"><h2>Queries</h2></div>'
323
+ html += '<div class="card"><table><thead><tr><th>SQL</th><th>Duration</th><th>Slow</th><th>Time</th></tr></thead><tbody>'
324
+ for (const e of data) {
325
+ const c = e.content
326
+ const sqlPreview = (c.sql || '').substring(0, 120) + ((c.sql || '').length > 120 ? '...' : '')
327
+ html += '<tr onclick="showDetail(\\'' + e.uuid + '\\')">'
328
+ html += '<td><span class="sql">' + escHtml(sqlPreview) + '</span></td>'
329
+ html += '<td>' + durationEl(c.duration) + '</td>'
330
+ html += '<td>' + (c.slow ? '<span class="badge badge-danger">Slow</span>' : '<span class="badge badge-muted">OK</span>') + '</td>'
331
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(e.createdAt) + '</td>'
332
+ html += '</tr>'
333
+ }
334
+ html += '</tbody></table></div>'
335
+ return html
336
+ }
337
+
338
+ async function renderExceptions() {
339
+ const { data } = await api('/entries?type=exception&limit=100')
340
+ if (!data.length) return '<div class="empty">No exceptions recorded.</div>'
341
+
342
+ let html = '<div class="header"><h2>Exceptions</h2></div>'
343
+ html += '<div class="card"><table><thead><tr><th>Class</th><th>Message</th><th>Path</th><th>Time</th></tr></thead><tbody>'
344
+ for (const e of data) {
345
+ const c = e.content
346
+ html += '<tr onclick="showDetail(\\'' + e.uuid + '\\')">'
347
+ html += '<td><span class="badge badge-danger">' + escHtml(c.class || 'Error') + '</span></td>'
348
+ html += '<td>' + escHtml((c.message || '').substring(0, 80)) + '</td>'
349
+ html += '<td style="color:var(--text-muted)">' + escHtml(c.path || '-') + '</td>'
350
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(e.createdAt) + '</td>'
351
+ html += '</tr>'
352
+ }
353
+ html += '</tbody></table></div>'
354
+ return html
355
+ }
356
+
357
+ async function renderLogs() {
358
+ const { data } = await api('/entries?type=log&limit=100')
359
+ if (!data.length) return '<div class="empty">No log entries recorded.</div>'
360
+
361
+ let html = '<div class="header"><h2>Logs</h2></div>'
362
+ html += '<div class="card"><table><thead><tr><th>Level</th><th>Message</th><th>Time</th></tr></thead><tbody>'
363
+ for (const e of data) {
364
+ const c = e.content
365
+ const badge = c.level === 'error' || c.level === 'fatal' ? 'badge-danger' : c.level === 'warn' ? 'badge-warning' : c.level === 'info' ? 'badge-info' : 'badge-muted'
366
+ html += '<tr onclick="showDetail(\\'' + e.uuid + '\\')">'
367
+ html += '<td><span class="badge ' + badge + '">' + escHtml(c.level) + '</span></td>'
368
+ html += '<td>' + escHtml((c.message || '').substring(0, 120)) + '</td>'
369
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(e.createdAt) + '</td>'
370
+ html += '</tr>'
371
+ }
372
+ html += '</tbody></table></div>'
373
+ return html
374
+ }
375
+
376
+ async function renderJobs() {
377
+ const { data } = await api('/entries?type=job&limit=100')
378
+ if (!data.length) return '<div class="empty">No jobs recorded yet.</div>'
379
+
380
+ let html = '<div class="header"><h2>Jobs</h2></div>'
381
+ html += '<div class="card"><table><thead><tr><th>Name</th><th>Status</th><th>Queue</th><th>Duration</th><th>Time</th></tr></thead><tbody>'
382
+ for (const e of data) {
383
+ const c = e.content
384
+ const badge = c.status === 'processed' ? 'badge-success' : c.status === 'failed' ? 'badge-danger' : 'badge-info'
385
+ html += '<tr onclick="showDetail(\\'' + e.uuid + '\\')">'
386
+ html += '<td>' + escHtml(c.name) + '</td>'
387
+ html += '<td><span class="badge ' + badge + '">' + escHtml(c.status) + '</span></td>'
388
+ html += '<td style="color:var(--text-muted)">' + escHtml(c.queue || 'default') + '</td>'
389
+ html += '<td>' + (c.duration != null ? durationEl(c.duration) : '-') + '</td>'
390
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(e.createdAt) + '</td>'
391
+ html += '</tr>'
392
+ }
393
+ html += '</tbody></table></div>'
394
+ return html
395
+ }
396
+
397
+ async function renderSlowRequests() {
398
+ const { data } = await api('/metrics/slow_request/top?period=3600&aggregate=count&limit=15')
399
+ let html = '<div class="header"><h2>Slow Requests</h2></div>'
400
+
401
+ if (!data.length) return html + '<div class="empty">No slow requests recorded.</div>'
402
+
403
+ html += '<div class="card"><table><thead><tr><th>Endpoint</th><th>Count</th><th>Max (ms)</th></tr></thead><tbody>'
404
+
405
+ const topMax = await api('/metrics/slow_request/top?period=3600&aggregate=max&limit=15')
406
+ const maxMap = {}
407
+ for (const r of topMax.data) maxMap[r.key] = r.value
408
+
409
+ for (const r of data) {
410
+ html += '<tr>'
411
+ html += '<td>' + escHtml(r.key) + '</td>'
412
+ html += '<td><span class="badge badge-danger">' + Math.round(r.value) + '</span></td>'
413
+ html += '<td>' + (maxMap[r.key] != null ? durationEl(maxMap[r.key]) : '-') + '</td>'
414
+ html += '</tr>'
415
+ }
416
+ html += '</tbody></table></div>'
417
+ return html
418
+ }
419
+
420
+ async function renderSlowQueries() {
421
+ const { data } = await api('/metrics/slow_query/top?period=3600&aggregate=count&limit=15')
422
+ let html = '<div class="header"><h2>Slow Queries</h2></div>'
423
+
424
+ if (!data.length) return html + '<div class="empty">No slow queries recorded.</div>'
425
+
426
+ html += '<div class="card"><table><thead><tr><th>Query</th><th>Count</th><th>Max (ms)</th></tr></thead><tbody>'
427
+
428
+ const topMax = await api('/metrics/slow_query/top?period=3600&aggregate=max&limit=15')
429
+ const maxMap = {}
430
+ for (const r of topMax.data) maxMap[r.key] = r.value
431
+
432
+ for (const r of data) {
433
+ html += '<tr>'
434
+ html += '<td><span class="sql">' + escHtml(r.key.substring(0, 100)) + '</span></td>'
435
+ html += '<td><span class="badge badge-danger">' + Math.round(r.value) + '</span></td>'
436
+ html += '<td>' + (maxMap[r.key] != null ? durationEl(maxMap[r.key]) : '-') + '</td>'
437
+ html += '</tr>'
438
+ }
439
+ html += '</tbody></table></div>'
440
+ return html
441
+ }
442
+
443
+ async function showDetail(uuid) {
444
+ const app = document.getElementById('app')
445
+ app.innerHTML = '<div class="loading">Loading...</div>'
446
+
447
+ const { data: entry } = await api('/entries/' + uuid)
448
+ const { data: batch } = await api('/entries/' + uuid + '/batch')
449
+
450
+ let html = '<div class="header"><h2>Entry Detail</h2><div class="actions"><button class="btn btn-sm" onclick="render()">Back</button></div></div>'
451
+ html += '<div class="card"><div class="detail">'
452
+ html += '<div class="detail-row"><div class="detail-label">UUID</div><div class="detail-value">' + escHtml(entry.uuid) + '</div></div>'
453
+ html += '<div class="detail-row"><div class="detail-label">Type</div><div class="detail-value"><span class="badge badge-info">' + escHtml(entry.type) + '</span></div></div>'
454
+ html += '<div class="detail-row"><div class="detail-label">Batch ID</div><div class="detail-value">' + escHtml(entry.batchId) + '</div></div>'
455
+ html += '<div class="detail-row"><div class="detail-label">Tags</div><div class="detail-value">' + tagsHtml(entry.tags) + '</div></div>'
456
+ html += '<div class="detail-row"><div class="detail-label">Created</div><div class="detail-value">' + new Date(entry.createdAt).toLocaleString() + '</div></div>'
457
+ html += '<div class="detail-row"><div class="detail-label">Content</div><div class="detail-value">' + jsonView(entry.content) + '</div></div>'
458
+ html += '</div></div>'
459
+
460
+ if (batch.length > 1) {
461
+ html += '<div style="margin-top:1rem"><div class="card"><div class="card-header">Related Entries (' + batch.length + ')</div>'
462
+ html += '<table><thead><tr><th>Type</th><th>Summary</th><th>Time</th></tr></thead><tbody>'
463
+ for (const b of batch) {
464
+ if (b.uuid === entry.uuid) continue
465
+ const summary = b.type === 'query' ? (b.content.sql || '').substring(0, 80) : b.type === 'log' ? b.content.message : b.type === 'exception' ? b.content.message : JSON.stringify(b.content).substring(0, 80)
466
+ html += '<tr onclick="showDetail(\\'' + b.uuid + '\\')" style="cursor:pointer">'
467
+ html += '<td><span class="badge badge-info">' + escHtml(b.type) + '</span></td>'
468
+ html += '<td>' + escHtml(summary) + '</td>'
469
+ html += '<td style="color:var(--text-muted);font-size:.75rem">' + timeAgo(b.createdAt) + '</td>'
470
+ html += '</tr>'
471
+ }
472
+ html += '</tbody></table></div></div>'
473
+ }
474
+
475
+ app.innerHTML = html
476
+ }
477
+
478
+ // ---- Render ----
479
+ async function render() {
480
+ const app = document.getElementById('app')
481
+ app.innerHTML = '<div class="loading">Loading...</div>'
482
+
483
+ try {
484
+ let html = ''
485
+ switch (currentView) {
486
+ case 'requests': html = await renderRequests(); break
487
+ case 'queries': html = await renderQueries(); break
488
+ case 'exceptions': html = await renderExceptions(); break
489
+ case 'logs': html = await renderLogs(); break
490
+ case 'jobs': html = await renderJobs(); break
491
+ case 'slow-requests': html = await renderSlowRequests(); break
492
+ case 'slow-queries': html = await renderSlowQueries(); break
493
+ }
494
+ app.innerHTML = html
495
+ } catch (err) {
496
+ app.innerHTML = '<div class="empty">Error loading data: ' + escHtml(err.message) + '</div>'
497
+ }
498
+ }
499
+
500
+ // Make showDetail available globally
501
+ window.showDetail = showDetail
502
+
503
+ // Initial render + auto-refresh
504
+ render()
505
+ polling = setInterval(render, 5000)
506
+ </script>
507
+ </body>
508
+ </html>`
509
+ }