adonisjs-server-stats 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +144 -9
  2. package/dist/src/dashboard/chart_aggregator.d.ts +21 -0
  3. package/dist/src/dashboard/chart_aggregator.d.ts.map +1 -0
  4. package/dist/src/dashboard/chart_aggregator.js +89 -0
  5. package/dist/src/dashboard/dashboard_controller.d.ts +147 -0
  6. package/dist/src/dashboard/dashboard_controller.d.ts.map +1 -0
  7. package/dist/src/dashboard/dashboard_controller.js +1008 -0
  8. package/dist/src/dashboard/dashboard_routes.d.ts +16 -0
  9. package/dist/src/dashboard/dashboard_routes.d.ts.map +1 -0
  10. package/dist/src/dashboard/dashboard_routes.js +88 -0
  11. package/dist/src/dashboard/dashboard_store.d.ts +158 -0
  12. package/dist/src/dashboard/dashboard_store.d.ts.map +1 -0
  13. package/dist/src/dashboard/dashboard_store.js +723 -0
  14. package/dist/src/dashboard/integrations/cache_inspector.d.ts +88 -0
  15. package/dist/src/dashboard/integrations/cache_inspector.d.ts.map +1 -0
  16. package/dist/src/dashboard/integrations/cache_inspector.js +215 -0
  17. package/dist/src/dashboard/integrations/config_inspector.d.ts +33 -0
  18. package/dist/src/dashboard/integrations/config_inspector.d.ts.map +1 -0
  19. package/dist/src/dashboard/integrations/config_inspector.js +155 -0
  20. package/dist/src/dashboard/integrations/index.d.ts +7 -0
  21. package/dist/src/dashboard/integrations/index.d.ts.map +1 -0
  22. package/dist/src/dashboard/integrations/index.js +3 -0
  23. package/dist/src/dashboard/integrations/queue_inspector.d.ts +106 -0
  24. package/dist/src/dashboard/integrations/queue_inspector.d.ts.map +1 -0
  25. package/dist/src/dashboard/integrations/queue_inspector.js +182 -0
  26. package/dist/src/dashboard/migrator.d.ts +18 -0
  27. package/dist/src/dashboard/migrator.d.ts.map +1 -0
  28. package/dist/src/dashboard/migrator.js +144 -0
  29. package/dist/src/dashboard/models/stats_email.d.ts +19 -0
  30. package/dist/src/dashboard/models/stats_email.d.ts.map +1 -0
  31. package/dist/src/dashboard/models/stats_email.js +66 -0
  32. package/dist/src/dashboard/models/stats_event.d.ts +14 -0
  33. package/dist/src/dashboard/models/stats_event.d.ts.map +1 -0
  34. package/dist/src/dashboard/models/stats_event.js +43 -0
  35. package/dist/src/dashboard/models/stats_log.d.ts +12 -0
  36. package/dist/src/dashboard/models/stats_log.d.ts.map +1 -0
  37. package/dist/src/dashboard/models/stats_log.js +42 -0
  38. package/dist/src/dashboard/models/stats_metric.d.ts +15 -0
  39. package/dist/src/dashboard/models/stats_metric.d.ts.map +1 -0
  40. package/dist/src/dashboard/models/stats_metric.js +50 -0
  41. package/dist/src/dashboard/models/stats_query.d.ts +20 -0
  42. package/dist/src/dashboard/models/stats_query.d.ts.map +1 -0
  43. package/dist/src/dashboard/models/stats_query.js +67 -0
  44. package/dist/src/dashboard/models/stats_request.d.ts +21 -0
  45. package/dist/src/dashboard/models/stats_request.d.ts.map +1 -0
  46. package/dist/src/dashboard/models/stats_request.js +61 -0
  47. package/dist/src/dashboard/models/stats_saved_filter.d.ts +11 -0
  48. package/dist/src/dashboard/models/stats_saved_filter.d.ts.map +1 -0
  49. package/dist/src/dashboard/models/stats_saved_filter.js +38 -0
  50. package/dist/src/dashboard/models/stats_trace.d.ts +19 -0
  51. package/dist/src/dashboard/models/stats_trace.d.ts.map +1 -0
  52. package/dist/src/dashboard/models/stats_trace.js +67 -0
  53. package/dist/src/debug/debug_store.d.ts +5 -0
  54. package/dist/src/debug/debug_store.d.ts.map +1 -1
  55. package/dist/src/debug/debug_store.js +10 -0
  56. package/dist/src/debug/email_collector.d.ts +2 -0
  57. package/dist/src/debug/email_collector.d.ts.map +1 -1
  58. package/dist/src/debug/email_collector.js +4 -0
  59. package/dist/src/debug/event_collector.d.ts +2 -0
  60. package/dist/src/debug/event_collector.d.ts.map +1 -1
  61. package/dist/src/debug/event_collector.js +11 -2
  62. package/dist/src/debug/query_collector.d.ts +2 -0
  63. package/dist/src/debug/query_collector.d.ts.map +1 -1
  64. package/dist/src/debug/query_collector.js +11 -0
  65. package/dist/src/debug/ring_buffer.d.ts +3 -0
  66. package/dist/src/debug/ring_buffer.d.ts.map +1 -1
  67. package/dist/src/debug/ring_buffer.js +6 -0
  68. package/dist/src/debug/trace_collector.d.ts +4 -2
  69. package/dist/src/debug/trace_collector.d.ts.map +1 -1
  70. package/dist/src/debug/trace_collector.js +7 -2
  71. package/dist/src/debug/types.d.ts +8 -0
  72. package/dist/src/debug/types.d.ts.map +1 -1
  73. package/dist/src/edge/client/dashboard.css +1504 -0
  74. package/dist/src/edge/client/dashboard.js +2378 -0
  75. package/dist/src/edge/client/debug-panel.css +528 -108
  76. package/dist/src/edge/client/debug-panel.js +663 -22
  77. package/dist/src/edge/client/stats-bar.css +112 -38
  78. package/dist/src/edge/client/stats-bar.js +37 -3
  79. package/dist/src/edge/plugin.d.ts.map +1 -1
  80. package/dist/src/edge/plugin.js +21 -0
  81. package/dist/src/edge/views/dashboard.edge +382 -0
  82. package/dist/src/edge/views/debug-panel.edge +60 -14
  83. package/dist/src/edge/views/stats-bar.edge +9 -0
  84. package/dist/src/index.d.ts +2 -0
  85. package/dist/src/index.d.ts.map +1 -1
  86. package/dist/src/index.js +1 -0
  87. package/dist/src/middleware/request_tracking_middleware.d.ts +20 -0
  88. package/dist/src/middleware/request_tracking_middleware.d.ts.map +1 -1
  89. package/dist/src/middleware/request_tracking_middleware.js +66 -2
  90. package/dist/src/provider/server_stats_provider.d.ts +13 -0
  91. package/dist/src/provider/server_stats_provider.d.ts.map +1 -1
  92. package/dist/src/provider/server_stats_provider.js +175 -1
  93. package/dist/src/types.d.ts +42 -0
  94. package/dist/src/types.d.ts.map +1 -1
  95. package/package.json +14 -1
