adonisjs-server-stats 1.3.2 → 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 +258 -489
  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 +39 -3
  15. package/dist/src/dashboard/dashboard_store.d.ts.map +1 -1
  16. package/dist/src/dashboard/dashboard_store.js +145 -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 +555 -165
  49. package/dist/src/edge/client/dashboard.js +2797 -1556
  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,26 +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 = await this.dashboardStore.getOverviewMetrics(range);
86
+ const [overview, widgets, sparklineData] = await Promise.all([
87
+ this.dashboardStore.getOverviewMetrics(range),
88
+ this.dashboardStore.getOverviewWidgets(range),
89
+ this.dashboardStore.getSparklineData(range),
90
+ ]);
91
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({
92
+ return emptyOverview();
93
+ const [cacheStats, jobQueueStatus] = await Promise.all([
94
+ this.fetchCacheOverview(),
95
+ this.fetchQueueOverview(),
96
+ ]);
97
+ return {
100
98
  ...overview,
101
99
  sparklines: {
102
100
  avgResponseTime: sparklineData.map((m) => m.avg_duration),
@@ -104,26 +102,17 @@ export default class DashboardController {
104
102
  requestsPerMinute: sparklineData.map((m) => m.request_count),
105
103
  errorRate: sparklineData.map((m) => m.request_count > 0 ? round((m.error_count / m.request_count) * 100) : 0),
106
104
  },
107
- });
108
- }
109
- catch {
110
- return response.json(emptyOverview());
111
- }
105
+ ...widgets,
106
+ cacheStats,
107
+ jobQueueStatus,
108
+ };
109
+ });
112
110
  }
113
- /**
114
- * GET {dashboardPath}/api/overview/chart — Chart data with time range.
115
- */
116
111
  async overviewChart({ request, response }) {
117
- const db = this.dashboardStore.getDb();
118
- if (!db)
119
- return response.json({ buckets: [] });
120
112
  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({
113
+ return this.withDb(response, { range, buckets: [] }, async () => {
114
+ const buckets = await this.dashboardStore.getChartData(range);
115
+ return {
127
116
  range,
128
117
  buckets: buckets.map((b) => ({
129
118
  bucket: b.bucket,
@@ -133,70 +122,42 @@ export default class DashboardController {
133
122
  errorCount: b.error_count,
134
123
  queryCount: b.query_count,
135
124
  })),
136
- });
137
- }
138
- catch {
139
- return response.json({ buckets: [] });
140
- }
125
+ };
126
+ });
141
127
  }
142
128
  // ---------------------------------------------------------------------------
143
129
  // Requests
144
130
  // ---------------------------------------------------------------------------
145
- /**
146
- * GET {dashboardPath}/api/requests — Paginated request history.
147
- */
148
131
  async requests({ request, response }) {
149
- const db = this.dashboardStore.getDb();
150
- if (!db)
151
- return response.json({ data: [], total: 0 });
152
132
  const qs = request.qs();
153
133
  const page = Math.max(1, Number(qs.page) || 1);
154
134
  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,
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,
174
140
  });
175
- }
176
- catch {
177
- return response.json({ data: [], total: 0 });
178
- }
141
+ return {
142
+ data: result.data.map(formatRequest),
143
+ total: result.total,
144
+ page: result.page,
145
+ perPage: result.perPage,
146
+ };
147
+ });
179
148
  }
