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