@@ -0,0 +1,1008 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CacheInspector } from './integrations/cache_inspector.js';
6
+ import { QueueInspector } from './integrations/queue_inspector.js';
7
+ import { ConfigInspector } from './integrations/config_inspector.js';
8
+ const EDGE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'edge');
9
+ /**
10
+ * Controller for the full-page dashboard.
11
+ *
12
+ * Serves the dashboard HTML page and all JSON API endpoints.
13
+ * Constructor-injected with the SQLite-backed DashboardStore,
14
+ * the in-memory DebugStore, and the AdonisJS application instance.
15
+ */
16
+ export default class DashboardController {
17
+ dashboardStore;
18
+ debugStore;
19
+ app;
20
+ cacheInspector = null;
21
+ queueInspector = null;
22
+ configInspector;
23
+ cacheAvailable = null;
24
+ queueAvailable = null;
25
+ cachedCss = null;
26
+ cachedJs = null;
27
+ cachedTransmitClient = null;
28
+ constructor(dashboardStore, debugStore, app) {
29
+ this.dashboardStore = dashboardStore;
30
+ this.debugStore = debugStore;
31
+ this.app = app;
32
+ this.configInspector = new ConfigInspector(app);
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Page
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * GET {dashboardPath} — Render the dashboard Edge template.
39
+ *
40
+ * Reads the dashboard CSS/JS assets and passes them as template state
41
+ * along with configuration for tracing and custom panes.
42
+ */
43
+ async page(ctx) {
44
+ if (!this.checkAccess(ctx)) {
45
+ return ctx.response.forbidden({ error: 'Access denied' });
46
+ }
47
+ // Lazily read and cache the CSS/JS assets
48
+ if (!this.cachedCss) {
49
+ this.cachedCss = readFileSync(join(EDGE_DIR, 'client', 'dashboard.css'), 'utf-8');
50
+ }
51
+ if (!this.cachedJs) {
52
+ this.cachedJs = readFileSync(join(EDGE_DIR, 'client', 'dashboard.js'), 'utf-8');
53
+ }
54
+ if (this.cachedTransmitClient === null) {
55
+ this.cachedTransmitClient = this.loadTransmitClient();
56
+ }
57
+ const config = this.app.config.get('server_stats');
58
+ const toolbarConfig = config?.devToolbar || {};
59
+ const dashPath = this.getDashboardPath();
60
+ return ctx.view.render('ss::dashboard', {
61
+ css: this.cachedCss,
62
+ js: this.cachedJs,
63
+ transmitClient: this.cachedTransmitClient,
64
+ dashboardPath: dashPath,
65
+ showTracing: !!toolbarConfig.tracing,
66
+ customPanes: toolbarConfig.panes || [],
67
+ dashConfig: {
68
+ basePath: dashPath,
69
+ tracing: !!toolbarConfig.tracing,
70
+ panes: (toolbarConfig.panes || []).map((p) => ({
71
+ id: p.id,
72
+ label: p.label,
73
+ })),
74
+ },
75
+ });
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Overview
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * GET {dashboardPath}/api/overview — Overview metrics cards.
82
+ */
83
+ async overview({ request, response }) {
84
+ const db = this.dashboardStore.getDb();
85
+ if (!db)
86
+ return response.json(emptyOverview());
87
+ try {
88
+ const range = request.qs().range || '1h';
89
+ // Use getOverviewMetrics for accurate stats (computed from raw requests)
90
+ const overview = await this.dashboardStore.getOverviewMetrics(range);
91
+ if (!overview)
92
+ return response.json(emptyOverview());
93
+ // Add sparklines from the pre-aggregated metrics table
94
+ const cutoff = minutesAgo(60);
95
+ const metrics = await db('server_stats_metrics')
96
+ .where('created_at', '>=', cutoff)
97
+ .orderBy('bucket', 'asc');
98
+ const sparklineData = metrics.slice(-15);
99
+ return response.json({
100
+ ...overview,
101
+ sparklines: {
102
+ avgResponseTime: sparklineData.map((m) => m.avg_duration),
103
+ p95ResponseTime: sparklineData.map((m) => m.p95_duration),
104
+ requestsPerMinute: sparklineData.map((m) => m.request_count),
105
+ errorRate: sparklineData.map((m) => m.request_count > 0 ? round((m.error_count / m.request_count) * 100) : 0),
106
+ },
107
+ });
108
+ }
109
+ catch {
110
+ return response.json(emptyOverview());
111
+ }
112
+ }
113
+ /**
114
+ * GET {dashboardPath}/api/overview/chart — Chart data with time range.
115
+ */
116
+ async overviewChart({ request, response }) {
117
+ const db = this.dashboardStore.getDb();
118
+ if (!db)
119
+ return response.json({ buckets: [] });
120
+ const range = request.qs().range || '1h';
121
+ const { cutoff } = parseRange(range);
122
+ try {
123
+ const buckets = await db('server_stats_metrics')
124
+ .where('bucket', '>=', cutoff)
125
+ .orderBy('bucket', 'asc');
126
+ return response.json({
127
+ range,
128
+ buckets: buckets.map((b) => ({
129
+ bucket: b.bucket,
130
+ requestCount: b.request_count,
131
+ avgDuration: b.avg_duration,
132
+ p95Duration: b.p95_duration,
133
+ errorCount: b.error_count,
134
+ queryCount: b.query_count,
135
+ })),
136
+ });
137
+ }
138
+ catch {
139
+ return response.json({ buckets: [] });
140
+ }
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Requests
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * GET {dashboardPath}/api/requests — Paginated request history.
147
+ */
148
+ async requests({ request, response }) {
149
+ const db = this.dashboardStore.getDb();
150
+ if (!db)
151
+ return response.json({ data: [], total: 0 });
152
+ const qs = request.qs();
153
+ const page = Math.max(1, Number(qs.page) || 1);
154
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
155
+ try {
156
+ let query = db('server_stats_requests');
157
+ if (qs.method)
158
+ query = query.where('method', qs.method.toUpperCase());
159
+ if (qs.status)
160
+ query = query.where('status_code', Number(qs.status));
161
+ if (qs.url)
162
+ query = query.where('url', 'like', `%${qs.url}%`);
163
+ const countResult = await query.clone().count('* as total').first();
164
+ const total = countResult?.total ?? 0;
165
+ const data = await query
166
+ .orderBy('created_at', 'desc')
167
+ .limit(perPage)
168
+ .offset((page - 1) * perPage);
169
+ return response.json({
170
+ data: data.map(formatRequest),
171
+ total,
172
+ page,
173
+ perPage,
174
+ });
175
+ }
176
+ catch {
177
+ return response.json({ data: [], total: 0 });
178
+ }
179
+ }
180
+ /**
181
+ * GET {dashboardPath}/api/requests/:id — Single request with trace.
182
+ */
183
+ async requestDetail({ params, response }) {
184
+ const db = this.dashboardStore.getDb();
185
+ if (!db)
186
+ return response.notFound({ error: 'Not found' });
187
+ try {
188
+ const id = Number(params.id);
189
+ const req = await db('server_stats_requests').where('id', id).first();
190
+ if (!req)
191
+ return response.notFound({ error: 'Request not found' });
192
+ const [queries, trace] = await Promise.all([
193
+ db('server_stats_queries').where('request_id', id).orderBy('created_at', 'asc'),
194
+ db('server_stats_traces').where('request_id', id).first(),
195
+ ]);
196
+ return response.json({
197
+ ...formatRequest(req),
198
+ queries: queries.map(formatQuery),
199
+ trace: trace ? formatTrace(trace) : null,
200
+ });
201
+ }
202
+ catch {
203
+ return response.notFound({ error: 'Not found' });
204
+ }
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Queries
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * GET {dashboardPath}/api/queries — Paginated query history.
211
+ */
212
+ async queries({ request, response }) {
213
+ const db = this.dashboardStore.getDb();
214
+ if (!db)
215
+ return response.json({ data: [], total: 0 });
216
+ const qs = request.qs();
217
+ const page = Math.max(1, Number(qs.page) || 1);
218
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
219
+ try {
220
+ let query = db('server_stats_queries');
221
+ if (qs.duration_min)
222
+ query = query.where('duration', '>=', Number(qs.duration_min));
223
+ if (qs.model)
224
+ query = query.where('model', qs.model);
225
+ if (qs.method)
226
+ query = query.where('method', qs.method);
227
+ if (qs.connection)
228
+ query = query.where('connection', qs.connection);
229
+ const countResult = await query.clone().count('* as total').first();
230
+ const total = countResult?.total ?? 0;
231
+ const data = await query
232
+ .orderBy('created_at', 'desc')
233
+ .limit(perPage)
234
+ .offset((page - 1) * perPage);
235
+ return response.json({
236
+ data: data.map(formatQuery),
237
+ total,
238
+ page,
239
+ perPage,
240
+ });
241
+ }
242
+ catch {
243
+ return response.json({ data: [], total: 0 });
244
+ }
245
+ }
246
+ /**
247
+ * GET {dashboardPath}/api/queries/grouped — Grouped by normalized SQL.
248
+ */
249
+ async queriesGrouped({ request, response }) {
250
+ const db = this.dashboardStore.getDb();
251
+ if (!db)
252
+ return response.json({ groups: [] });
253
+ const qs = request.qs();
254
+ const limit = clamp(Number(qs.limit) || 50, 1, 200);
255
+ const sort = qs.sort || 'total_duration';
256
+ try {
257
+ const validSorts = {
258
+ count: 'count',
259
+ avg_duration: 'avg_duration',
260
+ total_duration: 'total_duration',
261
+ };
262
+ const orderCol = validSorts[sort] || 'total_duration';
263
+ const groups = await db('server_stats_queries')
264
+ .select('sql_normalized')
265
+ .select(db.raw('COUNT(*) as count'))
266
+ .select(db.raw('AVG(duration) as avg_duration'))
267
+ .select(db.raw('MIN(duration) as min_duration'))
268
+ .select(db.raw('MAX(duration) as max_duration'))
269
+ .select(db.raw('SUM(duration) as total_duration'))
270
+ .groupBy('sql_normalized')
271
+ .orderBy(orderCol, 'desc')
272
+ .limit(limit);
273
+ // Calculate total time for percentage
274
+ const totalTime = groups.reduce((sum, g) => sum + (g.total_duration || 0), 0);
275
+ return response.json({
276
+ groups: groups.map((g) => ({
277
+ sqlNormalized: g.sql_normalized,
278
+ count: g.count,
279
+ avgDuration: round(g.avg_duration),
280
+ minDuration: round(g.min_duration),
281
+ maxDuration: round(g.max_duration),
282
+ totalDuration: round(g.total_duration),
283
+ percentOfTotal: totalTime > 0 ? round((g.total_duration / totalTime) * 100) : 0,
284
+ })),
285
+ });
286
+ }
287
+ catch {
288
+ return response.json({ groups: [] });
289
+ }
290
+ }
291
+ /**
292
+ * GET {dashboardPath}/api/queries/:id/explain — Run EXPLAIN on a query.
293
+ */
294
+ async queryExplain({ params, response }) {
295
+ const db = this.dashboardStore.getDb();
296
+ if (!db)
297
+ return response.notFound({ error: 'Not found' });
298
+ try {
299
+ const id = Number(params.id);
300
+ const query = await db('server_stats_queries').where('id', id).first();
301
+ if (!query)
302
+ return response.notFound({ error: 'Query not found' });
303
+ // Only allow EXPLAIN on SELECT queries
304
+ const sqlTrimmed = query.sql_text.trim().toUpperCase();
305
+ if (!sqlTrimmed.startsWith('SELECT')) {
306
+ return response.badRequest({ error: 'EXPLAIN is only supported for SELECT queries' });
307
+ }
308
+ // Run EXPLAIN on the app's default database connection (not the stats connection)
309
+ let appDb;
310
+ try {
311
+ const lucid = await this.app.container.make('lucid.db');
312
+ appDb = lucid.connection().getWriteClient();
313
+ }
314
+ catch {
315
+ return response.serviceUnavailable({ error: 'App database connection not available' });
316
+ }
317
+ // Parse stored bindings and pass them to the EXPLAIN query
318
+ let bindings = [];
319
+ if (query.bindings) {
320
+ try {
321
+ bindings = JSON.parse(query.bindings);
322
+ }
323
+ catch {
324
+ // If bindings can't be parsed, run without them
325
+ }
326
+ }
327
+ const explainResult = await appDb.raw(`EXPLAIN (FORMAT JSON) ${query.sql_text}`, bindings);
328
+ // PostgreSQL with Knex: result is { rows: [...] }
329
+ // Each row has a "QUERY PLAN" key containing the JSON plan
330
+ let plan = [];
331
+ const rawRows = explainResult?.rows ?? (Array.isArray(explainResult) ? explainResult : []);
332
+ if (rawRows.length > 0 && rawRows[0]['QUERY PLAN']) {
333
+ // EXPLAIN (FORMAT JSON) returns a single row with a JSON array
334
+ plan = rawRows[0]['QUERY PLAN'];
335
+ }
336
+ else {
337
+ plan = rawRows;
338
+ }
339
+ return response.json({
340
+ queryId: id,
341
+ sql: query.sql_text,
342
+ plan,
343
+ });
344
+ }
345
+ catch (error) {
346
+ return response.internalServerError({
347
+ error: 'EXPLAIN failed',
348
+ message: error?.message ?? 'Unknown error',
349
+ });
350
+ }
351
+ }
352
+ // ---------------------------------------------------------------------------
353
+ // Events
354
+ // ---------------------------------------------------------------------------
355
+ /**
356
+ * GET {dashboardPath}/api/events — Paginated event history.
357
+ */
358
+ async events({ request, response }) {
359
+ const db = this.dashboardStore.getDb();
360
+ if (!db)
361
+ return response.json({ data: [], total: 0 });
362
+ const qs = request.qs();
363
+ const page = Math.max(1, Number(qs.page) || 1);
364
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
365
+ try {
366
+ let query = db('server_stats_events');
367
+ if (qs.event_name)
368
+ query = query.where('event_name', qs.event_name);
369
+ const countResult = await query.clone().count('* as total').first();
370
+ const total = countResult?.total ?? 0;
371
+ const data = await query
372
+ .orderBy('created_at', 'desc')
373
+ .limit(perPage)
374
+ .offset((page - 1) * perPage);
375
+ return response.json({
376
+ data: data.map((e) => ({
377
+ id: e.id,
378
+ requestId: e.request_id,
379
+ eventName: e.event_name,
380
+ data: safeParseJson(e.data),
381
+ createdAt: e.created_at,
382
+ })),
383
+ total,
384
+ page,
385
+ perPage,
386
+ });
387
+ }
388
+ catch {
389
+ return response.json({ data: [], total: 0 });
390
+ }
391
+ }
392
+ // ---------------------------------------------------------------------------
393
+ // Routes
394
+ // ---------------------------------------------------------------------------
395
+ /**
396
+ * GET {dashboardPath}/api/routes — Route table (delegates to DebugStore).
397
+ */
398
+ async routes({ response }) {
399
+ const routes = this.debugStore.routes.getRoutes();
400
+ return response.json({
401
+ routes,
402
+ total: this.debugStore.routes.getRouteCount(),
403
+ });
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Logs
407
+ // ---------------------------------------------------------------------------
408
+ /**
409
+ * GET {dashboardPath}/api/logs — Paginated logs with structured search.
410
+ */
411
+ async logs({ request, response }) {
412
+ const db = this.dashboardStore.getDb();
413
+ if (!db)
414
+ return response.json({ data: [], total: 0 });
415
+ const qs = request.qs();
416
+ const page = Math.max(1, Number(qs.page) || 1);
417
+ const perPage = clamp(Number(qs.perPage) || 50, 1, 200);
418
+ try {
419
+ let query = db('server_stats_logs');
420
+ if (qs.level)
421
+ query = query.where('level', qs.level);
422
+ if (qs.message)
423
+ query = query.where('message', 'like', `%${qs.message}%`);
424
+ if (qs.request_id)
425
+ query = query.where('request_id', qs.request_id);
426
+ // Structured JSON field search: ?field=userId&operator=equals&value=5
427
+ if (qs.field && qs.value !== undefined) {
428
+ const operator = qs.operator || 'equals';
429
+ const jsonPath = `$.${qs.field}`;
430
+ if (operator === 'equals') {
431
+ query = query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, qs.value]);
432
+ }
433
+ else if (operator === 'contains') {
434
+ query = query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${qs.value}%`]);
435
+ }
436
+ else if (operator === 'starts_with') {
437
+ query = query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${qs.value}%`]);
438
+ }
439
+ }
440
+ const countResult = await query.clone().count('* as total').first();
441
+ const total = countResult?.total ?? 0;
442
+ const data = await query
443
+ .orderBy('created_at', 'desc')
444
+ .limit(perPage)
445
+ .offset((page - 1) * perPage);
446
+ return response.json({
447
+ data: data.map((l) => ({
448
+ id: l.id,
449
+ level: l.level,
450
+ message: l.message,
451
+ requestId: l.request_id,
452
+ data: safeParseJson(l.data),
453
+ createdAt: l.created_at,
454
+ })),
455
+ total,
456
+ page,
457
+ perPage,
458
+ });
459
+ }
460
+ catch {
461
+ return response.json({ data: [], total: 0 });
462
+ }
463
+ }
464
+ // ---------------------------------------------------------------------------
465
+ // Emails
466
+ // ---------------------------------------------------------------------------
467
+ /**
468
+ * GET {dashboardPath}/api/emails — Paginated email history.
469
+ */
470
+ async emails({ request, response }) {
471
+ const db = this.dashboardStore.getDb();
472
+ if (!db)
473
+ return response.json({ data: [], total: 0 });
474
+ const qs = request.qs();
475
+ const page = Math.max(1, Number(qs.page) || 1);
476
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
477
+ try {
478
+ let query = db('server_stats_emails');
479
+ if (qs.from)
480
+ query = query.where('from_addr', 'like', `%${qs.from}%`);
481
+ if (qs.to)
482
+ query = query.where('to_addr', 'like', `%${qs.to}%`);
483
+ if (qs.subject)
484
+ query = query.where('subject', 'like', `%${qs.subject}%`);
485
+ if (qs.status)
486
+ query = query.where('status', qs.status);
487
+ if (qs.mailer)
488
+ query = query.where('mailer', qs.mailer);
489
+ const countResult = await query.clone().count('* as total').first();
490
+ const total = countResult?.total ?? 0;
491
+ // Strip html/text from list for performance
492
+ const data = await query
493
+ .select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at')
494
+ .orderBy('created_at', 'desc')
495
+ .limit(perPage)
496
+ .offset((page - 1) * perPage);
497
+ return response.json({
498
+ data: data.map(formatEmail),
499
+ total,
500
+ page,
501
+ perPage,
502
+ });
503
+ }
504
+ catch {
505
+ return response.json({ data: [], total: 0 });
506
+ }
507
+ }
508
+ /**
509
+ * GET {dashboardPath}/api/emails/:id/preview — Email HTML preview.
510
+ */
511
+ async emailPreview({ params, response }) {
512
+ const db = this.dashboardStore.getDb();
513
+ if (!db)
514
+ return response.notFound({ error: 'Not found' });
515
+ try {
516
+ const id = Number(params.id);
517
+ const email = await db('server_stats_emails')
518
+ .where('id', id)
519
+ .select('html', 'text_body')
520
+ .first();
521
+ if (!email)
522
+ return response.notFound({ error: 'Email not found' });
523
+ const html = email.html || email.text_body || '<p>No content</p>';
524
+ return response.header('Content-Type', 'text/html; charset=utf-8').send(html);
525
+ }
526
+ catch {
527
+ return response.notFound({ error: 'Not found' });
528
+ }
529
+ }
530
+ // ---------------------------------------------------------------------------
531
+ // Traces
532
+ // ---------------------------------------------------------------------------
533
+ /**
534
+ * GET {dashboardPath}/api/traces — Paginated trace list (lightweight).
535
+ */
536
+ async traces({ request, response }) {
537
+ const db = this.dashboardStore.getDb();
538
+ if (!db)
539
+ return response.json({ data: [], total: 0 });
540
+ const qs = request.qs();
541
+ const page = Math.max(1, Number(qs.page) || 1);
542
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
543
+ try {
544
+ const countResult = await db('server_stats_traces').count('* as total').first();
545
+ const total = countResult?.total ?? 0;
546
+ // Strip spans and warnings from list for performance
547
+ const data = await db('server_stats_traces')
548
+ .select('id', 'request_id', 'method', 'url', 'status_code', 'total_duration', 'span_count', 'warnings', 'created_at')
549
+ .orderBy('created_at', 'desc')
550
+ .limit(perPage)
551
+ .offset((page - 1) * perPage);
552
+ return response.json({
553
+ data: data.map((t) => ({
554
+ id: t.id,
555
+ requestId: t.request_id,
556
+ method: t.method,
557
+ url: t.url,
558
+ statusCode: t.status_code,
559
+ totalDuration: t.total_duration,
560
+ spanCount: t.span_count,
561
+ warningCount: safeParseJsonArray(t.warnings).length,
562
+ createdAt: t.created_at,
563
+ })),
564
+ total,
565
+ page,
566
+ perPage,
567
+ });
568
+ }
569
+ catch {
570
+ return response.json({ data: [], total: 0 });
571
+ }
572
+ }
573
+ /**
574
+ * GET {dashboardPath}/api/traces/:id — Single trace with full spans.
575
+ */
576
+ async traceDetail({ params, response }) {
577
+ const db = this.dashboardStore.getDb();
578
+ if (!db)
579
+ return response.notFound({ error: 'Not found' });
580
+ try {
581
+ const id = Number(params.id);
582
+ const trace = await db('server_stats_traces').where('id', id).first();
583
+ if (!trace)
584
+ return response.notFound({ error: 'Trace not found' });
585
+ return response.json(formatTrace(trace));
586
+ }
587
+ catch {
588
+ return response.notFound({ error: 'Not found' });
589
+ }
590
+ }
591
+ // ---------------------------------------------------------------------------
592
+ // Cache
593
+ // ---------------------------------------------------------------------------
594
+ /**
595
+ * GET {dashboardPath}/api/cache — Cache stats and key list.
596
+ */
597
+ async cacheStats({ request, response }) {
598
+ const inspector = await this.getCacheInspector();
599
+ if (!inspector) {
600
+ return response.json({ available: false, stats: null, keys: [] });
601
+ }
602
+ const qs = request.qs();
603
+ const pattern = qs.pattern || '*';
604
+ const cursor = qs.cursor || '0';
605
+ const count = clamp(Number(qs.count) || 100, 1, 500);
606
+ try {
607
+ const [stats, keyList] = await Promise.all([
608
+ inspector.getStats(),
609
+ inspector.listKeys(pattern, cursor, count),
610
+ ]);
611
+ return response.json({
612
+ available: true,
613
+ stats,
614
+ keys: keyList.keys,
615
+ cursor: keyList.cursor,
616
+ });
617
+ }
618
+ catch {
619
+ return response.json({ available: false, stats: null, keys: [] });
620
+ }
621
+ }
622
+ /**
623
+ * GET {dashboardPath}/api/cache/:key — Single cache key detail.
624
+ */
625
+ async cacheKey({ params, response }) {
626
+ const inspector = await this.getCacheInspector();
627
+ if (!inspector) {
628
+ return response.notFound({ error: 'Cache not available' });
629
+ }
630
+ try {
631
+ const key = decodeURIComponent(params.key);
632
+ const detail = await inspector.getKey(key);
633
+ if (!detail)
634
+ return response.notFound({ error: 'Key not found' });
635
+ return response.json(detail);
636
+ }
637
+ catch {
638
+ return response.notFound({ error: 'Key not found' });
639
+ }
640
+ }
641
+ // ---------------------------------------------------------------------------
642
+ // Jobs / Queue
643
+ // ---------------------------------------------------------------------------
644
+ /**
645
+ * GET {dashboardPath}/api/jobs — Job list with status filter.
646
+ */
647
+ async jobs({ request, response }) {
648
+ const inspector = await this.getQueueInspector();
649
+ if (!inspector) {
650
+ return response.json({ available: false, overview: null, jobs: [], total: 0 });
651
+ }
652
+ const qs = request.qs();
653
+ const status = qs.status || 'active';
654
+ const page = Math.max(1, Number(qs.page) || 1);
655
+ const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
656
+ try {
657
+ const [overview, jobList] = await Promise.all([
658
+ inspector.getOverview(),
659
+ inspector.listJobs(status, page, perPage),
660
+ ]);
661
+ return response.json({
662
+ available: true,
663
+ overview,
664
+ jobs: jobList.jobs,
665
+ total: jobList.total,
666
+ page,
667
+ perPage,
668
+ });
669
+ }
670
+ catch {
671
+ return response.json({ available: false, overview: null, jobs: [], total: 0 });
672
+ }
673
+ }
674
+ /**
675
+ * GET {dashboardPath}/api/jobs/:id — Single job detail.
676
+ */
677
+ async jobDetail({ params, response }) {
678
+ const inspector = await this.getQueueInspector();
679
+ if (!inspector) {
680
+ return response.notFound({ error: 'Queue not available' });
681
+ }
682
+ try {
683
+ const detail = await inspector.getJob(String(params.id));
684
+ if (!detail)
685
+ return response.notFound({ error: 'Job not found' });
686
+ return response.json(detail);
687
+ }
688
+ catch {
689
+ return response.notFound({ error: 'Job not found' });
690
+ }
691
+ }
692
+ /**
693
+ * POST {dashboardPath}/api/jobs/:id/retry — Retry a failed job.
694
+ */
695
+ async jobRetry({ params, response }) {
696
+ const inspector = await this.getQueueInspector();
697
+ if (!inspector) {
698
+ return response.notFound({ error: 'Queue not available' });
699
+ }
700
+ try {
701
+ const success = await inspector.retryJob(String(params.id));
702
+ if (!success) {
703
+ return response.badRequest({ error: 'Job could not be retried (not in failed state)' });
704
+ }
705
+ return response.json({ success: true });
706
+ }
707
+ catch {
708
+ return response.internalServerError({ error: 'Retry failed' });
709
+ }
710
+ }
711
+ // ---------------------------------------------------------------------------
712
+ // Config
713
+ // ---------------------------------------------------------------------------
714
+ /**
715
+ * GET {dashboardPath}/api/config — Sanitized app config and env vars.
716
+ */
717
+ async config({ response }) {
718
+ const configData = this.configInspector.getConfig();
719
+ const envData = this.configInspector.getEnvVars();
720
+ return response.json({
721
+ config: configData.config,
722
+ env: envData.env,
723
+ });
724
+ }
725
+ // ---------------------------------------------------------------------------
726
+ // Saved Filters
727
+ // ---------------------------------------------------------------------------
728
+ /**
729
+ * GET {dashboardPath}/api/filters — List saved filter presets.
730
+ */
731
+ async savedFilters({ response }) {
732
+ const db = this.dashboardStore.getDb();
733
+ if (!db)
734
+ return response.json({ filters: [] });
735
+ try {
736
+ const filters = await db('server_stats_saved_filters')
737
+ .orderBy('created_at', 'desc');
738
+ return response.json({
739
+ filters: filters.map((f) => ({
740
+ id: f.id,
741
+ name: f.name,
742
+ section: f.section,
743
+ filterConfig: safeParseJson(f.filter_config),
744
+ createdAt: f.created_at,
745
+ })),
746
+ });
747
+ }
748
+ catch {
749
+ return response.json({ filters: [] });
750
+ }
751
+ }
752
+ /**
753
+ * POST {dashboardPath}/api/filters — Create a saved filter preset.
754
+ */
755
+ async createSavedFilter({ request, response }) {
756
+ const db = this.dashboardStore.getDb();
757
+ if (!db)
758
+ return response.serviceUnavailable({ error: 'Database not available' });
759
+ try {
760
+ const body = request.body();
761
+ const name = body.name;
762
+ const section = body.section;
763
+ const filterConfig = body.filterConfig;
764
+ if (!name || !section || !filterConfig) {
765
+ return response.badRequest({ error: 'Missing required fields: name, section, filterConfig' });
766
+ }
767
+ const [id] = await db('server_stats_saved_filters').insert({
768
+ name,
769
+ section,
770
+ filter_config: typeof filterConfig === 'string' ? filterConfig : JSON.stringify(filterConfig),
771
+ });
772
+ return response.json({
773
+ id,
774
+ name,
775
+ section,
776
+ filterConfig: typeof filterConfig === 'string' ? safeParseJson(filterConfig) : filterConfig,
777
+ });
778
+ }
779
+ catch {
780
+ return response.internalServerError({ error: 'Failed to create filter' });
781
+ }
782
+ }
783
+ /**
784
+ * DELETE {dashboardPath}/api/filters/:id — Delete a saved filter preset.
785
+ */
786
+ async deleteSavedFilter({ params, response }) {
787
+ const db = this.dashboardStore.getDb();
788
+ if (!db)
789
+ return response.serviceUnavailable({ error: 'Database not available' });
790
+ try {
791
+ const id = Number(params.id);
792
+ const deleted = await db('server_stats_saved_filters').where('id', id).del();
793
+ if (!deleted)
794
+ return response.notFound({ error: 'Filter not found' });
795
+ return response.json({ success: true });
796
+ }
797
+ catch {
798
+ return response.internalServerError({ error: 'Failed to delete filter' });
799
+ }
800
+ }
801
+ // ---------------------------------------------------------------------------
802
+ // Private helpers
803
+ // ---------------------------------------------------------------------------
804
+ /**
805
+ * Check if the current request is authorized via shouldShow.
806
+ */
807
+ checkAccess(ctx) {
808
+ const config = this.app.config.get('server_stats');
809
+ if (!config?.shouldShow)
810
+ return true;
811
+ try {
812
+ return config.shouldShow(ctx);
813
+ }
814
+ catch {
815
+ return false;
816
+ }
817
+ }
818
+ /**
819
+ * Get the configured dashboard path.
820
+ */
821
+ getDashboardPath() {
822
+ const config = this.app.config.get('server_stats');
823
+ return config?.devToolbar?.dashboardPath ?? '/__stats';
824
+ }
825
+ /**
826
+ * Try to locate and read the @adonisjs/transmit-client build file.
827
+ * Returns the file contents wrapped to expose `window.Transmit`, or
828
+ * an empty string if the package is not installed.
829
+ */
830
+ loadTransmitClient() {
831
+ try {
832
+ const req = createRequire(this.app.makePath('package.json'));
833
+ const clientPath = req.resolve('@adonisjs/transmit-client/build/index.js');
834
+ const src = readFileSync(clientPath, 'utf-8');
835
+ console.log('[server-stats] Transmit client loaded from:', clientPath, `(${src.length} bytes)`);
836
+ // The file is ESM with `export { Transmit }`. Wrap it in an IIFE
837
+ // that captures the export and assigns it to window.Transmit.
838
+ return `(function(){var __exports={};(function(){${src.replace(/^export\s*\{[^}]*\}\s*;?\s*$/m, '')}\n__exports.Transmit=Transmit;})();window.Transmit=__exports.Transmit;})()`;
839
+ }
840
+ catch (err) {
841
+ console.log('[server-stats] Transmit client not available:', err?.message || 'unknown error');
842
+ console.log('[server-stats] Dashboard will use polling. Install @adonisjs/transmit-client for real-time updates.');
843
+ return '';
844
+ }
845
+ }
846
+ /**
847
+ * Lazily initialize and return the CacheInspector (if Redis is available).
848
+ */
849
+ async getCacheInspector() {
850
+ if (this.cacheAvailable === false)
851
+ return null;
852
+ if (this.cacheInspector)
853
+ return this.cacheInspector;
854
+ try {
855
+ const available = await CacheInspector.isAvailable(this.app);
856
+ this.cacheAvailable = available;
857
+ if (!available)
858
+ return null;
859
+ const redis = await this.app.container.make('redis');
860
+ this.cacheInspector = new CacheInspector(redis);
861
+ return this.cacheInspector;
862
+ }
863
+ catch {
864
+ this.cacheAvailable = false;
865
+ return null;
866
+ }
867
+ }
868
+ /**
869
+ * Lazily initialize and return the QueueInspector (if Bull Queue is available).
870
+ */
871
+ async getQueueInspector() {
872
+ if (this.queueAvailable === false)
873
+ return null;
874
+ if (this.queueInspector)
875
+ return this.queueInspector;
876
+ try {
877
+ const available = await QueueInspector.isAvailable(this.app);
878
+ this.queueAvailable = available;
879
+ if (!available)
880
+ return null;
881
+ const queue = await this.app.container.make('queue');
882
+ this.queueInspector = new QueueInspector(queue);
883
+ return this.queueInspector;
884
+ }
885
+ catch {
886
+ this.queueAvailable = false;
887
+ return null;
888
+ }
889
+ }
890
+ }
891
+ // ---------------------------------------------------------------------------
892
+ // Formatting helpers
893
+ // ---------------------------------------------------------------------------
894
+ function formatRequest(r) {
895
+ return {
896
+ id: r.id,
897
+ method: r.method,
898
+ url: r.url,
899
+ statusCode: r.status_code,
900
+ duration: r.duration,
901
+ spanCount: r.span_count,
902
+ warningCount: r.warning_count,
903
+ createdAt: r.created_at,
904
+ };
905
+ }
906
+ function formatQuery(q) {
907
+ return {
908
+ id: q.id,
909
+ requestId: q.request_id,
910
+ sql: q.sql_text,
911
+ sqlNormalized: q.sql_normalized,
912
+ bindings: safeParseJson(q.bindings),
913
+ duration: q.duration,
914
+ method: q.method,
915
+ model: q.model,
916
+ connection: q.connection,
917
+ inTransaction: !!q.in_transaction,
918
+ createdAt: q.created_at,
919
+ };
920
+ }
921
+ function formatTrace(t) {
922
+ return {
923
+ id: t.id,
924
+ requestId: t.request_id,
925
+ method: t.method,
926
+ url: t.url,
927
+ statusCode: t.status_code,
928
+ totalDuration: t.total_duration,
929
+ spanCount: t.span_count,
930
+ spans: safeParseJson(t.spans) ?? [],
931
+ warnings: safeParseJsonArray(t.warnings),
932
+ createdAt: t.created_at,
933
+ };
934
+ }
935
+ function formatEmail(e) {
936
+ return {
937
+ id: e.id,
938
+ from: e.from_addr,
939
+ to: e.to_addr,
940
+ cc: e.cc,
941
+ bcc: e.bcc,
942
+ subject: e.subject,
943
+ mailer: e.mailer,
944
+ status: e.status,
945
+ messageId: e.message_id,
946
+ attachmentCount: e.attachment_count,
947
+ createdAt: e.created_at,
948
+ };
949
+ }
950
+ function emptyOverview() {
951
+ return {
952
+ avgResponseTime: 0,
953
+ p95ResponseTime: 0,
954
+ requestsPerMinute: 0,
955
+ errorRate: 0,
956
+ sparklines: {
957
+ avgResponseTime: [],
958
+ p95ResponseTime: [],
959
+ requestsPerMinute: [],
960
+ errorRate: [],
961
+ },
962
+ slowestEndpoints: [],
963
+ queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
964
+ recentErrors: [],
965
+ };
966
+ }
967
+ // ---------------------------------------------------------------------------
968
+ // Utility functions
969
+ // ---------------------------------------------------------------------------
970
+ function round(n) {
971
+ return Math.round(n * 100) / 100;
972
+ }
973
+ function clamp(value, min, max) {
974
+ return Math.max(min, Math.min(max, value));
975
+ }
976
+ function minutesAgo(minutes) {
977
+ const d = new Date(Date.now() - minutes * 60_000);
978
+ return d.toISOString().replace('T', ' ').slice(0, 19);
979
+ }
980
+ function parseRange(range) {
981
+ const rangeMap = {
982
+ '5m': 5,
983
+ '15m': 15,
984
+ '30m': 30,
985
+ '1h': 60,
986
+ '6h': 360,
987
+ '24h': 1440,
988
+ '7d': 10080,
989
+ };
990
+ const minutes = rangeMap[range] ?? 60;
991
+ return { cutoff: minutesAgo(minutes) };
992
+ }
993
+ function safeParseJson(value) {
994
+ if (value === null || value === undefined)
995
+ return null;
996
+ if (typeof value !== 'string')
997
+ return value;
998
+ try {
999
+ return JSON.parse(value);
1000
+ }
1001
+ catch {
1002
+ return value;
1003
+ }
1004
+ }
1005
+ function safeParseJsonArray(value) {
1006
+ const parsed = safeParseJson(value);
1007
+ return Array.isArray(parsed) ? parsed : [];
1008
+ }