180
- /**
181
- * GET {dashboardPath}/api/requests/:id — Single request with trace.
182
- */
183
149
  async requestDetail({ params, response }) {
184
- const db = this.dashboardStore.getDb();
185
- if (!db)
150
+ if (!this.dashboardStore.isReady()) {
186
151
  return response.notFound({ error: 'Not found' });
152
+ }
187
153
  try {
188
- const id = Number(params.id);
189
- const req = await db('server_stats_requests').where('id', id).first();
190
- if (!req)
154
+ const detail = await this.dashboardStore.getRequestDetail(Number(params.id));
155
+ if (!detail)
191
156
  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
157
  return response.json({
197
- ...formatRequest(req),
198
- queries: queries.map(formatQuery),
199
- trace: trace ? formatTrace(trace) : null,
158
+ ...formatRequest(detail),
159
+ queries: (detail.queries || []).map(formatQuery),
160
+ trace: detail.trace ? formatTrace(detail.trace) : null,
200
161
  });
201
162
  }
202
163
  catch {
@@ -206,73 +167,33 @@ export default class DashboardController {
206
167
  // ---------------------------------------------------------------------------
207
168
  // Queries
208
169
  // ---------------------------------------------------------------------------
209
- /**
210
- * GET {dashboardPath}/api/queries — Paginated query history.
211
- */
212
170
  async queries({ request, response }) {
213
- const db = this.dashboardStore.getDb();
214
- if (!db)
215
- return response.json({ data: [], total: 0 });
216
171
  const qs = request.qs();
217
172
  const page = Math.max(1, Number(qs.page) || 1);
218
173
  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,
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,
240
180
  });
241
- }
242
- catch {
243
- return response.json({ data: [], total: 0 });
244
- }
181
+ return {
182
+ data: result.data.map(formatQuery),
183
+ total: result.total,
184
+ page: result.page,
185
+ perPage: result.perPage,
186
+ };
187
+ });
245
188
  }
246
- /**
247
- * GET {dashboardPath}/api/queries/grouped — Grouped by normalized SQL.
248
- */
249
189
  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
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);
274
195
  const totalTime = groups.reduce((sum, g) => sum + (g.total_duration || 0), 0);
275
- return response.json({
196
+ return {
276
197
  groups: groups.map((g) => ({
277
198
  sqlNormalized: g.sql_normalized,
278
199
  count: g.count,
@@ -282,30 +203,23 @@ export default class DashboardController {
282
203
  totalDuration: round(g.total_duration),
283
204
  percentOfTotal: totalTime > 0 ? round((g.total_duration / totalTime) * 100) : 0,
284
205
  })),
285
- });
286
- }
287
- catch {
288
- return response.json({ groups: [] });
289
- }
206
+ };
207
+ });
290
208
  }
