adonisjs-server-stats 1.2.2 → 1.3.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 (95) hide show
  1. package/README.md +148 -9
  2. package/dist/src/dashboard/chart_aggregator.d.ts +21 -0
  3. package/dist/src/dashboard/chart_aggregator.d.ts.map +1 -0
  4. package/dist/src/dashboard/chart_aggregator.js +89 -0
  5. package/dist/src/dashboard/dashboard_controller.d.ts +147 -0
  6. package/dist/src/dashboard/dashboard_controller.d.ts.map +1 -0
  7. package/dist/src/dashboard/dashboard_controller.js +1008 -0
  8. package/dist/src/dashboard/dashboard_routes.d.ts +16 -0
  9. package/dist/src/dashboard/dashboard_routes.d.ts.map +1 -0
  10. package/dist/src/dashboard/dashboard_routes.js +88 -0
  11. package/dist/src/dashboard/dashboard_store.d.ts +158 -0
  12. package/dist/src/dashboard/dashboard_store.d.ts.map +1 -0
  13. package/dist/src/dashboard/dashboard_store.js +723 -0
  14. package/dist/src/dashboard/integrations/cache_inspector.d.ts +88 -0
  15. package/dist/src/dashboard/integrations/cache_inspector.d.ts.map +1 -0
  16. package/dist/src/dashboard/integrations/cache_inspector.js +215 -0
  17. package/dist/src/dashboard/integrations/config_inspector.d.ts +33 -0
  18. package/dist/src/dashboard/integrations/config_inspector.d.ts.map +1 -0
  19. package/dist/src/dashboard/integrations/config_inspector.js +155 -0
  20. package/dist/src/dashboard/integrations/index.d.ts +7 -0
  21. package/dist/src/dashboard/integrations/index.d.ts.map +1 -0
  22. package/dist/src/dashboard/integrations/index.js +3 -0
  23. package/dist/src/dashboard/integrations/queue_inspector.d.ts +106 -0
  24. package/dist/src/dashboard/integrations/queue_inspector.d.ts.map +1 -0
  25. package/dist/src/dashboard/integrations/queue_inspector.js +182 -0
  26. package/dist/src/dashboard/migrator.d.ts +18 -0
  27. package/dist/src/dashboard/migrator.d.ts.map +1 -0
  28. package/dist/src/dashboard/migrator.js +144 -0
  29. package/dist/src/dashboard/models/stats_email.d.ts +19 -0
  30. package/dist/src/dashboard/models/stats_email.d.ts.map +1 -0
  31. package/dist/src/dashboard/models/stats_email.js +66 -0
  32. package/dist/src/dashboard/models/stats_event.d.ts +14 -0
  33. package/dist/src/dashboard/models/stats_event.d.ts.map +1 -0
  34. package/dist/src/dashboard/models/stats_event.js +43 -0
  35. package/dist/src/dashboard/models/stats_log.d.ts +12 -0
  36. package/dist/src/dashboard/models/stats_log.d.ts.map +1 -0
  37. package/dist/src/dashboard/models/stats_log.js +42 -0
  38. package/dist/src/dashboard/models/stats_metric.d.ts +15 -0
  39. package/dist/src/dashboard/models/stats_metric.d.ts.map +1 -0
  40. package/dist/src/dashboard/models/stats_metric.js +50 -0
  41. package/dist/src/dashboard/models/stats_query.d.ts +20 -0
  42. package/dist/src/dashboard/models/stats_query.d.ts.map +1 -0
  43. package/dist/src/dashboard/models/stats_query.js +67 -0
  44. package/dist/src/dashboard/models/stats_request.d.ts +21 -0
  45. package/dist/src/dashboard/models/stats_request.d.ts.map +1 -0
  46. package/dist/src/dashboard/models/stats_request.js +61 -0
  47. package/dist/src/dashboard/models/stats_saved_filter.d.ts +11 -0
  48. package/dist/src/dashboard/models/stats_saved_filter.d.ts.map +1 -0
  49. package/dist/src/dashboard/models/stats_saved_filter.js +38 -0
  50. package/dist/src/dashboard/models/stats_trace.d.ts +19 -0
  51. package/dist/src/dashboard/models/stats_trace.d.ts.map +1 -0
  52. package/dist/src/dashboard/models/stats_trace.js +67 -0
  53. package/dist/src/debug/debug_store.d.ts +5 -0
  54. package/dist/src/debug/debug_store.d.ts.map +1 -1
  55. package/dist/src/debug/debug_store.js +10 -0
  56. package/dist/src/debug/email_collector.d.ts +2 -0
  57. package/dist/src/debug/email_collector.d.ts.map +1 -1
  58. package/dist/src/debug/email_collector.js +4 -0
  59. package/dist/src/debug/event_collector.d.ts +2 -0
  60. package/dist/src/debug/event_collector.d.ts.map +1 -1
  61. package/dist/src/debug/event_collector.js +11 -2
  62. package/dist/src/debug/query_collector.d.ts +2 -0
  63. package/dist/src/debug/query_collector.d.ts.map +1 -1
  64. package/dist/src/debug/query_collector.js +11 -0
  65. package/dist/src/debug/ring_buffer.d.ts +3 -0
  66. package/dist/src/debug/ring_buffer.d.ts.map +1 -1
  67. package/dist/src/debug/ring_buffer.js +6 -0
  68. package/dist/src/debug/trace_collector.d.ts +4 -2
  69. package/dist/src/debug/trace_collector.d.ts.map +1 -1
  70. package/dist/src/debug/trace_collector.js +7 -2
  71. package/dist/src/debug/types.d.ts +8 -0
  72. package/dist/src/debug/types.d.ts.map +1 -1
  73. package/dist/src/edge/client/dashboard.css +1504 -0
  74. package/dist/src/edge/client/dashboard.js +2378 -0
  75. package/dist/src/edge/client/debug-panel.css +528 -108
  76. package/dist/src/edge/client/debug-panel.js +663 -22
  77. package/dist/src/edge/client/stats-bar.css +112 -38
  78. package/dist/src/edge/client/stats-bar.js +37 -3
  79. package/dist/src/edge/plugin.d.ts.map +1 -1
  80. package/dist/src/edge/plugin.js +21 -0
  81. package/dist/src/edge/views/dashboard.edge +382 -0
  82. package/dist/src/edge/views/debug-panel.edge +60 -14
  83. package/dist/src/edge/views/stats-bar.edge +9 -0
  84. package/dist/src/index.d.ts +2 -0
  85. package/dist/src/index.d.ts.map +1 -1
  86. package/dist/src/index.js +1 -0
  87. package/dist/src/middleware/request_tracking_middleware.d.ts +20 -0
  88. package/dist/src/middleware/request_tracking_middleware.d.ts.map +1 -1
  89. package/dist/src/middleware/request_tracking_middleware.js +66 -2
  90. package/dist/src/provider/server_stats_provider.d.ts +13 -0
  91. package/dist/src/provider/server_stats_provider.d.ts.map +1 -1
  92. package/dist/src/provider/server_stats_provider.js +175 -1
  93. package/dist/src/types.d.ts +42 -0
  94. package/dist/src/types.d.ts.map +1 -1
  95. package/package.json +14 -1
