@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.
- package/package.json +22 -0
- package/src/collectors/collector.ts +65 -0
- package/src/collectors/exception_collector.ts +56 -0
- package/src/collectors/job_collector.ts +117 -0
- package/src/collectors/log_collector.ts +69 -0
- package/src/collectors/query_collector.ts +106 -0
- package/src/collectors/request_collector.ts +126 -0
- package/src/commands/devtools_prune.ts +55 -0
- package/src/dashboard/middleware.ts +42 -0
- package/src/dashboard/routes.ts +509 -0
- package/src/devtools_manager.ts +244 -0
- package/src/errors.ts +3 -0
- package/src/helpers.ts +82 -0
- package/src/index.ts +41 -0
- package/src/recorders/recorder.ts +51 -0
- package/src/recorders/slow_queries.ts +44 -0
- package/src/recorders/slow_requests.ts +44 -0
- package/src/storage/aggregate_store.ts +195 -0
- package/src/storage/entry_store.ts +160 -0
- package/src/types.ts +81 -0
- package/stubs/config/devtools.ts +24 -0
- package/stubs/schemas/devtools_aggregates.ts +14 -0
- package/stubs/schemas/devtools_entries.ts +13 -0
- package/tsconfig.json +4 -0
|
@@ -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
|
+
}
|