291
- /**
292
- * GET {dashboardPath}/api/queries/:id/explain — Run EXPLAIN on a query.
293
- */
294
209
  async queryExplain({ params, response }) {
295
- const db = this.dashboardStore.getDb();
296
- if (!db)
210
+ if (!this.dashboardStore.isReady()) {
297
211
  return response.notFound({ error: 'Not found' });
212
+ }
298
213
  try {
214
+ const db = this.dashboardStore.getDb();
299
215
  const id = Number(params.id);
300
216
  const query = await db('server_stats_queries').where('id', id).first();
301
217
  if (!query)
302
218
  return response.notFound({ error: 'Query not found' });
303
- // Only allow EXPLAIN on SELECT queries
304
219
  const sqlTrimmed = query.sql_text.trim().toUpperCase();
305
220
  if (!sqlTrimmed.startsWith('SELECT')) {
306
221
  return response.badRequest({ error: 'EXPLAIN is only supported for SELECT queries' });
307
222
  }
308
- // Run EXPLAIN on the app's default database connection (not the stats connection)
309
223
  let appDb;
310
224
  try {
311
225
  const lucid = await this.app.container.make('lucid.db');
@@ -314,7 +228,6 @@ export default class DashboardController {
314
228
  catch {
315
229
  return response.serviceUnavailable({ error: 'App database connection not available' });
316
230
  }
317
- // Parse stored bindings and pass them to the EXPLAIN query
318
231
  let bindings = [];
319
232
  if (query.bindings) {
320
233
  try {
@@ -325,22 +238,15 @@ export default class DashboardController {
325
238
  }
326
239
  }
327
240
  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
241
  let plan = [];
331
242
  const rawRows = explainResult?.rows ?? (Array.isArray(explainResult) ? explainResult : []);
332
243
  if (rawRows.length > 0 && rawRows[0]['QUERY PLAN']) {
333
- // EXPLAIN (FORMAT JSON) returns a single row with a JSON array
334
244
  plan = rawRows[0]['QUERY PLAN'];
335
245
  }
336
246
  else {
337
247
  plan = rawRows;
338
248
  }
339
- return response.json({
340
- queryId: id,
341
- sql: query.sql_text,
342
- plan,
343
- });
249
+ return response.json({ queryId: id, sql: query.sql_text, plan });
344
250
  }
345
251
  catch (error) {
346
252
  return response.internalServerError({
@@ -352,49 +258,31 @@ export default class DashboardController {
352
258
  // ---------------------------------------------------------------------------
353
259
  // Events
354
260
  // ---------------------------------------------------------------------------
355
- /**
356
- * GET {dashboardPath}/api/events — Paginated event history.
357
- */
358
261
  async events({ request, response }) {
359
- const db = this.dashboardStore.getDb();
360
- if (!db)
361
- return response.json({ data: [], total: 0 });
362
262
  const qs = request.qs();
363
263
  const page = Math.max(1, Number(qs.page) || 1);
364
264
  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) => ({
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) => ({
377
271
  id: e.id,
378
272
  requestId: e.request_id,
379
273
  eventName: e.event_name,
380
274
  data: safeParseJson(e.data),
381
275
  createdAt: e.created_at,
382
276
  })),
383
- total,
384
- page,
385
- perPage,
386
- });
387
- }
388
- catch {
389
- return response.json({ data: [], total: 0 });
390
- }
277
+ total: result.total,
278
+ page: result.page,
279
+ perPage: result.perPage,
280
+ };
281
+ });
391
282
  }
392
283
  // ---------------------------------------------------------------------------
393
284
  // Routes
394
285
  // ---------------------------------------------------------------------------
395
- /**
396
- * GET {dashboardPath}/api/routes — Route table (delegates to DebugStore).
397
- */
398
286
  async routes({ response }) {
399
287
  const routes = this.debugStore.routes.getRoutes();
400
288
  return response.json({
@@ -405,46 +293,34 @@ export default class DashboardController {
405
293
  // ---------------------------------------------------------------------------
406
294
  // Logs
407
295
  // ---------------------------------------------------------------------------
408
- /**
409
- * GET {dashboardPath}/api/logs — Paginated logs with structured search.
410
- */
411
296
  async logs({ request, response }) {
412
- const db = this.dashboardStore.getDb();
413
- if (!db)
414
- return response.json({ data: [], total: 0 });
415
297
  const qs = request.qs();
416
298
  const page = Math.max(1, Number(qs.page) || 1);
417
299
  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) => ({
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) => ({
448
324
  id: l.id,
449
325
  level: l.level,
450
326
  message: l.message,
@@ -452,75 +328,43 @@ export default class DashboardController {
452
328
  data: safeParseJson(l.data),
453
329
  createdAt: l.created_at,
454
330
  })),
455
- total,
456
- page,
457
- perPage,
458
- });
459
- }
460
- catch {
461
- return response.json({ data: [], total: 0 });
462
- }
331
+ total: result.total,
332
+ page: result.page,
333
+ perPage: result.perPage,
334
+ };
335
+ });
463
336
  }
464
337
  // ---------------------------------------------------------------------------
465
338
  // Emails
466
339
  // ---------------------------------------------------------------------------
467
- /**
468
- * GET {dashboardPath}/api/emails — Paginated email history.
469
- */
470
340
  async emails({ request, response }) {
471
- const db = this.dashboardStore.getDb();
472
- if (!db)
473
- return response.json({ data: [], total: 0 });
474
341
  const qs = request.qs();
475
342
  const page = Math.max(1, Number(qs.page) || 1);
476
343
  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
- }
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
+ });
507
359
  }