@@ -0,0 +1,723 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { autoMigrate, runRetentionCleanup } from './migrator.js';
4
+ import { ChartAggregator } from './chart_aggregator.js';
5
+ // ---------------------------------------------------------------------------
6
+ // DashboardStore
7
+ // ---------------------------------------------------------------------------
8
+ /**
9
+ * Bridges the in-memory RingBuffer collectors to SQLite persistence
10
+ * and provides query methods for the dashboard API.
11
+ *
12
+ * Handles auto-migration, retention cleanup, periodic metrics aggregation,
13
+ * and self-exclusion of dashboard routes and server_stats connection queries.
14
+ */
15
+ export class DashboardStore {
16
+ db = null;
17
+ emitter = null;
18
+ config;
19
+ dashboardPath;
20
+ retentionTimer = null;
21
+ chartAggregator = null;
22
+ handlers = [];
23
+ constructor(config) {
24
+ this.config = config;
25
+ this.dashboardPath = config.dashboardPath;
26
+ }
27
+ // =========================================================================
28
+ // Lifecycle
29
+ // =========================================================================
30
+ /**
31
+ * Initialize the SQLite connection, run migrations and retention
32
+ * cleanup, start chart aggregation, and wire event listeners.
33
+ */
34
+ async start(_lucidDb, emitter, appRoot) {
35
+ this.emitter = emitter;
36
+ const dbFilePath = appRoot + '/' + this.config.dbPath;
37
+ await mkdir(dirname(dbFilePath), { recursive: true });
38
+ // Create a standalone Knex connection to SQLite — bypasses Lucid's
39
+ // connection manager entirely so we never pollute the app's pool.
40
+ const knexModule = await import('knex');
41
+ const knexFactory = knexModule.default ?? knexModule;
42
+ this.db = knexFactory({
43
+ client: 'better-sqlite3',
44
+ connection: { filename: dbFilePath },
45
+ useNullAsDefault: true,
46
+ });
47
+ await this.db.raw('PRAGMA journal_mode=WAL');
48
+ await this.db.raw('PRAGMA foreign_keys=ON');
49
+ await autoMigrate(this.db);
50
+ await runRetentionCleanup(this.db, this.config.retentionDays);
51
+ // Hourly retention cleanup
52
+ this.retentionTimer = setInterval(async () => {
53
+ try {
54
+ await runRetentionCleanup(this.db, this.config.retentionDays);
55
+ }
56
+ catch {
57
+ // Silently ignore
58
+ }
59
+ }, 60 * 60 * 1000);
60
+ // Start chart aggregation (every 60s)
61
+ this.chartAggregator = new ChartAggregator(this.db);
62
+ this.chartAggregator.start();
63
+ // Wire email event listeners
64
+ this.wireEventListeners();
65
+ }
66
+ /** Shut down timers, event listeners, and database connection. */
67
+ async stop() {
68
+ if (this.retentionTimer) {
69
+ clearInterval(this.retentionTimer);
70
+ this.retentionTimer = null;
71
+ }
72
+ this.chartAggregator?.stop();
73
+ if (this.emitter) {
74
+ for (const h of this.handlers) {
75
+ if (typeof this.emitter.off === 'function') {
76
+ this.emitter.off(h.event, h.fn);
77
+ }
78
+ }
79
+ }
80
+ this.handlers = [];
81
+ if (this.db && typeof this.db.destroy === 'function') {
82
+ try {
83
+ await this.db.destroy();
84
+ }
85
+ catch {
86
+ // Ignore
87
+ }
88
+ }
89
+ this.db = null;
90
+ }
91
+ /** Get the raw Knex database connection (for DashboardController). */
92
+ getDb() {
93
+ return this.db;
94
+ }
95
+ /** Whether the store is initialized and ready. */
96
+ isReady() {
97
+ return this.db !== null;
98
+ }
99
+ // =========================================================================
100
+ // Write methods — persist data from collectors
101
+ // =========================================================================
102
+ /**
103
+ * Record a completed request. Returns the inserted row ID, or null
104
+ * if the request was self-excluded or an error occurred.
105
+ */
106
+ async recordRequest(method, url, statusCode, duration, spanCount = 0, warningCount = 0) {
107
+ if (!this.db)
108
+ return null;
109
+ if (url.startsWith(this.dashboardPath))
110
+ return null;
111
+ try {
112
+ const [id] = await this.db('server_stats_requests').insert({
113
+ method,
114
+ url,
115
+ status_code: statusCode,
116
+ duration: Math.round(duration * 100) / 100,
117
+ span_count: spanCount,
118
+ warning_count: warningCount,
119
+ });
120
+ return id;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /** Batch-insert queries for a request. Filters out server_stats connection queries. */
127
+ async recordQueries(requestId, queries) {
128
+ if (!this.db || queries.length === 0)
129
+ return;
130
+ const filtered = queries.filter((q) => q.connection !== 'server_stats');
131
+ if (filtered.length === 0)
132
+ return;
133
+ try {
134
+ const rows = filtered.map((q) => ({
135
+ request_id: requestId,
136
+ sql_text: q.sql,
137
+ sql_normalized: normalizeSql(q.sql),
138
+ bindings: q.bindings ? JSON.stringify(q.bindings) : null,
139
+ duration: Math.round(q.duration * 100) / 100,
140
+ method: q.method,
141
+ model: q.model,
142
+ connection: q.connection,
143
+ in_transaction: q.inTransaction ? 1 : 0,
144
+ }));
145
+ // SQLite variable limit: batch in chunks of 50
146
+ for (let i = 0; i < rows.length; i += 50) {
147
+ await this.db('server_stats_queries').insert(rows.slice(i, i + 50));
148
+ }
149
+ }
150
+ catch {
151
+ // Silently ignore
152
+ }
153
+ }
154
+ /** Batch-insert events for a request. */
155
+ async recordEvents(requestId, events) {
156
+ if (!this.db || events.length === 0)
157
+ return;
158
+ try {
159
+ const rows = events.map((e) => ({
160
+ request_id: requestId,
161
+ event_name: e.event,
162
+ data: e.data,
163
+ }));
164
+ for (let i = 0; i < rows.length; i += 50) {
165
+ await this.db('server_stats_events').insert(rows.slice(i, i + 50));
166
+ }
167
+ }
168
+ catch {
169
+ // Silently ignore
170
+ }
171
+ }
172
+ /** Record a single email. */
173
+ async recordEmail(record) {
174
+ if (!this.db)
175
+ return;
176
+ try {
177
+ await this.db('server_stats_emails').insert({
178
+ from_addr: record.from,
179
+ to_addr: record.to,
180
+ cc: record.cc,
181
+ bcc: record.bcc,
182
+ subject: record.subject,
183
+ html: record.html,
184
+ text_body: record.text,
185
+ mailer: record.mailer,
186
+ status: record.status,
187
+ message_id: record.messageId,
188
+ attachment_count: record.attachmentCount,
189
+ });
190
+ }
191
+ catch {
192
+ // Silently ignore
193
+ }
194
+ }
195
+ /** Record a single log entry (from LogStreamService). */
196
+ async recordLog(entry) {
197
+ if (!this.db)
198
+ return;
199
+ try {
200
+ const levelName = typeof entry.levelName === 'string'
201
+ ? entry.levelName
202
+ : String(entry.level || 'unknown');
203
+ await this.db('server_stats_logs').insert({
204
+ level: levelName,
205
+ message: String(entry.msg || entry.message || ''),
206
+ request_id: entry.requestId ? String(entry.requestId) : null,
207
+ data: JSON.stringify(entry),
208
+ });
209
+ }
210
+ catch {
211
+ // Silently ignore
212
+ }
213
+ }
214
+ /** Record a trace for a request. */
215
+ async recordTrace(requestId, trace) {
216
+ if (!this.db)
217
+ return;
218
+ try {
219
+ await this.db('server_stats_traces').insert({
220
+ request_id: requestId,
221
+ method: trace.method,
222
+ url: trace.url,
223
+ status_code: trace.statusCode,
224
+ total_duration: Math.round(trace.totalDuration * 100) / 100,
225
+ span_count: trace.spanCount,
226
+ spans: JSON.stringify(trace.spans),
227
+ warnings: trace.warnings.length > 0 ? JSON.stringify(trace.warnings) : null,
228
+ });
229
+ }
230
+ catch {
231
+ // Silently ignore
232
+ }
233
+ }
234
+ /**
235
+ * Convenience: persist a full request with associated queries and trace.
236
+ * Calls recordRequest, recordQueries, and recordTrace in sequence.
237
+ */
238
+ async persistRequest(method, url, statusCode, duration, queries, trace) {
239
+ const requestId = await this.recordRequest(method, url, statusCode, duration, trace?.spanCount ?? 0, trace?.warnings?.length ?? 0);
240
+ if (requestId === null)
241
+ return null;
242
+ await this.recordQueries(requestId, queries);
243
+ if (trace) {
244
+ await this.recordTrace(requestId, trace);
245
+ }
246
+ return requestId;
247
+ }
248
+ // =========================================================================
249
+ // Read methods — query data for dashboard API
250
+ // =========================================================================
251
+ /** Paginated request history with optional filters. */
252
+ async getRequests(page = 1, perPage = 50, filters) {
253
+ return this.paginate('server_stats_requests', page, perPage, (query) => {
254
+ if (filters?.method)
255
+ query.where('method', filters.method);
256
+ if (filters?.url)
257
+ query.where('url', 'like', `%${filters.url}%`);
258
+ if (filters?.statusMin)
259
+ query.where('status_code', '>=', filters.statusMin);
260
+ if (filters?.statusMax)
261
+ query.where('status_code', '<=', filters.statusMax);
262
+ if (filters?.durationMin)
263
+ query.where('duration', '>=', filters.durationMin);
264
+ if (filters?.durationMax)
265
+ query.where('duration', '<=', filters.durationMax);
266
+ });
267
+ }
268
+ /** Paginated query history with optional filters. */
269
+ async getQueries(page = 1, perPage = 50, filters) {
270
+ return this.paginate('server_stats_queries', page, perPage, (query) => {
271
+ if (filters?.method)
272
+ query.where('method', filters.method);
273
+ if (filters?.model)
274
+ query.where('model', filters.model);
275
+ if (filters?.connection)
276
+ query.where('connection', filters.connection);
277
+ if (filters?.durationMin)
278
+ query.where('duration', '>=', filters.durationMin);
279
+ if (filters?.durationMax)
280
+ query.where('duration', '<=', filters.durationMax);
281
+ if (filters?.requestId)
282
+ query.where('request_id', filters.requestId);
283
+ });
284
+ }
285
+ /**
286
+ * Grouped query patterns: aggregated by sql_normalized
287
+ * with count, avg/min/max/total duration.
288
+ */
289
+ async getQueriesGrouped() {
290
+ if (!this.db)
291
+ return [];
292
+ return this.db('server_stats_queries')
293
+ .select('sql_normalized', this.db.raw('COUNT(*) as count'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'), this.db.raw('ROUND(MIN(duration), 2) as min_duration'), this.db.raw('ROUND(MAX(duration), 2) as max_duration'), this.db.raw('ROUND(SUM(duration), 2) as total_duration'))
294
+ .groupBy('sql_normalized')
295
+ .orderBy('count', 'desc')
296
+ .limit(200);
297
+ }
298
+ /** Paginated event history with optional filters. */
299
+ async getEvents(page = 1, perPage = 50, filters) {
300
+ return this.paginate('server_stats_events', page, perPage, (query) => {
301
+ if (filters?.eventName)
302
+ query.where('event_name', 'like', `%${filters.eventName}%`);
303
+ });
304
+ }
305
+ /** Paginated email history with optional filters. */
306
+ async getEmails(page = 1, perPage = 50, filters) {
307
+ return this.paginate('server_stats_emails', page, perPage, (query) => {
308
+ if (filters?.from)
309
+ query.where('from_addr', 'like', `%${filters.from}%`);
310
+ if (filters?.to)
311
+ query.where('to_addr', 'like', `%${filters.to}%`);
312
+ if (filters?.subject)
313
+ query.where('subject', 'like', `%${filters.subject}%`);
314
+ if (filters?.mailer)
315
+ query.where('mailer', filters.mailer);
316
+ if (filters?.status)
317
+ query.where('status', filters.status);
318
+ });
319
+ }
320
+ /** Get email HTML body for preview. */
321
+ async getEmailHtml(id) {
322
+ if (!this.db)
323
+ return null;
324
+ const row = await this.db('server_stats_emails').where('id', id).select('html').first();
325
+ return row?.html ?? null;
326
+ }
327
+ /**
328
+ * Paginated log history with structured search support.
329
+ *
330
+ * Structured filters query into the JSON `data` column using
331
+ * SQLite's `json_extract()`.
332
+ */
333
+ async getLogs(page = 1, perPage = 50, filters) {
334
+ return this.paginate('server_stats_logs', page, perPage, (query) => {
335
+ if (filters?.level)
336
+ query.where('level', filters.level);
337
+ if (filters?.requestId)
338
+ query.where('request_id', filters.requestId);
339
+ if (filters?.search) {
340
+ query.where('message', 'like', `%${filters.search}%`);
341
+ }
342
+ if (filters?.structured && filters.structured.length > 0) {
343
+ for (const sf of filters.structured) {
344
+ const jsonPath = `$.${sf.field}`;
345
+ switch (sf.operator) {
346
+ case 'equals':
347
+ query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, sf.value]);
348
+ break;
349
+ case 'contains':
350
+ query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${sf.value}%`]);
351
+ break;
352
+ case 'startsWith':
353
+ query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${sf.value}%`]);
354
+ break;
355
+ }
356
+ }
357
+ }
358
+ });
359
+ }
360
+ /** Paginated trace history with optional filters. */
361
+ async getTraces(page = 1, perPage = 50, filters) {
362
+ return this.paginate('server_stats_traces', page, perPage, (query) => {
363
+ if (filters?.method)
364
+ query.where('method', filters.method);
365
+ if (filters?.url)
366
+ query.where('url', 'like', `%${filters.url}%`);
367
+ if (filters?.statusMin)
368
+ query.where('status_code', '>=', filters.statusMin);
369
+ if (filters?.statusMax)
370
+ query.where('status_code', '<=', filters.statusMax);
371
+ });
372
+ }
373
+ /** Single trace with full span data. */
374
+ async getTraceDetail(id) {
375
+ if (!this.db)
376
+ return null;
377
+ const row = await this.db('server_stats_traces').where('id', id).first();
378
+ if (!row)
379
+ return null;
380
+ return {
381
+ ...row,
382
+ spans: typeof row.spans === 'string' ? JSON.parse(row.spans) : row.spans,
383
+ warnings: row.warnings
384
+ ? typeof row.warnings === 'string'
385
+ ? JSON.parse(row.warnings)
386
+ : row.warnings
387
+ : [],
388
+ };
389
+ }
390
+ /** Single request with associated queries, events, and trace. */
391
+ async getRequestDetail(id) {
392
+ if (!this.db)
393
+ return null;
394
+ const request = await this.db('server_stats_requests').where('id', id).first();
395
+ if (!request)
396
+ return null;
397
+ const [queries, events, trace] = await Promise.all([
398
+ this.db('server_stats_queries').where('request_id', id).orderBy('created_at', 'asc'),
399
+ this.db('server_stats_events').where('request_id', id).orderBy('created_at', 'asc'),
400
+ this.db('server_stats_traces').where('request_id', id).first(),
401
+ ]);
402
+ return {
403
+ ...request,
404
+ queries,
405
+ events,
406
+ trace: trace
407
+ ? {
408
+ ...trace,
409
+ spans: typeof trace.spans === 'string' ? JSON.parse(trace.spans) : trace.spans,
410
+ warnings: trace.warnings
411
+ ? typeof trace.warnings === 'string'
412
+ ? JSON.parse(trace.warnings)
413
+ : trace.warnings
414
+ : [],
415
+ }
416
+ : null,
417
+ };
418
+ }
419
+ // =========================================================================
420
+ // Overview & Charts
421
+ // =========================================================================
422
+ /**
423
+ * Aggregated overview metrics for the dashboard cards.
424
+ *
425
+ * @param range — '1h' | '6h' | '24h' | '7d'
426
+ */
427
+ async getOverviewMetrics(range = '1h') {
428
+ if (!this.db)
429
+ return null;
430
+ const cutoff = rangeToCutoff(range);
431
+ // Recent requests for calculations
432
+ const requests = await this.db('server_stats_requests')
433
+ .where('created_at', '>=', cutoff)
434
+ .select('duration', 'status_code', 'url', 'created_at');
435
+ const total = requests.length;
436
+ if (total === 0) {
437
+ return {
438
+ avgResponseTime: 0,
439
+ p95ResponseTime: 0,
440
+ requestsPerMinute: 0,
441
+ errorRate: 0,
442
+ totalRequests: 0,
443
+ slowestEndpoints: [],
444
+ queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
445
+ recentErrors: [],
446
+ };
447
+ }
448
+ const durations = requests.map((r) => r.duration).sort((a, b) => a - b);
449
+ const avgResponseTime = durations.reduce((s, d) => s + d, 0) / total;
450
+ const p95Index = Math.floor(total * 0.95);
451
+ const p95ResponseTime = durations[Math.min(p95Index, total - 1)];
452
+ const errorCount = requests.filter((r) => r.status_code >= 400).length;
453
+ const rangeMinutes = rangeToMinutes(range);
454
+ const requestsPerMin = total / rangeMinutes;
455
+ // Slowest endpoints (top 5 by average duration)
456
+ const slowestEndpoints = await this.db('server_stats_requests')
457
+ .where('created_at', '>=', cutoff)
458
+ .select('url', this.db.raw('COUNT(*) as count'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
459
+ .groupBy('url')
460
+ .orderBy('avg_duration', 'desc')
461
+ .limit(5);
462
+ // Query stats
463
+ const queryStats = await this.db('server_stats_queries')
464
+ .where('created_at', '>=', cutoff)
465
+ .select(this.db.raw('COUNT(*) as total'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
466
+ .first();
467
+ // Recent errors (last 5 log entries with level error/fatal)
468
+ const recentErrors = await this.db('server_stats_logs')
469
+ .where('created_at', '>=', cutoff)
470
+ .whereIn('level', ['error', 'fatal'])
471
+ .orderBy('created_at', 'desc')
472
+ .limit(5);
473
+ return {
474
+ avgResponseTime: Math.round(avgResponseTime * 100) / 100,
475
+ p95ResponseTime: Math.round(p95ResponseTime * 100) / 100,
476
+ requestsPerMinute: Math.round(requestsPerMin * 100) / 100,
477
+ errorRate: Math.round((errorCount / total) * 10000) / 100,
478
+ totalRequests: total,
479
+ slowestEndpoints: slowestEndpoints.map((s) => ({
480
+ url: s.url,
481
+ count: s.count,
482
+ avgDuration: s.avg_duration,
483
+ })),
484
+ queryStats: {
485
+ total: queryStats?.total ?? 0,
486
+ avgDuration: queryStats?.avg_duration ?? 0,
487
+ perRequest: total > 0 ? Math.round(((queryStats?.total ?? 0) / total) * 100) / 100 : 0,
488
+ },
489
+ recentErrors: recentErrors.map((e) => ({
490
+ id: e.id,
491
+ message: e.message,
492
+ createdAt: e.created_at,
493
+ })),
494
+ };
495
+ }
496
+ /**
497
+ * Time-series chart data from server_stats_metrics.
498
+ *
499
+ * @param range — '1h' | '6h' | '24h' | '7d'
500
+ */
501
+ async getChartData(range = '1h') {
502
+ if (!this.db)
503
+ return [];
504
+ const cutoff = rangeToCutoff(range);
505
+ // For 1h/6h, use the per-minute metrics table.
506
+ // For 24h/7d, aggregate metrics into larger buckets.
507
+ const rows = await this.db('server_stats_metrics')
508
+ .where('bucket', '>=', cutoff)
509
+ .orderBy('bucket', 'asc');
510
+ if (range === '1h' || range === '6h') {
511
+ return rows;
512
+ }
513
+ // For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
514
+ const bucketMinutes = range === '7d' ? 60 : 15;
515
+ const grouped = new Map();
516
+ for (const row of rows) {
517
+ const bucketKey = roundBucket(row.bucket, bucketMinutes);
518
+ if (!grouped.has(bucketKey)) {
519
+ grouped.set(bucketKey, {
520
+ bucket: bucketKey,
521
+ request_count: 0,
522
+ avg_duration: 0,
523
+ p95_duration: 0,
524
+ error_count: 0,
525
+ query_count: 0,
526
+ avg_query_duration: 0,
527
+ _count: 0,
528
+ });
529
+ }
530
+ const g = grouped.get(bucketKey);
531
+ g.request_count += row.request_count;
532
+ g.error_count += row.error_count;
533
+ g.query_count += row.query_count;
534
+ g.avg_duration += row.avg_duration;
535
+ g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
536
+ g.avg_query_duration += row.avg_query_duration;
537
+ g._count++;
538
+ }
539
+ return Array.from(grouped.values()).map((g) => ({
540
+ bucket: g.bucket,
541
+ request_count: g.request_count,
542
+ avg_duration: g._count > 0 ? Math.round((g.avg_duration / g._count) * 100) / 100 : 0,
543
+ p95_duration: Math.round(g.p95_duration * 100) / 100,
544
+ error_count: g.error_count,
545
+ query_count: g.query_count,
546
+ avg_query_duration: g._count > 0 ? Math.round((g.avg_query_duration / g._count) * 100) / 100 : 0,
547
+ }));
548
+ }
549
+ // =========================================================================
550
+ // Saved filters CRUD
551
+ // =========================================================================
552
+ async getSavedFilters(section) {
553
+ if (!this.db)
554
+ return [];
555
+ const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
556
+ if (section)
557
+ query.where('section', section);
558
+ return query;
559
+ }
560
+ async createSavedFilter(name, section, filterConfig) {
561
+ if (!this.db)
562
+ return null;
563
+ const [id] = await this.db('server_stats_saved_filters').insert({
564
+ name,
565
+ section,
566
+ filter_config: JSON.stringify(filterConfig),
567
+ });
568
+ return { id, name, section, filterConfig };
569
+ }
570
+ async deleteSavedFilter(id) {
571
+ if (!this.db)
572
+ return false;
573
+ const deleted = await this.db('server_stats_saved_filters').where('id', id).delete();
574
+ return deleted > 0;
575
+ }
576
+ // =========================================================================
577
+ // EXPLAIN
578
+ // =========================================================================
579
+ /**
580
+ * Run EXPLAIN on a stored query using the app's default database connection.
581
+ * Only allows SELECT queries for safety.
582
+ *
583
+ * @param queryId — ID from server_stats_queries
584
+ * @param appDb — The application's Lucid database manager
585
+ */
586
+ async runExplain(queryId, appDb) {
587
+ if (!this.db)
588
+ return { error: 'Dashboard store not initialized' };
589
+ const row = await this.db('server_stats_queries').where('id', queryId).first();
590
+ if (!row)
591
+ return { error: 'Query not found' };
592
+ const sql = row.sql_text.trim();
593
+ if (!sql.toLowerCase().startsWith('select')) {
594
+ return { error: 'EXPLAIN is only supported for SELECT queries' };
595
+ }
596
+ try {
597
+ const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
598
+ return { plan: result.rows || result };
599
+ }
600
+ catch (err) {
601
+ return { error: err.message || 'EXPLAIN failed' };
602
+ }
603
+ }
604
+ // =========================================================================
605
+ // Private helpers
606
+ // =========================================================================
607
+ /** Generic paginated query with filter callback. */
608
+ async paginate(table, page, perPage, applyFilters) {
609
+ if (!this.db) {
610
+ return { data: [], total: 0, page, perPage, lastPage: 0 };
611
+ }
612
+ // Count total
613
+ const countQuery = this.db(table);
614
+ if (applyFilters)
615
+ applyFilters(countQuery);
616
+ const [{ count: total }] = await countQuery.count('* as count');
617
+ // Fetch page
618
+ const offset = (page - 1) * perPage;
619
+ const dataQuery = this.db(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
620
+ if (applyFilters)
621
+ applyFilters(dataQuery);
622
+ const data = await dataQuery;
623
+ return {
624
+ data,
625
+ total,
626
+ page,
627
+ perPage,
628
+ lastPage: Math.ceil(total / perPage),
629
+ };
630
+ }
631
+ /**
632
+ * Wire email event listeners to persist emails as they arrive.
633
+ */
634
+ wireEventListeners() {
635
+ if (!this.emitter || typeof this.emitter.on !== 'function')
636
+ return;
637
+ const buildAndPersistEmail = (data, status) => {
638
+ const msg = data?.message || data;
639
+ const record = {
640
+ id: 0,
641
+ from: extractAddresses(msg?.from) || 'unknown',
642
+ to: extractAddresses(msg?.to) || 'unknown',
643
+ cc: extractAddresses(msg?.cc) || null,
644
+ bcc: extractAddresses(msg?.bcc) || null,
645
+ subject: msg?.subject || '(no subject)',
646
+ html: msg?.html || null,
647
+ text: msg?.text || null,
648
+ mailer: data?.mailerName || data?.mailer || 'unknown',
649
+ status,
650
+ messageId: data?.response?.messageId || data?.messageId || null,
651
+ attachmentCount: Array.isArray(msg?.attachments) ? msg.attachments.length : 0,
652
+ timestamp: Date.now(),
653
+ };
654
+ this.recordEmail(record);
655
+ };
656
+ this.handlers = [
657
+ { event: 'mail:sent', fn: (data) => buildAndPersistEmail(data, 'sent') },
658
+ { event: 'mail:queued', fn: (data) => buildAndPersistEmail(data, 'queued') },
659
+ { event: 'queued:mail:error', fn: (data) => buildAndPersistEmail(data, 'failed') },
660
+ ];
661
+ for (const h of this.handlers) {
662
+ this.emitter.on(h.event, h.fn);
663
+ }
664
+ }
665
+ }
666
+ // ---------------------------------------------------------------------------
667
+ // Helpers
668
+ // ---------------------------------------------------------------------------
669
+ /**
670
+ * Normalize a SQL query by replacing literal values with `?` placeholders.
671
+ * Used for grouping identical query patterns.
672
+ */
673
+ function normalizeSql(sql) {
674
+ return sql
675
+ .replace(/'[^']*'/g, '?')
676
+ .replace(/\b\d+(\.\d+)?\b/g, '?')
677
+ .replace(/\s+/g, ' ')
678
+ .trim();
679
+ }
680
+ /** Extract email addresses from various AdonisJS mail address formats. */
681
+ function extractAddresses(value) {
682
+ if (!value)
683
+ return '';
684
+ if (typeof value === 'string')
685
+ return value;
686
+ if (Array.isArray(value)) {
687
+ return value
688
+ .map((v) => (typeof v === 'string' ? v : v?.address || ''))
689
+ .filter(Boolean)
690
+ .join(', ');
691
+ }
692
+ if (typeof value === 'object' && value.address)
693
+ return value.address;
694
+ return '';
695
+ }
696
+ /** Convert a range string to a SQLite-compatible datetime cutoff. */
697
+ function rangeToCutoff(range) {
698
+ const minutes = rangeToMinutes(range);
699
+ const cutoff = new Date(Date.now() - minutes * 60_000);
700
+ return cutoff.toISOString().replace('T', ' ').slice(0, 19);
701
+ }
702
+ /** Convert a range string to total minutes. */
703
+ function rangeToMinutes(range) {
704
+ switch (range) {
705
+ case '1h':
706
+ return 60;
707
+ case '6h':
708
+ return 360;
709
+ case '24h':
710
+ return 1440;
711
+ case '7d':
712
+ return 10080;
713
+ default:
714
+ return 60;
715
+ }
716
+ }
717
+ /** Round a bucket timestamp string down to the nearest N minutes. */
718
+ function roundBucket(bucket, minutes) {
719
+ const date = new Date(bucket.replace(' ', 'T') + 'Z');
720
+ const ms = minutes * 60_000;
721
+ const rounded = new Date(Math.floor(date.getTime() / ms) * ms);
722
+ return rounded.toISOString().replace('T', ' ').slice(0, 19);
723
+ }