adonisjs-server-stats 1.6.5 → 1.6.11

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 (86) hide show
  1. package/README.md +1 -0
  2. package/dist/core/api-client.d.ts.map +1 -1
  3. package/dist/core/dashboard-api.d.ts +2 -1
  4. package/dist/core/dashboard-api.d.ts.map +1 -1
  5. package/dist/core/dashboard-data-controller.d.ts +2 -0
  6. package/dist/core/dashboard-data-controller.d.ts.map +1 -1
  7. package/dist/core/debug-data-controller.d.ts +1 -0
  8. package/dist/core/debug-data-controller.d.ts.map +1 -1
  9. package/dist/core/history-buffer.d.ts.map +1 -1
  10. package/dist/core/index.js +404 -361
  11. package/dist/core/server-stats-controller.d.ts +1 -0
  12. package/dist/core/server-stats-controller.d.ts.map +1 -1
  13. package/dist/core/sparkline.d.ts.map +1 -1
  14. package/dist/react/core/api-client.d.ts.map +1 -1
  15. package/dist/react/core/dashboard-api.d.ts +2 -1
  16. package/dist/react/core/dashboard-api.d.ts.map +1 -1
  17. package/dist/react/core/dashboard-data-controller.d.ts +2 -0
  18. package/dist/react/core/dashboard-data-controller.d.ts.map +1 -1
  19. package/dist/react/core/debug-data-controller.d.ts +1 -0
  20. package/dist/react/core/debug-data-controller.d.ts.map +1 -1
  21. package/dist/react/core/history-buffer.d.ts.map +1 -1
  22. package/dist/react/core/server-stats-controller.d.ts +1 -0
  23. package/dist/react/core/server-stats-controller.d.ts.map +1 -1
  24. package/dist/react/core/sparkline.d.ts.map +1 -1
  25. package/dist/src/collectors/app_collector.d.ts.map +1 -1
  26. package/dist/src/collectors/db_pool_collector.d.ts.map +1 -1
  27. package/dist/src/collectors/redis_collector.d.ts.map +1 -1
  28. package/dist/src/controller/debug_controller.d.ts +3 -1
  29. package/dist/src/controller/debug_controller.d.ts.map +1 -1
  30. package/dist/src/controller/debug_controller.js +25 -20
  31. package/dist/src/dashboard/chart_aggregator.js +42 -41
  32. package/dist/src/dashboard/dashboard_controller.d.ts.map +1 -1
  33. package/dist/src/dashboard/dashboard_controller.js +7 -5
  34. package/dist/src/dashboard/dashboard_store.d.ts +61 -19
  35. package/dist/src/dashboard/dashboard_store.d.ts.map +1 -1
  36. package/dist/src/dashboard/dashboard_store.js +677 -474
  37. package/dist/src/dashboard/integrations/config_inspector.d.ts +4 -0
  38. package/dist/src/dashboard/integrations/config_inspector.d.ts.map +1 -1
  39. package/dist/src/dashboard/integrations/config_inspector.js +16 -2
  40. package/dist/src/dashboard/migrator.d.ts.map +1 -1
  41. package/dist/src/dashboard/migrator.js +30 -4
  42. package/dist/src/data/data_access.d.ts.map +1 -1
  43. package/dist/src/data/data_access.js +26 -6
  44. package/dist/src/debug/debug_store.d.ts.map +1 -1
  45. package/dist/src/debug/debug_store.js +17 -7
  46. package/dist/src/debug/email_collector.d.ts +2 -0
  47. package/dist/src/debug/email_collector.d.ts.map +1 -1
  48. package/dist/src/debug/email_collector.js +17 -13
  49. package/dist/src/debug/event_collector.d.ts +7 -1
  50. package/dist/src/debug/event_collector.d.ts.map +1 -1
  51. package/dist/src/debug/event_collector.js +46 -17
  52. package/dist/src/debug/query_collector.d.ts +12 -0
  53. package/dist/src/debug/query_collector.d.ts.map +1 -1
  54. package/dist/src/debug/query_collector.js +35 -5
  55. package/dist/src/debug/ring_buffer.d.ts +14 -0
  56. package/dist/src/debug/ring_buffer.d.ts.map +1 -1
  57. package/dist/src/debug/ring_buffer.js +48 -2
  58. package/dist/src/debug/trace_collector.d.ts +1 -0
  59. package/dist/src/debug/trace_collector.d.ts.map +1 -1
  60. package/dist/src/debug/trace_collector.js +4 -1
  61. package/dist/src/define_config.d.ts.map +1 -1
  62. package/dist/src/define_config.js +5 -1
  63. package/dist/src/edge/client/dashboard.js +2 -2
  64. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  65. package/dist/src/edge/client/debug-panel.js +1 -1
  66. package/dist/src/edge/client/stats-bar.js +1 -1
  67. package/dist/src/edge/client-vue/dashboard.js +5 -5
  68. package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
  69. package/dist/src/edge/client-vue/debug-panel.js +2 -2
  70. package/dist/src/edge/client-vue/stats-bar.js +3 -3
  71. package/dist/src/engine/request_metrics.d.ts.map +1 -1
  72. package/dist/src/engine/request_metrics.js +33 -3
  73. package/dist/src/log_stream/log_stream_provider.js +1 -1
  74. package/dist/src/log_stream/log_stream_service.d.ts +1 -0
  75. package/dist/src/log_stream/log_stream_service.d.ts.map +1 -1
  76. package/dist/src/log_stream/log_stream_service.js +13 -3
  77. package/dist/src/prometheus/prometheus_collector.d.ts.map +1 -1
  78. package/dist/src/provider/server_stats_provider.d.ts.map +1 -1
  79. package/dist/src/provider/server_stats_provider.js +17 -31
  80. package/dist/src/stubs/config.stub +3 -0
  81. package/dist/src/types.d.ts +12 -0
  82. package/dist/src/types.d.ts.map +1 -1
  83. package/dist/src/utils/logger.d.ts +7 -5
  84. package/dist/src/utils/logger.d.ts.map +1 -1
  85. package/dist/src/utils/logger.js +27 -5
  86. package/package.json +6 -2