508
- /**
509
- * GET {dashboardPath}/api/emails/:id/preview — Email HTML preview.
510
- */
511
360
  async emailPreview({ params, response }) {
512
- const db = this.dashboardStore.getDb();
513
- if (!db)
361
+ if (!this.dashboardStore.isReady()) {
514
362
  return response.notFound({ error: 'Not found' });
363
+ }
515
364
  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)
365
+ const html = await this.dashboardStore.getEmailHtml(Number(params.id));
366
+ if (!html)
522
367
  return response.notFound({ error: 'Email not found' });
523
- const html = email.html || email.text_body || '<p>No content</p>';
524
368
  return response.header('Content-Type', 'text/html; charset=utf-8').send(html);
525
369
  }
526
370
  catch {
@@ -530,27 +374,14 @@ export default class DashboardController {
530
374
  // ---------------------------------------------------------------------------
531
375
  // Traces
532
376
  // ---------------------------------------------------------------------------
533
- /**
534
- * GET {dashboardPath}/api/traces — Paginated trace list (lightweight).
535
- */
536
377
  async traces({ request, response }) {
537
- const db = this.dashboardStore.getDb();
538
- if (!db)
539
- return response.json({ data: [], total: 0 });
540
378
  const qs = request.qs();
541
379
  const page = Math.max(1, Number(qs.page) || 1);
542
380
  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) => ({
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) => ({
554
385
  id: t.id,
555
386
  requestId: t.request_id,
556
387
  method: t.method,
@@ -561,25 +392,18 @@ export default class DashboardController {
561
392
  warningCount: safeParseJsonArray(t.warnings).length,
562
393
  createdAt: t.created_at,
563
394
  })),
564
- total,
565
- page,
566
- perPage,
567
- });
568
- }
569
- catch {
570
- return response.json({ data: [], total: 0 });
571
- }
395
+ total: result.total,
396
+ page: result.page,
397
+ perPage: result.perPage,
398
+ };
399
+ });
572
400
  }
