adonisjs-server-stats 1.4.0 → 1.5.1

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