@@ -32,6 +32,52 @@ export class DashboardStore {
32
32
  handlers = [];
33
33
  dbFilePath = '';
34
34
  lastCleanupAt = null;
35
+ // In-flight request coalescing — prevents concurrent identical reads from
36
+ // each acquiring the single-connection pool independently. When 30 rapid
37
+ // clicks trigger 30 getOverviewMetrics('1h') calls, only ONE actually
38
+ // executes; the other 29 get the same promise.
39
+ inflight = new Map();
40
+ coalesce(key, fn) {
41
+ const existing = this.inflight.get(key);
42
+ if (existing)
43
+ return existing;
44
+ const promise = fn().finally(() => this.inflight.delete(key));
45
+ this.inflight.set(key, promise);
46
+ return promise;
47
+ }
48
+ // Short-lived result cache — serves stale data for repeat requests within
49
+ // the TTL window. Cache miss falls through to coalesce(), so concurrent
50
+ // cache misses still only execute once.
51
+ resultCache = new Map();
52
+ cached(key, ttlMs, fn) {
53
+ const entry = this.resultCache.get(key);
54
+ if (entry && Date.now() < entry.expiresAt)
55
+ return Promise.resolve(entry.data);
56
+ return this.coalesce(key, async () => {
57
+ const result = await fn();
58
+ this.resultCache.set(key, { data: result, expiresAt: Date.now() + ttlMs });
59
+ return result;
60
+ });
61
+ }
62
+ // Cached storage stats (polled every 3s by Internals tab — cache for 10s)
63
+ cachedStorageStats = null;
64
+ static STORAGE_STATS_TTL_MS = 10_000;
65
+ // TTL constants for the cached() helper
66
+ static WIDGETS_CACHE_TTL_MS = 2_000;
67
+ static SPARKLINE_CACHE_TTL_MS = 5_000;
68
+ static CHART_CACHE_TTL_MS = 5_000;
69
+ static QUERIES_GROUPED_CACHE_TTL_MS = 3_000;
70
+ static PAGINATE_CACHE_TTL_MS = 1_000;
71
+ // Write queue — buffers pending writes and flushes them in batch
72
+ // transactions to avoid overwhelming the single-connection pool.
73
+ writeQueue = [];
74
+ pendingEvents = [];
75
+ pendingLogs = [];
76
+ pendingEmails = [];
77
+ flushTimer = null;
78
+ flushing = false;
79
+ static FLUSH_INTERVAL_MS = 500;
80
+ static MAX_QUEUE_SIZE = 200;
35
81
  constructor(config) {
36
82
  this.config = config;
37
83
  this.dashboardPath = config.dashboardPath;
@@ -84,11 +130,45 @@ export class DashboardStore {
84
130
  client: 'better-sqlite3',
85
131
  connection: { filename: dbFilePath },
86
132
  useNullAsDefault: true,
133
+ // SQLite only supports one writer. Using a single-connection pool
134
+ // prevents SQLITE_BUSY deadlocks under load and ensures PRAGMAs
135
+ // are set consistently on the one connection that's reused.
136
+ pool: {
137
+ min: 1,
138
+ max: 1,
139
+ // Allow up to 10s for connection acquisition under load.
140
+ // The previous 2s timeout caused cascading failures during rapid
141
+ // tab switching when the write flush held the connection.
142
+ acquireTimeoutMillis: 10_000,
143
+ // Set PRAGMAs on every new connection (not just the first one)
144
+ afterCreate(conn, done) {
145
+ const raw = conn;
146
+ try {
147
+ raw.pragma('journal_mode = WAL');
148
+ raw.pragma('foreign_keys = ON');
149
+ raw.pragma('synchronous = NORMAL');
150
+ raw.pragma('cache_size = -64000'); // 64 MB page cache
151
+ raw.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
152
+ raw.pragma('temp_store = MEMORY');
153
+ // Note: busy_timeout is a no-op via PRAGMA in better-sqlite3.
154
+ // Use the `timeout` constructor option in better-sqlite3 if needed.
155
+ }
156
+ catch {
157
+ // Fallback: PRAGMAs will be set via db.raw() below
158
+ }
159
+ done(null, conn);
160
+ },
161
+ },
87
162
  });
88
163
  this.db = db;
164
+ // Ensure PRAGMAs are set (fallback if afterCreate didn't work)
89
165
  log.info('dashboard: setting PRAGMA...');
90
166
  await db.raw('PRAGMA journal_mode=WAL');
91
167
  await db.raw('PRAGMA foreign_keys=ON');
168
+ await db.raw('PRAGMA synchronous=NORMAL');
169
+ await db.raw('PRAGMA cache_size=-64000');
170
+ await db.raw('PRAGMA mmap_size=268435456');
171
+ await db.raw('PRAGMA temp_store=MEMORY');
92
172
  log.info('dashboard: PRAGMA set');
93
173
  log.info('dashboard: running migrations...');
94
174
  await autoMigrate(db);
@@ -118,6 +198,12 @@ export class DashboardStore {
118
198
  }
119
199
  /** Shut down timers, event listeners, and database connection. */
120
200
  async stop() {
201
+ // Flush remaining writes before shutting down
202
+ if (this.flushTimer) {
203
+ clearTimeout(this.flushTimer);
204
+ this.flushTimer = null;
205
+ }
206
+ await this.flushWriteQueue().catch(() => { });
121
207
  if (this.retentionTimer) {
122
208
  clearInterval(this.retentionTimer);
123
209
  this.retentionTimer = null;
@@ -149,7 +235,11 @@ export class DashboardStore {
149
235
  isReady() {
150
236
  return this.db !== null;
151
237
  }
152
- /** Get SQLite storage statistics for the diagnostics endpoint. */
238
+ /**
239
+ * Get SQLite storage statistics for the diagnostics endpoint.
240
+ * Cached for 10s since the Internals tab polls every 3s.
241
+ * Wrapped in a single transaction — 1 pool acquire instead of 8.
242
+ */
153
243
  async getStorageStats() {
154
244
  if (!this.db) {
155
245
  return {
@@ -162,238 +252,308 @@ export class DashboardStore {
162
252
  lastCleanupAt: null,
163
253
  };
164
254
  }
165
- let fileSizeMb = 0;
166
- let walSizeMb = 0;
167
- try {
168
- const s = await fsStat(this.dbFilePath);
169
- fileSizeMb = Math.round((s.size / (1024 * 1024)) * 100) / 100;
170
- }
171
- catch {
172
- // File may not exist yet
173
- }
174
- try {
175
- const ws = await fsStat(this.dbFilePath + '-wal');
176
- walSizeMb = Math.round((ws.size / (1024 * 1024)) * 100) / 100;
177
- }
178
- catch {
179
- // WAL file may not exist
255
+ // Serve cached stats if still fresh (avoids 8 COUNT queries per 3s poll)
256
+ if (this.cachedStorageStats &&
257
+ Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_STATS_TTL_MS) {
258
+ return this.cachedStorageStats.data;
180
259
  }
181
- const tableNames = [
182
- 'server_stats_requests',
183
- 'server_stats_queries',
184
- 'server_stats_events',
185
- 'server_stats_emails',
186
- 'server_stats_logs',
187
- 'server_stats_traces',
188
- 'server_stats_metrics',
189
- 'server_stats_saved_filters',
190
- ];
191
- const tables = [];
192
- for (const name of tableNames) {
260
+ return this.coalesce('storageStats', async () => {
261
+ let fileSizeMb = 0;
262
+ let walSizeMb = 0;
193
263
  try {
194
- const [row] = await this.db(name).count('* as count');
195
- tables.push({ name, rowCount: Number(row.count) });
264
+ const s = await fsStat(this.dbFilePath);
265
+ fileSizeMb = Math.round((s.size / (1024 * 1024)) * 100) / 100;
196
266
  }
197
267
  catch {
198
- tables.push({ name, rowCount: 0 });
268
+ // File may not exist yet
199
269
  }
200
- }
201
- return {
202
- ready: true,
203
- dbPath: this.config.dbPath,
204
- fileSizeMb,
205
- walSizeMb,
206
- retentionDays: this.config.retentionDays,
207
- tables,
208
- lastCleanupAt: this.lastCleanupAt,
209
- };
270
+ try {
271
+ const ws = await fsStat(this.dbFilePath + '-wal');
272
+ walSizeMb = Math.round((ws.size / (1024 * 1024)) * 100) / 100;
273
+ }
274
+ catch {
275
+ // WAL file may not exist
276
+ }
277
+ const tableNames = [
278
+ 'server_stats_requests',
279
+ 'server_stats_queries',
280
+ 'server_stats_events',
281
+ 'server_stats_emails',
282
+ 'server_stats_logs',
283
+ 'server_stats_traces',
284
+ 'server_stats_metrics',
285
+ 'server_stats_saved_filters',
286
+ ];
287
+ // Single transaction for all 8 COUNT queries — 1 pool acquire instead of 8
288
+ const tables = await this.db.transaction(async (trx) => {
289
+ const result = [];
290
+ for (const name of tableNames) {
291
+ try {
292
+ const [row] = await trx(name).count('* as count');
293
+ result.push({ name, rowCount: Number(row.count) });
294
+ }
295
+ catch {
296
+ result.push({ name, rowCount: 0 });
297
+ }
298
+ }
299
+ return result;
300
+ });
301
+ const stats = {
302
+ ready: true,
303
+ dbPath: this.config.dbPath,
304
+ fileSizeMb,
305
+ walSizeMb,
306
+ retentionDays: this.config.retentionDays,
307
+ tables,
308
+ lastCleanupAt: this.lastCleanupAt,
309
+ };
310
+ this.cachedStorageStats = { data: stats, cachedAt: Date.now() };
311
+ return stats;
312
+ });
210
313
  }
211
314
  // =========================================================================
212
- // Write methods — persist data from collectors
315
+ // Write methods — queued writes with batch flushing
213
316
  // =========================================================================
214
317
  /**
215
- * Record a completed request. Returns the inserted row ID, or null
216
- * if the request was self-excluded or an error occurred.
318
+ * Queue a full request (with queries, events, trace) for batch persistence.
319
+ * Returns null immediately actual IDs are assigned during flush.
320
+ *
321
+ * Writes are buffered and flushed every 500ms in a single transaction,
322
+ * which prevents the single-connection pool from being overwhelmed by
323
+ * individual fire-and-forget INSERT calls under load.
217
324
  */
218
- async recordRequest(input) {
325
+ persistRequest(input) {
219
326
  if (!this.db)
220
- return null;
327
+ return Promise.resolve(null);
221
328
  if (input.url.startsWith(this.dashboardPath))
222
- return null;
223
- try {
224
- const [id] = await this.db('server_stats_requests').insert({
225
- method: input.method,
226
- url: input.url,
227
- status_code: input.statusCode,
228
- duration: round(input.duration),
229
- span_count: input.spanCount ?? 0,
230
- warning_count: input.warningCount ?? 0,
231
- });
232
- return id;
233
- }
234
- catch (err) {
235
- const method_ = 'recordRequest';
236
- if (!warnedWritePaths.has(method_)) {
237
- warnedWritePaths.add(method_);
238
- log.warn(`dashboard: ${method_} failed — ${err?.message}`);
239
- }
240
- return null;
329
+ return Promise.resolve(null);
330
+ // Drop oldest entries if queue is too deep (backpressure)
331
+ if (this.writeQueue.length >= DashboardStore.MAX_QUEUE_SIZE) {
332
+ this.writeQueue.splice(0, Math.floor(DashboardStore.MAX_QUEUE_SIZE / 4));
241
333
  }
334
+ this.writeQueue.push(input);
335
+ this.scheduleFlush();
336
+ return Promise.resolve(null);
242
337
  }
243
- /** Batch-insert queries for a request. Filters out server_stats connection queries. */
244
- async recordQueries(requestId, queries) {
245
- if (!this.db || queries.length === 0)
338
+ /** Queue events to be attached to a request during flush. */
339
+ queueEvents(requestIndex, events) {
340
+ if (events.length === 0)
246
341
  return;
247
- const filtered = queries.filter((q) => q.connection !== 'server_stats');
248
- if (filtered.length === 0)
342
+ this.pendingEvents.push({ requestIndex, events });
343
+ }
344
+ /** Record a single log entry — queued for batch flush. */
345
+ recordLog(entry) {
346
+ if (!this.db)
249
347
  return;
250
- try {
251
- const rows = filtered.map((q) => ({
252
- request_id: requestId,
253
- sql_text: q.sql,
254
- sql_normalized: normalizeSql(q.sql),
255
- bindings: q.bindings ? JSON.stringify(q.bindings) : null,
256
- duration: round(q.duration),
257
- method: q.method,
258
- model: q.model,
259
- connection: q.connection,
260
- in_transaction: q.inTransaction ? 1 : 0,
261
- }));
262
- // SQLite variable limit: batch in chunks of 50
263
- for (let i = 0; i < rows.length; i += 50) {
264
- await this.db('server_stats_queries').insert(rows.slice(i, i + 50));
265
- }
266
- }
267
- catch (err) {
268
- const method = 'recordQueries';
269
- if (!warnedWritePaths.has(method)) {
270
- warnedWritePaths.add(method);
271
- log.warn(`dashboard: ${method} failed — ${err?.message}`);
272
- }
348
+ // Drop oldest if too many pending
349
+ if (this.pendingLogs.length >= DashboardStore.MAX_QUEUE_SIZE) {
350
+ this.pendingLogs.splice(0, Math.floor(DashboardStore.MAX_QUEUE_SIZE / 4));
273
351
  }
352
+ this.pendingLogs.push(entry);
353
+ this.scheduleFlush();
274
354
  }
275
- /** Batch-insert events for a request. */
276
- async recordEvents(requestId, events) {
277
- if (!this.db || events.length === 0)
355
+ /** Record a single email — queued for batch flush (avoids bypassing the write queue). */
356
+ recordEmail(record) {
357
+ if (!this.db)
278
358
  return;
279
- try {
280
- const rows = events.map((e) => ({
281
- request_id: requestId,
282
- event_name: e.event,
283
- data: e.data,
284
- }));
285
- for (let i = 0; i < rows.length; i += 50) {
286
- await this.db('server_stats_events').insert(rows.slice(i, i + 50));
287
- }
288
- }
289
- catch (err) {
290
- const method = 'recordEvents';
291
- if (!warnedWritePaths.has(method)) {
292
- warnedWritePaths.add(method);
293
- log.warn(`dashboard: ${method} failed — ${err?.message}`);
294
- }
359
+ if (this.pendingEmails.length >= DashboardStore.MAX_QUEUE_SIZE) {
360
+ this.pendingEmails.splice(0, Math.floor(DashboardStore.MAX_QUEUE_SIZE / 4));
295
361
  }
362
+ this.pendingEmails.push(record);
363
+ this.scheduleFlush();
296
364
  }
297
- /** Record a single email. */
298
- async recordEmail(record) {
299
- if (!this.db)
365
+ /** Schedule the next batch flush if not already scheduled. */
366
+ scheduleFlush() {
367
+ if (this.flushTimer)
300
368
  return;
301
- try {
302
- await this.db('server_stats_emails').insert({
303
- from_addr: record.from,
304
- to_addr: record.to,
305
- cc: record.cc,
306
- bcc: record.bcc,
307
- subject: record.subject,
308
- html: record.html,
309
- text_body: record.text,
310
- mailer: record.mailer,
311
- status: record.status,
312
- message_id: record.messageId,
313
- attachment_count: record.attachmentCount,
369
+ this.flushTimer = setTimeout(() => {
370
+ this.flushTimer = null;
371
+ this.flushWriteQueue().catch((err) => {
372
+ if (!warnedWritePaths.has('flush')) {
373
+ warnedWritePaths.add('flush');
374
+ log.warn(`dashboard: flush failed — ${err?.message}`);
375
+ }
314
376
  });
315
- }
316
- catch (err) {
317
- const method = 'recordEmail';
318
- if (!warnedWritePaths.has(method)) {
319
- warnedWritePaths.add(method);
320
- log.warn(`dashboard: ${method} failed — ${err?.message}`);
321
- }
322
- }
377
+ }, DashboardStore.FLUSH_INTERVAL_MS);
323
378
  }
324
- /** Record a single log entry (from LogStreamService). */
325
- async recordLog(entry) {
326
- if (!this.db)
379
+ /**
380
+ * Flush all pending writes in a single transaction.
381
+ *
382
+ * A transaction acquires the pool connection ONCE, runs all INSERTs
383
+ * (synchronous via better-sqlite3), then releases. Without a
384
+ * transaction, each INSERT does its own async acquire/release cycle —
385
+ * under load this creates hundreds of microtasks that starve the
386
+ * event loop and freeze the server.
387
+ */
388
+ async flushWriteQueue() {
389
+ if (this.flushing || !this.db)
327
390
  return;
328
- try {
391
+ this.flushing = true;
392
+ // Snapshot and clear the queues
393
+ const requests = this.writeQueue.splice(0);
394
+ const logs = this.pendingLogs.splice(0);
395
+ const events = this.pendingEvents.splice(0);
396
+ const emails = this.pendingEmails.splice(0);
397
+ if (requests.length === 0 && logs.length === 0 && events.length === 0 && emails.length === 0) {
398
+ this.flushing = false;
399
+ return;
400
+ }
401
+ // Pre-stringify JSON OUTSIDE the transaction so the synchronous
402
+ // better-sqlite3 execution doesn't block the event loop on large spans.
403
+ const preparedRequests = requests.map((input) => ({
404
+ input,
405
+ filteredQueries: input.queries
406
+ .filter((q) => q.connection !== 'server_stats')
407
+ .map((q) => ({
408
+ sql_text: q.sql,
409
+ sql_normalized: normalizeSql(q.sql),
410
+ bindings: q.bindings ? JSON.stringify(q.bindings) : null,
411
+ duration: round(q.duration),
412
+ method: q.method,
413
+ model: q.model,
414
+ connection: q.connection,
415
+ in_transaction: q.inTransaction ? 1 : 0,
416
+ })),
417
+ traceRow: input.trace
418
+ ? {
419
+ method: input.trace.method,
420
+ url: input.trace.url,
421
+ status_code: input.trace.statusCode,
422
+ total_duration: round(input.trace.totalDuration),
423
+ span_count: input.trace.spanCount,
424
+ spans: JSON.stringify(input.trace.spans),
425
+ warnings: input.trace.warnings.length > 0 ? JSON.stringify(input.trace.warnings) : null,
426
+ }
427
+ : null,
428
+ }));
429
+ const preparedLogs = logs.map((entry) => {
329
430
  const levelName = typeof entry.levelName === 'string' ? entry.levelName : String(entry.level || 'unknown');
330
- await this.db('server_stats_logs').insert({
431
+ return {
331
432
  level: levelName,
332
433
  message: String(entry.msg || entry.message || ''),
333
434
  request_id: entry.request_id || entry.requestId || entry['x-request-id']
334
435
  ? String(entry.request_id || entry.requestId || entry['x-request-id'])
335
436
  : null,
336
437
  data: JSON.stringify(entry),
337
- });
338
- }
339
- catch (err) {
340
- const method = 'recordLog';
341
- if (!warnedWritePaths.has(method)) {
342
- warnedWritePaths.add(method);
343
- log.warn(`dashboard: ${method} failed — ${err?.message}`);
344
- }
345
- }
346
- }
347
- /** Record a trace for a request. */
348
- async recordTrace(requestId, trace) {
349
- if (!this.db)
350
- return;
438
+ };
439
+ });
351
440
  try {
352
- await this.db('server_stats_traces').insert({
353
- request_id: requestId,
354
- method: trace.method,
355
- url: trace.url,
356
- status_code: trace.statusCode,
357
- total_duration: round(trace.totalDuration),
358
- span_count: trace.spanCount,
359
- spans: JSON.stringify(trace.spans),
360
- warnings: trace.warnings.length > 0 ? JSON.stringify(trace.warnings) : null,
441
+ await this.db.transaction(async (trx) => {
442
+ // -- Requests + queries + traces --
443
+ for (const { input, filteredQueries, traceRow } of preparedRequests) {
444
+ try {
445
+ const [requestId] = await trx('server_stats_requests').insert({
446
+ method: input.method,
447
+ url: input.url,
448
+ status_code: input.statusCode,
449
+ duration: round(input.duration),
450
+ span_count: input.trace?.spanCount ?? 0,
451
+ warning_count: input.trace?.warnings?.length ?? 0,
452
+ });
453
+ if (requestId !== null && requestId !== undefined && filteredQueries.length > 0) {
454
+ const rows = filteredQueries.map((q) => ({ ...q, request_id: requestId }));
455
+ for (let i = 0; i < rows.length; i += 50) {
456
+ await trx('server_stats_queries').insert(rows.slice(i, i + 50));
457
+ }
458
+ }
459
+ if (requestId !== null && requestId !== undefined && traceRow) {
460
+ await trx('server_stats_traces').insert({ ...traceRow, request_id: requestId });
461
+ }
462
+ }
463
+ catch (err) {
464
+ if (!warnedWritePaths.has('persistRequest')) {
465
+ warnedWritePaths.add('persistRequest');
466
+ log.warn(`dashboard: persistRequest failed — ${err?.message}`);
467
+ }
468
+ }
469
+ }
470
+ // -- Events --
471
+ for (const { events: evts } of events) {
472
+ try {
473
+ const rows = evts.map((e) => ({
474
+ request_id: null,
475
+ event_name: e.event,
476
+ data: e.data,
477
+ }));
478
+ for (let i = 0; i < rows.length; i += 50) {
479
+ await trx('server_stats_events').insert(rows.slice(i, i + 50));
480
+ }
481
+ }
482
+ catch (err) {
483
+ if (!warnedWritePaths.has('recordEvents')) {
484
+ warnedWritePaths.add('recordEvents');
485
+ log.warn(`dashboard: recordEvents failed — ${err?.message}`);
486
+ }
487
+ }
488
+ }
489
+ // -- Emails --
490
+ if (emails.length > 0) {
491
+ try {
492
+ const rows = emails.map((record) => ({
493
+ from_addr: record.from,
494
+ to_addr: record.to,
495
+ cc: record.cc,
496
+ bcc: record.bcc,
497
+ subject: record.subject,
498
+ html: record.html,
499
+ text_body: record.text,
500
+ mailer: record.mailer,
501
+ status: record.status,
502
+ message_id: record.messageId,
503
+ attachment_count: record.attachmentCount,
504
+ }));
505
+ for (let i = 0; i < rows.length; i += 50) {
506
+ await trx('server_stats_emails').insert(rows.slice(i, i + 50));
507
+ }
508
+ }
509
+ catch (err) {
510
+ if (!warnedWritePaths.has('recordEmail')) {
511
+ warnedWritePaths.add('recordEmail');
512
+ log.warn(`dashboard: recordEmail failed — ${err?.message}`);
513
+ }
514
+ }
515
+ }
516
+ // -- Logs --
517
+ if (preparedLogs.length > 0) {
518
+ try {
519
+ for (let i = 0; i < preparedLogs.length; i += 50) {
520
+ await trx('server_stats_logs').insert(preparedLogs.slice(i, i + 50));
521
+ }
522
+ }
523
+ catch (err) {
524
+ if (!warnedWritePaths.has('recordLog')) {
525
+ warnedWritePaths.add('recordLog');
526
+ log.warn(`dashboard: recordLog failed — ${err?.message}`);
527
+ }
528
+ }
529
+ }
361
530
  });
362
531
  }
363
532
  catch (err) {
364
- const method = 'recordTrace';
365
- if (!warnedWritePaths.has(method)) {
366
- warnedWritePaths.add(method);
367
- log.warn(`dashboard: ${method} failed — ${err?.message}`);
533
+ if (!warnedWritePaths.has('flush')) {
534
+ warnedWritePaths.add('flush');
535
+ log.warn(`dashboard: flush transaction failed — ${err?.message}`);
368
536
  }
369
537
  }
370
- }
371
- /**
372
- * Convenience: persist a full request with associated queries and trace.
373
- * Calls recordRequest, recordQueries, and recordTrace in sequence.
374
- */
375
- async persistRequest(input) {
376
- const requestId = await this.recordRequest({
377
- method: input.method,
378
- url: input.url,
379
- statusCode: input.statusCode,
380
- duration: input.duration,
381
- spanCount: input.trace?.spanCount ?? 0,
382
- warningCount: input.trace?.warnings?.length ?? 0,
383
- });
384
- if (requestId === null)
385
- return null;
386
- await this.recordQueries(requestId, input.queries);
387
- if (input.trace) {
388
- await this.recordTrace(requestId, input.trace);
538
+ finally {
539
+ this.flushing = false;
540
+ }
541
+ // Yield to the event loop after the transaction so HTTP requests
542
+ // and timers get a chance to run between flush cycles.
543
+ await new Promise((resolve) => setImmediate(resolve));
544
+ // If more data arrived during flush, schedule another
545
+ if (this.writeQueue.length > 0 ||
546
+ this.pendingLogs.length > 0 ||
547
+ this.pendingEmails.length > 0) {
548
+ this.scheduleFlush();
389
549
  }
390
- return requestId;
391
550
  }
392
551
  // =========================================================================
393
552
  // Read methods — query data for dashboard API
394
553
  // =========================================================================
395
554
  /** Paginated request history with optional filters. */
396
555
  async getRequests(page = 1, perPage = 50, filters) {
556
+ const fk = filters ? JSON.stringify(filters) : '';
397
557
  return this.paginate('server_stats_requests', page, perPage, (query) => {
398
558
  if (filters?.method)
399
559
  query.where('method', filters.method);
@@ -415,10 +575,11 @@ export class DashboardStore {
415
575
  qb.where('url', 'like', term).orWhere('method', 'like', term);
416
576
  });
417
577
  }
418
- });
578
+ }, fk);
419
579
  }
420
580
  /** Paginated query history with optional filters. */
421
581
  async getQueries(page = 1, perPage = 50, filters) {
582
+ const fk = filters ? JSON.stringify(filters) : '';
422
583
  return this.paginate('server_stats_queries', page, perPage, (query) => {
423
584
  if (filters?.method)
424
585
  query.where('method', filters.method);
@@ -440,7 +601,7 @@ export class DashboardStore {
440
601
  .orWhere('connection', 'like', term);
441
602
  });
442
603
  }
443
- });
604
+ }, fk);
444
605
  }
445
606
  /**
446
607
  * Grouped query patterns: aggregated by sql_normalized
@@ -449,34 +610,41 @@ export class DashboardStore {
449
610
  async getQueriesGrouped(limit = 200, sort = 'total_duration', search) {
450
611
  if (!this.db)
451
612
  return [];
452
- const validSorts = {
453
- count: 'count',
454
- avg_duration: 'avg_duration',
455
- total_duration: 'total_duration',
456
- };
457
- const orderCol = validSorts[sort] || 'total_duration';
458
- const query = this.db('server_stats_queries')
459
- .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'))
460
- .groupBy('sql_normalized')
461
- .orderBy(orderCol, 'desc')
462
- .limit(limit);
463
- if (search) {
464
- query.where('sql_normalized', 'like', `%${search}%`);
465
- }
466
- return query;
613
+ return this.cached('queriesGrouped:' + limit + ':' + sort + ':' + (search || ''), DashboardStore.QUERIES_GROUPED_CACHE_TTL_MS, async () => {
614
+ const validSorts = {
615
+ count: 'count',
616
+ avg_duration: 'avg_duration',
617
+ total_duration: 'total_duration',
618
+ };
619
+ const orderCol = validSorts[sort] || 'total_duration';
620
+ // Apply a time cutoff to avoid scanning the entire table
621
+ const cutoff = rangeToCutoff('7d');
622
+ const query = this.db('server_stats_queries')
623
+ .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'))
624
+ .where('created_at', '>=', cutoff)
625
+ .groupBy('sql_normalized')
626
+ .orderBy(orderCol, 'desc')
627
+ .limit(limit);
628
+ if (search) {
629
+ query.where('sql_normalized', 'like', `%${search}%`);
630
+ }
631
+ return query;
632
+ });
467
633
  }
468
634
  /** Paginated event history with optional filters. */
469
635
  async getEvents(page = 1, perPage = 50, filters) {
636
+ const fk = filters ? JSON.stringify(filters) : '';
470
637
  return this.paginate('server_stats_events', page, perPage, (query) => {
471
638
  if (filters?.eventName)
472
639
  query.where('event_name', 'like', `%${filters.eventName}%`);
473
640
  if (filters?.search) {
474
641
  query.where('event_name', 'like', `%${filters.search}%`);
475
642
  }
476
- });
643
+ }, fk);
477
644
  }
478
645
  /** Paginated email history with optional filters. */
479
646
  async getEmails(page = 1, perPage = 50, filters, excludeBody = false) {
647
+ const fk = (filters ? JSON.stringify(filters) : '') + (excludeBody ? ':noBody' : '');
480
648
  return this.paginate('server_stats_emails', page, perPage, (query) => {
481
649
  if (filters?.search) {
482
650
  const term = `%${filters.search}%`;
@@ -500,19 +668,21 @@ export class DashboardStore {
500
668
  if (excludeBody) {
501
669
  query.select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at');
502
670
  }
503
- });
671
+ }, fk);
504
672
  }
505
673
  /** Get email HTML body for preview (falls back to text_body). */
506
674
  async getEmailHtml(id) {
507
675
  if (!this.db)
508
676
  return null;
509
- const row = await this.db('server_stats_emails')
510
- .where('id', id)
511
- .select('html', 'text_body')
512
- .first();
513
- if (!row)
514
- return null;
515
- return row.html || row.text_body || null;
677
+ return this.coalesce('emailHtml:' + id, async () => {
678
+ const row = await this.db('server_stats_emails')
679
+ .where('id', id)
680
+ .select('html', 'text_body')
681
+ .first();
682
+ if (!row)
683
+ return null;
684
+ return row.html || row.text_body || null;
685
+ });
516
686
  }
517
687
  /**
518
688
  * Paginated log history with structured search support.
@@ -521,6 +691,7 @@ export class DashboardStore {
521
691
  * SQLite's `json_extract()`.
522
692
  */
523
693
  async getLogs(page = 1, perPage = 50, filters) {
694
+ const fk = filters ? JSON.stringify(filters) : '';
524
695
  return this.paginate('server_stats_logs', page, perPage, (query) => {
525
696
  if (filters?.level)
526
697
  query.where('level', filters.level);
@@ -545,10 +716,11 @@ export class DashboardStore {
545
716
  }
546
717
  }
547
718
  }
548
- });
719
+ }, fk);
549
720
  }
550
721
  /** Paginated trace history with optional filters. */
551
722
  async getTraces(page = 1, perPage = 50, filters) {
723
+ const fk = filters ? JSON.stringify(filters) : '';
552
724
  return this.paginate('server_stats_traces', page, perPage, (query) => {
553
725
  if (filters?.method)
554
726
  query.where('method', filters.method);
@@ -564,45 +736,56 @@ export class DashboardStore {
564
736
  qb.where('url', 'like', term).orWhere('method', 'like', term);
565
737
  });
566
738
  }
567
- });
739
+ }, fk);
568
740
  }
569
741
  /** Single trace with full span data. */
570
742
  async getTraceDetail(id) {
571
743
  if (!this.db)
572
744
  return null;
573
- const row = await this.db('server_stats_traces').where('id', id).first();
574
- if (!row)
575
- return null;
576
- return {
577
- ...row,
578
- spans: safeParseJson(row.spans) ?? [],
579
- warnings: safeParseJsonArray(row.warnings),
580
- };
745
+ return this.coalesce('traceDetail:' + id, async () => {
746
+ const row = await this.db('server_stats_traces').where('id', id).first();
747
+ if (!row)
748
+ return null;
749
+ return {
750
+ ...row,
751
+ spans: safeParseJson(row.spans) ?? [],
752
+ warnings: safeParseJsonArray(row.warnings),
753
+ };
754
+ });
581
755
  }
582
- /** Single request with associated queries, events, and trace. */
756
+ /**
757
+ * Single request with associated queries, events, and trace.
758
+ * Wrapped in a transaction — 1 pool acquire instead of 4.
759
+ */
583
760
  async getRequestDetail(id) {
584
761
  if (!this.db)
585
762
  return null;
586
- const request = await this.db('server_stats_requests').where('id', id).first();
587
- if (!request)
588
- return null;
589
- const [queries, events, trace] = await Promise.all([
590
- this.db('server_stats_queries').where('request_id', id).orderBy('created_at', 'asc'),
591
- this.db('server_stats_events').where('request_id', id).orderBy('created_at', 'asc'),
592
- this.db('server_stats_traces').where('request_id', id).first(),
593
- ]);
594
- return {
595
- ...request,
596
- queries,
597
- events,
598
- trace: trace
599
- ? {
600
- ...trace,
601
- spans: safeParseJson(trace.spans) ?? [],
602
- warnings: safeParseJsonArray(trace.warnings),
603
- }
604
- : null,
605
- };
763
+ return this.coalesce('requestDetail:' + id, async () => {
764
+ return this.db.transaction(async (trx) => {
765
+ const request = await trx('server_stats_requests').where('id', id).first();
766
+ if (!request)
767
+ return null;
768
+ const queries = await trx('server_stats_queries')
769
+ .where('request_id', id)
770
+ .orderBy('created_at', 'asc');
771
+ const events = await trx('server_stats_events')
772
+ .where('request_id', id)
773
+ .orderBy('created_at', 'asc');
774
+ const trace = await trx('server_stats_traces').where('request_id', id).first();
775
+ return {
776
+ ...request,
777
+ queries,
778
+ events,
779
+ trace: trace
780
+ ? {
781
+ ...trace,
782
+ spans: safeParseJson(trace.spans) ?? [],
783
+ warnings: safeParseJsonArray(trace.warnings),
784
+ }
785
+ : null,
786
+ };
787
+ });
788
+ });
606
789
  }
607
790
  // =========================================================================
608
791
  // Overview & Charts
@@ -612,74 +795,85 @@ export class DashboardStore {
612
795
  *
613
796
  * @param range — '1h' | '6h' | '24h' | '7d'
614
797
  */
798
+ /**
799
+ * Wrapped in a single transaction — 1 pool acquire instead of 5.
800
+ */
615
801
  async getOverviewMetrics(range = '1h') {
616
802
  if (!this.db)
617
803
  return null;
618
- const cutoff = rangeToCutoff(range);
619
- // Recent requests for calculations
620
- const requests = await this.db('server_stats_requests')
621
- .where('created_at', '>=', cutoff)
622
- .select('duration', 'status_code', 'url', 'created_at');
623
- const total = requests.length;
624
- if (total === 0) {
625
- return {
626
- avgResponseTime: 0,
627
- p95ResponseTime: 0,
628
- requestsPerMinute: 0,
629
- errorRate: 0,
630
- totalRequests: 0,
631
- slowestEndpoints: [],
632
- queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
633
- recentErrors: [],
634
- };
635
- }
636
- const durations = requests.map((r) => r.duration).sort((a, b) => a - b);
637
- const avgResponseTime = durations.reduce((s, d) => s + d, 0) / total;
638
- const p95Index = Math.floor(total * 0.95);
639
- const p95ResponseTime = durations[Math.min(p95Index, total - 1)];
640
- const errorCount = requests.filter((r) => r.status_code >= 400).length;
641
- const rangeMinutes = rangeToMinutes(range);
642
- const requestsPerMin = total / rangeMinutes;
643
- // Slowest endpoints (top 5 by average duration)
644
- const slowestEndpoints = await this.db('server_stats_requests')
645
- .where('created_at', '>=', cutoff)
646
- .select('url', this.db.raw('COUNT(*) as count'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
647
- .groupBy('url')
648
- .orderBy('avg_duration', 'desc')
649
- .limit(5);
650
- // Query stats
651
- const queryStats = await this.db('server_stats_queries')
652
- .where('created_at', '>=', cutoff)
653
- .select(this.db.raw('COUNT(*) as total'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
654
- .first();
655
- // Recent errors (last 5 log entries with level error/fatal)
656
- const recentErrors = await this.db('server_stats_logs')
657
- .where('created_at', '>=', cutoff)
658
- .whereIn('level', ['error', 'fatal'])
659
- .orderBy('created_at', 'desc')
660
- .limit(5);
661
- return {
662
- avgResponseTime: round(avgResponseTime),
663
- p95ResponseTime: round(p95ResponseTime),
664
- requestsPerMinute: round(requestsPerMin),
665
- errorRate: round((errorCount / total) * 100),
666
- totalRequests: total,
667
- slowestEndpoints: slowestEndpoints.map((s) => ({
668
- url: s.url,
669
- count: s.count,
670
- avgDuration: s.avg_duration,
671
- })),
672
- queryStats: {
673
- total: queryStats?.total ?? 0,
674
- avgDuration: queryStats?.avg_duration ?? 0,
675
- perRequest: total > 0 ? round((queryStats?.total ?? 0) / total) : 0,
676
- },
677
- recentErrors: recentErrors.map((e) => ({
678
- id: e.id,
679
- message: e.message,
680
- createdAt: e.created_at,
681
- })),
682
- };
804
+ return this.cached('overviewMetrics:' + range, 2_000, async () => {
805
+ const cutoff = rangeToCutoff(range);
806
+ const result = await this.db.transaction(async (trx) => {
807
+ const stats = await trx('server_stats_requests')
808
+ .where('created_at', '>=', cutoff)
809
+ .select(trx.raw('COUNT(*) as total'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'), trx.raw('SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count'))
810
+ .first();
811
+ const total = Number(stats?.total ?? 0);
812
+ if (total === 0) {
813
+ return {
814
+ avgResponseTime: 0,
815
+ p95ResponseTime: 0,
816
+ requestsPerMinute: 0,
817
+ errorRate: 0,
818
+ totalRequests: 0,
819
+ slowestEndpoints: [],
820
+ queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
821
+ recentErrors: [],
822
+ };
823
+ }
824
+ const avgResponseTime = stats?.avg_duration;
825
+ const errorCount = Number(stats?.error_count ?? 0);
826
+ const rangeMinutes = rangeToMinutes(range);
827
+ const requestsPerMin = total / rangeMinutes;
828
+ const p95Offset = Math.floor(total * 0.95);
829
+ const p95Row = await trx('server_stats_requests')
830
+ .where('created_at', '>=', cutoff)
831
+ .orderBy('duration', 'asc')
832
+ .offset(Math.min(p95Offset, total - 1))
833
+ .limit(1)
834
+ .select('duration')
835
+ .first();
836
+ const p95ResponseTime = p95Row?.duration ?? 0;
837
+ const slowestEndpoints = await trx('server_stats_requests')
838
+ .where('created_at', '>=', cutoff)
839
+ .select('url', trx.raw('COUNT(*) as count'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
840
+ .groupBy('url')
841
+ .orderBy('avg_duration', 'desc')
842
+ .limit(5);
843
+ const queryStats = await trx('server_stats_queries')
844
+ .where('created_at', '>=', cutoff)
845
+ .select(trx.raw('COUNT(*) as total'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
846
+ .first();
847
+ const recentErrors = await trx('server_stats_logs')
848
+ .where('created_at', '>=', cutoff)
849
+ .whereIn('level', ['error', 'fatal'])
850
+ .orderBy('created_at', 'desc')
851
+ .limit(5);
852
+ return {
853
+ avgResponseTime: round(avgResponseTime),
854
+ p95ResponseTime: round(p95ResponseTime),
855
+ requestsPerMinute: round(requestsPerMin),
856
+ errorRate: round((errorCount / total) * 100),
857
+ totalRequests: total,
858
+ slowestEndpoints: slowestEndpoints.map((s) => ({
859
+ url: s.url,
860
+ count: s.count,
861
+ avgDuration: s.avg_duration,
862
+ })),
863
+ queryStats: {
864
+ total: queryStats?.total ?? 0,
865
+ avgDuration: queryStats?.avg_duration ?? 0,
866
+ perRequest: total > 0 ? round((queryStats?.total ?? 0) / total) : 0,
867
+ },
868
+ recentErrors: recentErrors.map((e) => ({
869
+ id: e.id,
870
+ message: e.message,
871
+ createdAt: e.created_at,
872
+ })),
873
+ };
874
+ });
875
+ return result;
876
+ });
683
877
  }
684
878
  /**
685
879
  * Time-series chart data from server_stats_metrics.
@@ -689,50 +883,52 @@ export class DashboardStore {
689
883
  async getChartData(range = '1h') {
690
884
  if (!this.db)
691
885
  return [];
692
- const cutoff = rangeToCutoff(range);
693
- // For 1h/6h, use the per-minute metrics table.
694
- // For 24h/7d, aggregate metrics into larger buckets.
695
- const rows = await this.db('server_stats_metrics')
696
- .where('bucket', '>=', cutoff)
697
- .orderBy('bucket', 'asc');
698
- if (range === '1h' || range === '6h') {
699
- return rows;
700
- }
701
- // For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
702
- const bucketMinutes = range === '7d' ? 60 : 15;
703
- const grouped = new Map();
704
- for (const row of rows) {
705
- const bucketKey = roundBucket(row.bucket, bucketMinutes);
706
- if (!grouped.has(bucketKey)) {
707
- grouped.set(bucketKey, {
708
- bucket: bucketKey,
709
- request_count: 0,
710
- avg_duration: 0,
711
- p95_duration: 0,
712
- error_count: 0,
713
- query_count: 0,
714
- avg_query_duration: 0,
715
- _count: 0,
716
- });
886
+ return this.cached('chartData:' + range, DashboardStore.CHART_CACHE_TTL_MS, async () => {
887
+ const cutoff = rangeToCutoff(range);
888
+ // For 1h/6h, use the per-minute metrics table.
889
+ // For 24h/7d, aggregate metrics into larger buckets.
890
+ const rows = await this.db('server_stats_metrics')
891
+ .where('bucket', '>=', cutoff)
892
+ .orderBy('bucket', 'asc');
893
+ if (range === '1h' || range === '6h') {
894
+ return rows;
717
895
  }
718
- const g = grouped.get(bucketKey);
719
- g.request_count += row.request_count;
720
- g.error_count += row.error_count;
721
- g.query_count += row.query_count;
722
- g.avg_duration += row.avg_duration;
723
- g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
724
- g.avg_query_duration += row.avg_query_duration;
725
- g._count++;
726
- }
727
- return Array.from(grouped.values()).map((g) => ({
728
- bucket: g.bucket,
729
- request_count: g.request_count,
730
- avg_duration: g._count > 0 ? round(g.avg_duration / g._count) : 0,
731
- p95_duration: round(g.p95_duration),
732
- error_count: g.error_count,
733
- query_count: g.query_count,
734
- avg_query_duration: g._count > 0 ? round(g.avg_query_duration / g._count) : 0,
735
- }));
896
+ // For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
897
+ const bucketMinutes = range === '7d' ? 60 : 15;
898
+ const grouped = new Map();
899
+ for (const row of rows) {
900
+ const bucketKey = roundBucket(row.bucket, bucketMinutes);
901
+ if (!grouped.has(bucketKey)) {
902
+ grouped.set(bucketKey, {
903
+ bucket: bucketKey,
904
+ request_count: 0,
905
+ avg_duration: 0,
906
+ p95_duration: 0,
907
+ error_count: 0,
908
+ query_count: 0,
909
+ avg_query_duration: 0,
910
+ _count: 0,
911
+ });
912
+ }
913
+ const g = grouped.get(bucketKey);
914
+ g.request_count += row.request_count;
915
+ g.error_count += row.error_count;
916
+ g.query_count += row.query_count;
917
+ g.avg_duration += row.avg_duration;
918
+ g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
919
+ g.avg_query_duration += row.avg_query_duration;
920
+ g._count++;
921
+ }
922
+ return Array.from(grouped.values()).map((g) => ({
923
+ bucket: g.bucket,
924
+ request_count: g.request_count,
925
+ avg_duration: g._count > 0 ? round(g.avg_duration / g._count) : 0,
926
+ p95_duration: round(g.p95_duration),
927
+ error_count: g.error_count,
928
+ query_count: g.query_count,
929
+ avg_query_duration: g._count > 0 ? round(g.avg_query_duration / g._count) : 0,
930
+ }));
931
+ });
736
932
  }
737
933
  /**
738
934
  * Widget data for the dashboard overview.
@@ -749,96 +945,96 @@ export class DashboardStore {
749
945
  };
750
946
  if (!this.db)
751
947
  return empty;
752
- const cutoff = rangeToCutoff(range);
753
- try {
754
- const [topEventsRaw, emailStatusRaw, logLevelsRaw, statusRaw, slowQueriesRaw] = await Promise.all([
755
- // Top 5 events by count
756
- this.db('server_stats_events')
757
- .select('event_name', this.db.raw('COUNT(*) as count'))
758
- .where('created_at', '>=', cutoff)
759
- .groupBy('event_name')
760
- .orderBy('count', 'desc')
761
- .limit(5),
762
- // Email activity by status
763
- this.db('server_stats_emails')
764
- .select('status', this.db.raw('COUNT(*) as count'))
765
- .where('created_at', '>=', cutoff)
766
- .groupBy('status'),
767
- // Log level breakdown
768
- this.db('server_stats_logs')
769
- .select('level', this.db.raw('COUNT(*) as count'))
770
- .where('created_at', '>=', cutoff)
771
- .groupBy('level'),
772
- // Status code distribution bucketed into 2xx/3xx/4xx/5xx
773
- this.db('server_stats_requests')
774
- .select(this.db.raw(`SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as "s2xx"`), this.db.raw(`SUM(CASE WHEN status_code >= 300 AND status_code < 400 THEN 1 ELSE 0 END) as "s3xx"`), this.db.raw(`SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) as "s4xx"`), this.db.raw(`SUM(CASE WHEN status_code >= 500 AND status_code < 600 THEN 1 ELSE 0 END) as "s5xx"`))
775
- .where('created_at', '>=', cutoff)
776
- .first(),
777
- // Slowest queries by avg duration (top 5)
778
- this.db('server_stats_queries')
779
- .select('sql_normalized', this.db.raw('ROUND(AVG(duration), 2) as avg_duration'), this.db.raw('COUNT(*) as count'))
780
- .where('created_at', '>=', cutoff)
781
- .groupBy('sql_normalized')
782
- .orderBy('avg_duration', 'desc')
783
- .limit(5),
784
- ]);
785
- // Map top events
786
- const topEvents = (topEventsRaw || []).map((r) => ({
787
- eventName: r.event_name,
788
- count: r.count,
789
- }));
790
- // Map email activity
791
- const emailActivity = { sent: 0, queued: 0, failed: 0 };
792
- for (const row of emailStatusRaw || []) {
793
- const status = row.status;
794
- const count = row.count;
795
- if (status === 'sent')
796
- emailActivity.sent = count;
797
- else if (status === 'queued')
798
- emailActivity.queued = count;
799
- else if (status === 'failed')
800
- emailActivity.failed = count;
801
- }
802
- // Map log level breakdown
803
- const logLevelBreakdown = { error: 0, warn: 0, info: 0, debug: 0 };
804
- for (const row of logLevelsRaw || []) {
805
- const level = row.level;
806
- if (level in logLevelBreakdown) {
807
- logLevelBreakdown[level] = row.count;
948
+ return this.cached('overviewWidgets:' + range, DashboardStore.WIDGETS_CACHE_TTL_MS, async () => {
949
+ const cutoff = rangeToCutoff(range);
950
+ try {
951
+ // Single transaction 1 pool acquire instead of 5.
952
+ const { topEventsRaw, emailStatusRaw, logLevelsRaw, statusRaw, slowQueriesRaw } = await this.db.transaction(async (trx) => ({
953
+ topEventsRaw: await trx('server_stats_events')
954
+ .select('event_name', trx.raw('COUNT(*) as count'))
955
+ .where('created_at', '>=', cutoff)
956
+ .groupBy('event_name')
957
+ .orderBy('count', 'desc')
958
+ .limit(5),
959
+ emailStatusRaw: await trx('server_stats_emails')
960
+ .select('status', trx.raw('COUNT(*) as count'))
961
+ .where('created_at', '>=', cutoff)
962
+ .groupBy('status'),
963
+ logLevelsRaw: await trx('server_stats_logs')
964
+ .select('level', trx.raw('COUNT(*) as count'))
965
+ .where('created_at', '>=', cutoff)
966
+ .groupBy('level'),
967
+ statusRaw: await trx('server_stats_requests')
968
+ .select(trx.raw(`SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as "s2xx"`), trx.raw(`SUM(CASE WHEN status_code >= 300 AND status_code < 400 THEN 1 ELSE 0 END) as "s3xx"`), trx.raw(`SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) as "s4xx"`), trx.raw(`SUM(CASE WHEN status_code >= 500 AND status_code < 600 THEN 1 ELSE 0 END) as "s5xx"`))
969
+ .where('created_at', '>=', cutoff)
970
+ .first(),
971
+ slowQueriesRaw: await trx('server_stats_queries')
972
+ .select('sql_normalized', trx.raw('ROUND(AVG(duration), 2) as avg_duration'), trx.raw('COUNT(*) as count'))
973
+ .where('created_at', '>=', cutoff)
974
+ .groupBy('sql_normalized')
975
+ .orderBy('avg_duration', 'desc')
976
+ .limit(5),
977
+ }));
978
+ // Map top events
979
+ const topEvents = (topEventsRaw || []).map((r) => ({
980
+ eventName: r.event_name,
981
+ count: r.count,
982
+ }));
983
+ // Map email activity
984
+ const emailActivity = { sent: 0, queued: 0, failed: 0 };
985
+ for (const row of emailStatusRaw || []) {
986
+ const status = row.status;
987
+ const count = row.count;
988
+ if (status === 'sent')
989
+ emailActivity.sent = count;
990
+ else if (status === 'queued')
991
+ emailActivity.queued = count;
992
+ else if (status === 'failed')
993
+ emailActivity.failed = count;
994
+ }
995
+ // Map log level breakdown
996
+ const logLevelBreakdown = { error: 0, warn: 0, info: 0, debug: 0 };
997
+ for (const row of logLevelsRaw || []) {
998
+ const level = row.level;
999
+ if (level in logLevelBreakdown) {
1000
+ logLevelBreakdown[level] = row.count;
1001
+ }
808
1002
  }
1003
+ // Map status distribution
1004
+ const statusDistribution = {
1005
+ '2xx': statusRaw?.s2xx ?? 0,
1006
+ '3xx': statusRaw?.s3xx ?? 0,
1007
+ '4xx': statusRaw?.s4xx ?? 0,
1008
+ '5xx': statusRaw?.s5xx ?? 0,
1009
+ };
1010
+ // Map slowest queries
1011
+ const slowestQueries = (slowQueriesRaw || []).map((r) => ({
1012
+ sqlNormalized: r.sql_normalized,
1013
+ avgDuration: r.avg_duration,
1014
+ count: r.count,
1015
+ }));
1016
+ return { topEvents, emailActivity, logLevelBreakdown, statusDistribution, slowestQueries };
809
1017
  }
810
- // Map status distribution
811
- const statusDistribution = {
812
- '2xx': statusRaw?.s2xx ?? 0,
813
- '3xx': statusRaw?.s3xx ?? 0,
814
- '4xx': statusRaw?.s4xx ?? 0,
815
- '5xx': statusRaw?.s5xx ?? 0,
816
- };
817
- // Map slowest queries
818
- const slowestQueries = (slowQueriesRaw || []).map((r) => ({
819
- sqlNormalized: r.sql_normalized,
820
- avgDuration: r.avg_duration,
821
- count: r.count,
822
- }));
823
- return { topEvents, emailActivity, logLevelBreakdown, statusDistribution, slowestQueries };
824
- }
825
- catch (err) {
826
- if (!overviewWidgetWarned) {
827
- overviewWidgetWarned = true;
828
- log.warn('dashboard: getOverviewWidgets query failed — ' + err?.message);
1018
+ catch (err) {
1019
+ if (!overviewWidgetWarned) {
1020
+ overviewWidgetWarned = true;
1021
+ log.warn('dashboard: getOverviewWidgets query failed — ' + err?.message);
1022
+ }
1023
+ return empty;
829
1024
  }
830
- return empty;
831
- }
1025
+ });
832
1026
  }
833
1027
  /** Get sparkline data points from pre-aggregated metrics. */
834
1028
  async getSparklineData(range) {
835
1029
  if (!this.db)
836
1030
  return [];
837
- const cutoff = rangeToCutoff(range);
838
- const metrics = await this.db('server_stats_metrics')
839
- .where('created_at', '>=', cutoff)
840
- .orderBy('bucket', 'asc');
841
- return metrics.slice(-15);
1031
+ return this.cached('sparkline:' + range, DashboardStore.SPARKLINE_CACHE_TTL_MS, async () => {
1032
+ const cutoff = rangeToCutoff(range);
1033
+ const metrics = await this.db('server_stats_metrics')
1034
+ .where('bucket', '>=', cutoff)
1035
+ .orderBy('bucket', 'asc');
1036
+ return metrics.slice(-15);
1037
+ });
842
1038
  }
843
1039
  // =========================================================================
844
1040
  // Saved filters CRUD
@@ -846,10 +1042,12 @@ export class DashboardStore {
846
1042
  async getSavedFilters(section) {
847
1043
  if (!this.db)
848
1044
  return [];
849
- const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
850
- if (section)
851
- query.where('section', section);
852
- return query;
1045
+ return this.coalesce('savedFilters:' + (section || ''), async () => {
1046
+ const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
1047
+ if (section)
1048
+ query.where('section', section);
1049
+ return query;
1050
+ });
853
1051
  }
854
1052
  async createSavedFilter(name, section, filterConfig) {
855
1053
  if (!this.db)
@@ -880,48 +1078,53 @@ export class DashboardStore {
880
1078
  async runExplain(queryId, appDb) {
881
1079
  if (!this.db)
882
1080
  return { error: 'Dashboard store not initialized' };
883
- const row = await this.db('server_stats_queries').where('id', queryId).first();
884
- if (!row)
885
- return { error: 'Query not found' };
886
- const sql = row.sql_text.trim();
887
- if (!sql.toLowerCase().startsWith('select')) {
888
- return { error: 'EXPLAIN is only supported for SELECT queries' };
889
- }
890
- try {
891
- const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
892
- return { plan: result.rows || result };
893
- }
894
- catch (err) {
895
- return { error: err.message || 'EXPLAIN failed' };
896
- }
1081
+ return this.coalesce('explain:' + queryId, async () => {
1082
+ const row = await this.db('server_stats_queries').where('id', queryId).first();
1083
+ if (!row)
1084
+ return { error: 'Query not found' };
1085
+ const sql = row.sql_text.trim();
1086
+ if (!sql.toLowerCase().startsWith('select')) {
1087
+ return { error: 'EXPLAIN is only supported for SELECT queries' };
1088
+ }
1089
+ try {
1090
+ const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
1091
+ return { plan: result.rows || result };
1092
+ }
1093
+ catch (err) {
1094
+ return { error: err.message || 'EXPLAIN failed' };
1095
+ }
1096
+ });
897
1097
  }
898
1098
  // =========================================================================
899
1099
  // Private helpers
900
1100
  // =========================================================================
901
- /** Generic paginated query with filter callback. */
902
- async paginate(table, page, perPage, applyFilters) {
1101
+ /**
1102
+ * Generic paginated query with filter callback.
1103
+ *
1104
+ * Wrapped in a single transaction so COUNT + SELECT acquire the pool
1105
+ * connection only once instead of two separate acquire/release cycles.
1106
+ * With max:1 pool, this halves pool pressure per paginated endpoint.
1107
+ */
1108
+ async paginate(table, page, perPage, applyFilters, filterKey) {
903
1109
  if (!this.db) {
904
1110
  return { data: [], total: 0, page, perPage, lastPage: 0 };
905
1111
  }
906
- // Count total
907
- const countQuery = this.db(table);
908
- if (applyFilters)
909
- applyFilters(countQuery);
910
- const [{ count: totalRaw }] = await countQuery.count('* as count');
911
- const total = Number(totalRaw);
912
- // Fetch page
913
- const offset = (page - 1) * perPage;
914
- const dataQuery = this.db(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
915
- if (applyFilters)
916
- applyFilters(dataQuery);
917
- const data = await dataQuery;
918
- return {
919
- data,
920
- total,
921
- page,
922
- perPage,
923
- lastPage: Math.ceil(total / perPage),
924
- };
1112
+ const coalesceKey = 'paginate:' + table + ':' + page + ':' + perPage + ':' + (filterKey || '');
1113
+ return this.cached(coalesceKey, DashboardStore.PAGINATE_CACHE_TTL_MS, async () => {
1114
+ return this.db.transaction(async (trx) => {
1115
+ const countQuery = trx(table);
1116
+ if (applyFilters)
1117
+ applyFilters(countQuery);
1118
+ const [{ count: totalRaw }] = await countQuery.count('* as count');
1119
+ const total = Number(totalRaw);
1120
+ const offset = (page - 1) * perPage;
1121
+ const dataQuery = trx(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
1122
+ if (applyFilters)
1123
+ applyFilters(dataQuery);
1124
+ const data = await dataQuery;
1125
+ return { data, total, page, perPage, lastPage: Math.ceil(total / perPage) };
1126
+ });
1127
+ });
925
1128
  }
926
1129
  /**
927
1130
  * Wire email event listeners to persist emails as they arrive.