573
- /**
574
- * GET {dashboardPath}/api/traces/:id — Single trace with full spans.
575
- */
576
401
  async traceDetail({ params, response }) {
577
- const db = this.dashboardStore.getDb();
578
- if (!db)
402
+ if (!this.dashboardStore.isReady()) {
579
403
  return response.notFound({ error: 'Not found' });
404
+ }
580
405
  try {
581
- const id = Number(params.id);
582
- const trace = await db('server_stats_traces').where('id', id).first();
406
+ const trace = await this.dashboardStore.getTraceDetail(Number(params.id));
583
407
  if (!trace)
584
408
  return response.notFound({ error: 'Trace not found' });
585
409
  return response.json(formatTrace(trace));
@@ -591,11 +415,8 @@ export default class DashboardController {
591
415
  // ---------------------------------------------------------------------------
592
416
  // Cache
593
417
  // ---------------------------------------------------------------------------
594
- /**
595
- * GET {dashboardPath}/api/cache — Cache stats and key list.
596
- */
597
418
  async cacheStats({ request, response }) {
598
- const inspector = await this.getCacheInspector();
419
+ const inspector = await this.getInspector('cache');
599
420
  if (!inspector) {
600
421
  return response.json({ available: false, stats: null, keys: [] });
601
422
  }
@@ -619,11 +440,8 @@ export default class DashboardController {
619
440
  return response.json({ available: false, stats: null, keys: [] });
620
441
  }
621
442
  }
622
- /**
623
- * GET {dashboardPath}/api/cache/:key — Single cache key detail.
624
- */
625
443
  async cacheKey({ params, response }) {
626
- const inspector = await this.getCacheInspector();
444
+ const inspector = await this.getInspector('cache');
627
445
  if (!inspector) {
628
446
  return response.notFound({ error: 'Cache not available' });
629
447
  }
@@ -641,11 +459,8 @@ export default class DashboardController {
641
459
  // ---------------------------------------------------------------------------
642
460
  // Jobs / Queue
643
461
  // ---------------------------------------------------------------------------
644
- /**
645
- * GET {dashboardPath}/api/jobs — Job list with status filter.
646
- */
647
462
  async jobs({ request, response }) {
648
- const inspector = await this.getQueueInspector();
463
+ const inspector = await this.getInspector('queue');
649
464
  if (!inspector) {
650
465
  return response.json({ available: false, overview: null, jobs: [], total: 0 });
651
466
  }
@@ -671,11 +486,8 @@ export default class DashboardController {
671
486
  return response.json({ available: false, overview: null, jobs: [], total: 0 });
672
487
  }
673
488
  }
674
- /**
675
- * GET {dashboardPath}/api/jobs/:id — Single job detail.
676
- */
677
489
  async jobDetail({ params, response }) {
678
- const inspector = await this.getQueueInspector();
490
+ const inspector = await this.getInspector('queue');
679
491
  if (!inspector) {
680
492
  return response.notFound({ error: 'Queue not available' });
681
493
  }
@@ -689,11 +501,8 @@ export default class DashboardController {
689
501
  return response.notFound({ error: 'Job not found' });
690
502
  }
691
503
  }
692
- /**
693
- * POST {dashboardPath}/api/jobs/:id/retry — Retry a failed job.
694
- */
695
504
  async jobRetry({ params, response }) {
696
- const inspector = await this.getQueueInspector();
505
+ const inspector = await this.getInspector('queue');
697
506
  if (!inspector) {
698
507
  return response.notFound({ error: 'Queue not available' });
699
508
  }
@@ -711,9 +520,6 @@ export default class DashboardController {
711
520
  // ---------------------------------------------------------------------------
712
521
  // Config
713
522
  // ---------------------------------------------------------------------------
714
- /**
715
- * GET {dashboardPath}/api/config — Sanitized app config and env vars.
716
- */
717
523
  async config({ response }) {
718
524
  const configData = this.configInspector.getConfig();
719
525
  const envData = this.configInspector.getEnvVars();
@@ -725,17 +531,10 @@ export default class DashboardController {
725
531
  // ---------------------------------------------------------------------------
726
532
  // Saved Filters
727
533
  // ---------------------------------------------------------------------------
728
- /**
729
- * GET {dashboardPath}/api/filters — List saved filter presets.
730
- */
731
534
  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({
535
+ return this.withDb(response, { filters: [] }, async () => {
536
+ const filters = await this.dashboardStore.getSavedFilters();
537
+ return {
739
538
  filters: filters.map((f) => ({
740
539
  id: f.id,
741
540
  name: f.name,
@@ -743,53 +542,38 @@ export default class DashboardController {
743
542
  filterConfig: safeParseJson(f.filter_config),
744
543
  createdAt: f.created_at,
745
544
  })),
746
- });
747
- }
748
- catch {
749
- return response.json({ filters: [] });
750
- }
545
+ };
546
+ });
751
547
  }
752
- /**
753
- * POST {dashboardPath}/api/filters — Create a saved filter preset.
754
- */
755
548
  async createSavedFilter({ request, response }) {
756
- const db = this.dashboardStore.getDb();
757
- if (!db)
549
+ if (!this.dashboardStore.isReady()) {
758
550
  return response.serviceUnavailable({ error: 'Database not available' });
551
+ }
759
552
  try {
760
553
  const body = request.body();
761
- const name = body.name;
762
- const section = body.section;
763
- const filterConfig = body.filterConfig;
554
+ const { name, section, filterConfig } = body;
764
555
  if (!name || !section || !filterConfig) {
765
- return response.badRequest({ error: 'Missing required fields: name, section, filterConfig' });
556
+ return response.badRequest({
557
+ error: 'Missing required fields: name, section, filterConfig',
558
+ });
766
559
  }
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
- });
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);
778
566
  }
779
567
  catch {
780
568
  return response.internalServerError({ error: 'Failed to create filter' });
781
569
  }
782
570
  }
783
- /**
784
- * DELETE {dashboardPath}/api/filters/:id — Delete a saved filter preset.
785
- */
786
571
  async deleteSavedFilter({ params, response }) {
787
- const db = this.dashboardStore.getDb();
788
- if (!db)
572
+ if (!this.dashboardStore.isReady()) {
789
573
  return response.serviceUnavailable({ error: 'Database not available' });
574
+ }
790
575
  try {
791
- const id = Number(params.id);
792
- const deleted = await db('server_stats_saved_filters').where('id', id).del();
576
+ const deleted = await this.dashboardStore.deleteSavedFilter(Number(params.id));
793
577
  if (!deleted)
794
578
  return response.notFound({ error: 'Filter not found' });
795
579
  return response.json({ success: true });
@@ -802,8 +586,19 @@ export default class DashboardController {
802
586
  // Private helpers
803
587
  // ---------------------------------------------------------------------------
804
588
  /**
805
- * 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.
806
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
+ }
807
602
  checkAccess(ctx) {
808
603
  const config = this.app.config.get('server_stats');
809
604
  if (!config?.shouldShow)
@@ -815,81 +610,90 @@ export default class DashboardController {
815
610
  return false;
816
611
  }
817
612
  }
818
- /**
819
- * Get the configured dashboard path.
820
- */
821
613
  getDashboardPath() {
822
614
  const config = this.app.config.get('server_stats');
823
615
  return config?.devToolbar?.dashboardPath ?? '/__stats';
824
616
  }
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 '';
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
+ }
636
+ }
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
+ }
844
655
  }
845
656
  }
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;
657
+ /** Fetch cache overview stats for the overview page. */
658
+ async fetchCacheOverview() {
854
659
  try {
855
- const available = await CacheInspector.isAvailable(this.app);
856
- this.cacheAvailable = available;
857
- if (!available)
660
+ const inspector = await this.getInspector('cache');
661
+ if (!inspector)
858
662
  return null;
859
- const redis = await this.app.container.make('redis');
860
- this.cacheInspector = new CacheInspector(redis);
861
- 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
+ };
862
670
  }
863
671
  catch {
864
- this.cacheAvailable = false;
865
672
  return null;
866
673
  }
867
674
  }
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;
675
+ /** Fetch queue overview stats for the overview page. */
676
+ async fetchQueueOverview() {
876
677
  try {
877
- const available = await QueueInspector.isAvailable(this.app);
878
- this.queueAvailable = available;
879
- if (!available)
678
+ const inspector = await this.getInspector('queue');
679
+ if (!inspector)
880
680
  return null;
881
- const queue = await this.app.container.make('queue');
882
- this.queueInspector = new QueueInspector(queue);
883
- 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
+ };
884
689
  }
885
690
  catch {
886
- this.queueAvailable = false;
887
691
  return null;
888
692
  }
889
693
  }
890
694
  }
891
695
  // ---------------------------------------------------------------------------
892
- // Formatting helpers
696
+ // Formatting helpers (snake_case DB rows → camelCase API response)
893
697
  // ---------------------------------------------------------------------------
894
698
  function formatRequest(r) {
895
699
  return {
@@ -962,47 +766,12 @@ function emptyOverview() {
962
766
  slowestEndpoints: [],
963
767
  queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
964
768
  recentErrors: [],
769
+ topEvents: [],
770
+ emailActivity: { sent: 0, queued: 0, failed: 0 },
771
+ logLevelBreakdown: { error: 0, warn: 0, info: 0, debug: 0 },
772
+ statusDistribution: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
773
+ slowestQueries: [],
774
+ cacheStats: null,
775
+ jobQueueStatus: null,
965
776
  };
966
777
  }
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
- }