adonisjs-server-stats 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +23 -14
  2. package/dist/core/config-utils.d.ts +8 -0
  3. package/dist/core/constants.d.ts +4 -0
  4. package/dist/core/dashboard-data-controller.d.ts +16 -0
  5. package/dist/core/dashboard-data-helpers.d.ts +12 -0
  6. package/dist/core/debug-data-controller.d.ts +4 -0
  7. package/dist/core/define-config-helpers.d.ts +25 -0
  8. package/dist/core/feature-detect-helpers.d.ts +36 -0
  9. package/dist/core/field-resolvers.d.ts +64 -0
  10. package/dist/core/formatters-helpers.d.ts +23 -0
  11. package/dist/core/formatters.d.ts +15 -0
  12. package/dist/core/index.d.ts +1 -1
  13. package/dist/core/index.js +599 -509
  14. package/dist/core/log-utils-helpers.d.ts +13 -0
  15. package/dist/core/metrics.d.ts +3 -28
  16. package/dist/core/pagination.d.ts +0 -9
  17. package/dist/core/server-stats-controller.d.ts +6 -0
  18. package/dist/core/transmit-helpers.d.ts +7 -0
  19. package/dist/core/types-dashboard.d.ts +178 -0
  20. package/dist/core/types-diagnostics.d.ts +85 -0
  21. package/dist/core/types.d.ts +10 -442
  22. package/dist/react/CacheSection-BYN53kYO.js +135 -0
  23. package/dist/react/CacheStatsBar-CRodCOeP.js +27 -0
  24. package/dist/react/CacheTab-DOhuK05d.js +106 -0
  25. package/dist/react/{ConfigSection-DfFd-WRq.js → ConfigSection-B9EHh4Rp.js} +1 -1
  26. package/dist/react/{ConfigTab-Bdg8YMer.js → ConfigTab-C8kriE2b.js} +1 -1
  27. package/dist/react/CustomPaneTab-CvzQS_Wh.js +99 -0
  28. package/dist/react/EmailPreviewOverlay-BmXOAvqG.js +58 -0
  29. package/dist/react/EmailsSection-BJyFJf7A.js +226 -0
  30. package/dist/react/EmailsTab-Ch8jp10B.js +110 -0
  31. package/dist/react/{EventsSection-ByQ-9blq.js → EventsSection-DJPwHeT8.js} +28 -27
  32. package/dist/react/EventsTab-B-FoehXC.js +58 -0
  33. package/dist/react/{FilterBar-DQRXpWrb.js → FilterBar-CQ7bD669.js} +15 -15
  34. package/dist/react/{InternalsContent-DBzsI0CG.js → InternalsContent-O8ino9oM.js} +133 -109
  35. package/dist/react/InternalsSection-B6VlVx5f.js +22 -0
  36. package/dist/react/InternalsTab-CkEKpRMU.js +17 -0
  37. package/dist/react/JobStatsBar-C7RslAFE.js +30 -0
  38. package/dist/react/JobsSection-DWF4i1t_.js +167 -0
  39. package/dist/react/JobsTab-DqnifQXV.js +129 -0
  40. package/dist/react/LogEntryRow-CMMkqA9M.js +43 -0
  41. package/dist/react/LogsSection-C1xC5aP4.js +198 -0
  42. package/dist/react/LogsTab-CS4sLfLw.js +79 -0
  43. package/dist/react/{OverviewSection-C4T1ur51.js → OverviewSection-CxvfOR0v.js} +70 -80
  44. package/dist/react/QueriesSection-CrMdU5Ax.js +458 -0
  45. package/dist/react/{QueriesTab-osLUWd4L.js → QueriesTab-x85PjkyS.js} +38 -40
  46. package/dist/react/RequestsSection-DETN9oZb.js +321 -0
  47. package/dist/react/{RoutesSection-BUSkM6PY.js → RoutesSection-CmorkJeC.js} +2 -2
  48. package/dist/react/RoutesTab-CbzBOzpc.js +68 -0
  49. package/dist/react/SplitPaneWrapper-BiIgT4ND.js +49 -0
  50. package/dist/react/TimeAgoCell-o3KigGfM.js +8 -0
  51. package/dist/react/{TimelineTab-Covg5weo.js → TimelineTab-Ue9tUD_n.js} +76 -102
  52. package/dist/react/index-DwDK-4oX.js +1121 -0
  53. package/dist/react/index.js +6 -6
  54. package/dist/react/react/components/shared/CacheStatsBar.d.ts +13 -0
  55. package/dist/react/react/components/shared/EmailPreviewOverlay.d.ts +29 -0
  56. package/dist/react/react/components/{Dashboard/shared → shared}/FilterBar.d.ts +4 -3
  57. package/dist/react/react/components/shared/JobStatsBar.d.ts +12 -0
  58. package/dist/react/react/components/shared/LogEntryRow.d.ts +9 -0
  59. package/dist/react/react/components/shared/RelatedLogs.d.ts +2 -2
  60. package/dist/react/react/components/shared/SplitPaneWrapper.d.ts +7 -0
  61. package/dist/react/react/components/shared/TimeAgoCell.d.ts +17 -0
  62. package/dist/react/react/hooks/useDashboardData.d.ts +4 -8
  63. package/dist/react/react/hooks/useDiagnosticsData.d.ts +14 -0
  64. package/dist/react/style.css +1 -1
  65. package/dist/src/collectors/app_collector.d.ts +0 -8
  66. package/dist/src/collectors/app_collector.js +45 -52
  67. package/dist/src/collectors/auto_detect.d.ts +0 -23
  68. package/dist/src/collectors/auto_detect.js +33 -55
  69. package/dist/src/collectors/db_pool_collector.d.ts +14 -16
  70. package/dist/src/collectors/db_pool_collector.js +72 -57
  71. package/dist/src/collectors/log_collector.d.ts +0 -47
  72. package/dist/src/collectors/log_collector.js +36 -65
  73. package/dist/src/collectors/queue_collector.d.ts +0 -20
  74. package/dist/src/collectors/queue_collector.js +60 -76
  75. package/dist/src/collectors/redis_collector.d.ts +10 -10
  76. package/dist/src/collectors/redis_collector.js +69 -66
  77. package/dist/src/config/deprecation_migration.d.ts +7 -0
  78. package/dist/src/config/deprecation_migration.js +201 -0
  79. package/dist/src/controller/debug_controller.d.ts +1 -1
  80. package/dist/src/controller/debug_controller.js +87 -81
  81. package/dist/src/dashboard/cache_handlers.d.ts +14 -0
  82. package/dist/src/dashboard/cache_handlers.js +52 -0
  83. package/dist/src/dashboard/chart_aggregator.d.ts +0 -7
  84. package/dist/src/dashboard/chart_aggregator.js +68 -50
  85. package/dist/src/dashboard/coalesce_cache.d.ts +25 -0
  86. package/dist/src/dashboard/coalesce_cache.js +47 -0
  87. package/dist/src/dashboard/dashboard_controller.d.ts +11 -37
  88. package/dist/src/dashboard/dashboard_controller.js +51 -544
  89. package/dist/src/dashboard/dashboard_page_assets.d.ts +17 -0
  90. package/dist/src/dashboard/dashboard_page_assets.js +51 -0
  91. package/dist/src/dashboard/dashboard_store.d.ts +19 -218
  92. package/dist/src/dashboard/dashboard_store.js +115 -1116
  93. package/dist/src/dashboard/dashboard_types.d.ts +83 -0
  94. package/dist/src/dashboard/dashboard_types.js +4 -0
  95. package/dist/src/dashboard/detail_queries.d.ts +19 -0
  96. package/dist/src/dashboard/detail_queries.js +98 -0
  97. package/dist/src/dashboard/email_event_builder.d.ts +8 -0
  98. package/dist/src/dashboard/email_event_builder.js +65 -0
  99. package/dist/src/dashboard/explain_query.d.ts +8 -0
  100. package/dist/src/dashboard/explain_query.js +22 -0
  101. package/dist/src/dashboard/filter_handlers.d.ts +23 -0
  102. package/dist/src/dashboard/filter_handlers.js +56 -0
  103. package/dist/src/dashboard/filtered_queries.d.ts +15 -0
  104. package/dist/src/dashboard/filtered_queries.js +155 -0
  105. package/dist/src/dashboard/flush_manager.d.ts +25 -0
  106. package/dist/src/dashboard/flush_manager.js +107 -0
  107. package/dist/src/dashboard/format_helpers.d.ts +126 -0
  108. package/dist/src/dashboard/format_helpers.js +140 -0
  109. package/dist/src/dashboard/inspector_manager.d.ts +36 -0
  110. package/dist/src/dashboard/inspector_manager.js +102 -0
  111. package/dist/src/dashboard/integrations/config_inspector.js +11 -13
  112. package/dist/src/dashboard/integrations/queue_inspector.d.ts +3 -3
  113. package/dist/src/dashboard/integrations/queue_inspector.js +13 -10
  114. package/dist/src/dashboard/jobs_handlers.d.ts +14 -0
  115. package/dist/src/dashboard/jobs_handlers.js +61 -0
  116. package/dist/src/dashboard/knex_factory.d.ts +18 -0
  117. package/dist/src/dashboard/knex_factory.js +91 -0
  118. package/dist/src/dashboard/migrator.js +30 -159
  119. package/dist/src/dashboard/migrator_tables.d.ts +19 -0
  120. package/dist/src/dashboard/migrator_tables.js +153 -0
  121. package/dist/src/dashboard/overview_queries.d.ts +66 -0
  122. package/dist/src/dashboard/overview_queries.js +155 -0
  123. package/dist/src/dashboard/overview_query_runners.d.ts +25 -0
  124. package/dist/src/dashboard/overview_query_runners.js +84 -0
  125. package/dist/src/dashboard/overview_store_queries.d.ts +40 -0
  126. package/dist/src/dashboard/overview_store_queries.js +69 -0
  127. package/dist/src/dashboard/paginate_helper.d.ts +12 -0
  128. package/dist/src/dashboard/paginate_helper.js +33 -0
  129. package/dist/src/dashboard/query_explain_handler.d.ts +10 -0
  130. package/dist/src/dashboard/query_explain_handler.js +80 -0
  131. package/dist/src/dashboard/read_queries.d.ts +32 -0
  132. package/dist/src/dashboard/read_queries.js +107 -0
  133. package/dist/src/dashboard/saved_filter_queries.d.ts +10 -0
  134. package/dist/src/dashboard/saved_filter_queries.js +24 -0
  135. package/dist/src/dashboard/storage_stats.d.ts +41 -0
  136. package/dist/src/dashboard/storage_stats.js +81 -0
  137. package/dist/src/dashboard/write_queue.d.ts +106 -0
  138. package/dist/src/dashboard/write_queue.js +225 -0
  139. package/dist/src/data/data_access.d.ts +2 -39
  140. package/dist/src/data/data_access.js +17 -193
  141. package/dist/src/data/data_access_helpers.d.ts +130 -0
  142. package/dist/src/data/data_access_helpers.js +212 -0
  143. package/dist/src/debug/debug_store.js +37 -32
  144. package/dist/src/debug/email_collector.d.ts +1 -10
  145. package/dist/src/debug/email_collector.js +78 -81
  146. package/dist/src/debug/event_collector.d.ts +0 -9
  147. package/dist/src/debug/event_collector.js +79 -62
  148. package/dist/src/debug/query_collector.js +23 -19
  149. package/dist/src/debug/route_inspector.d.ts +1 -5
  150. package/dist/src/debug/route_inspector.js +50 -51
  151. package/dist/src/debug/trace_collector.d.ts +9 -1
  152. package/dist/src/debug/trace_collector.js +21 -15
  153. package/dist/src/debug/types.d.ts +1 -1
  154. package/dist/src/define_config.d.ts +0 -65
  155. package/dist/src/define_config.js +93 -333
  156. package/dist/src/edge/client/dashboard.js +2 -2
  157. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  158. package/dist/src/edge/client/stats-bar.js +1 -1
  159. package/dist/src/edge/client-vue/dashboard.js +5 -5
  160. package/dist/src/edge/client-vue/debug-panel-deferred.js +4 -4
  161. package/dist/src/edge/client-vue/stats-bar.js +3 -3
  162. package/dist/src/edge/plugin.d.ts +0 -16
  163. package/dist/src/edge/plugin.js +57 -64
  164. package/dist/src/engine/request_metrics.d.ts +1 -0
  165. package/dist/src/engine/request_metrics.js +32 -42
  166. package/dist/src/middleware/request_tracking_middleware.d.ts +2 -8
  167. package/dist/src/middleware/request_tracking_middleware.js +65 -93
  168. package/dist/src/provider/auth_middleware_detector.d.ts +16 -0
  169. package/dist/src/provider/auth_middleware_detector.js +97 -0
  170. package/dist/src/provider/boot_helpers.d.ts +20 -0
  171. package/dist/src/provider/boot_helpers.js +91 -0
  172. package/dist/src/provider/boot_initializer.d.ts +28 -0
  173. package/dist/src/provider/boot_initializer.js +35 -0
  174. package/dist/src/provider/dashboard_init.d.ts +30 -0
  175. package/dist/src/provider/dashboard_init.js +138 -0
  176. package/dist/src/provider/dashboard_setup.d.ts +25 -0
  177. package/dist/src/provider/dashboard_setup.js +78 -0
  178. package/dist/src/provider/diagnostics.d.ts +134 -0
  179. package/dist/src/provider/diagnostics.js +127 -0
  180. package/dist/src/provider/email_bridge.d.ts +43 -0
  181. package/dist/src/provider/email_bridge.js +80 -0
  182. package/dist/src/provider/email_helpers.d.ts +13 -0
  183. package/dist/src/provider/email_helpers.js +68 -0
  184. package/dist/src/provider/pino_hook.d.ts +17 -0
  185. package/dist/src/provider/pino_hook.js +35 -0
  186. package/dist/src/provider/provider_helpers_extra.d.ts +47 -0
  187. package/dist/src/provider/provider_helpers_extra.js +177 -0
  188. package/dist/src/provider/server_stats_provider.d.ts +39 -85
  189. package/dist/src/provider/server_stats_provider.js +132 -951
  190. package/dist/src/provider/shutdown_helpers.d.ts +43 -0
  191. package/dist/src/provider/shutdown_helpers.js +70 -0
  192. package/dist/src/provider/toolbar_setup.d.ts +57 -0
  193. package/dist/src/provider/toolbar_setup.js +141 -0
  194. package/dist/src/routes/dashboard_routes.d.ts +14 -0
  195. package/dist/src/routes/dashboard_routes.js +197 -0
  196. package/dist/src/routes/debug_routes.d.ts +14 -0
  197. package/dist/src/routes/debug_routes.js +101 -0
  198. package/dist/src/routes/register_routes.d.ts +0 -78
  199. package/dist/src/routes/register_routes.js +22 -352
  200. package/dist/src/routes/stats_routes.d.ts +5 -0
  201. package/dist/src/routes/stats_routes.js +14 -0
  202. package/dist/src/styles/components.css +163 -0
  203. package/dist/src/styles/dashboard.css +13 -105
  204. package/dist/src/styles/debug-panel.css +2 -53
  205. package/dist/src/styles/utilities.css +3 -1
  206. package/dist/src/types.d.ts +305 -14
  207. package/dist/vue/{CacheSection-oFAJL3mo.js → CacheSection-DT2Mwf_s.js} +1 -1
  208. package/dist/vue/{ConfigSection-BhfJ4KqL.js → ConfigSection-BwKwS9lh.js} +1 -1
  209. package/dist/vue/CustomPaneTab-Hr1IBHfz.js +172 -0
  210. package/dist/vue/{EmailsSection-BcNyhyHs.js → EmailsSection-B65g0FVS.js} +1 -1
  211. package/dist/vue/{EventsSection-r60Q5Lmu.js → EventsSection-CxqtVF-o.js} +1 -1
  212. package/dist/vue/{JobsSection-BHL-hkQw.js → JobsSection-rMIyMb-g.js} +1 -1
  213. package/dist/vue/{LogsSection-DRMGzJmg.js → LogsSection-DmmZVJ7D.js} +9 -3
  214. package/dist/vue/{LogsTab-Bg3o0Mm6.js → LogsTab-47zEK7jL.js} +4 -1
  215. package/dist/vue/{OverviewSection-CXh6Ja1B.js → OverviewSection-BMabyqw-.js} +49 -50
  216. package/dist/vue/{QueriesSection-IodIsCJ-.js → QueriesSection-BfDFwGqH.js} +44 -45
  217. package/dist/vue/{QueriesTab-C8_7oprC.js → QueriesTab-DuTG7cpC.js} +30 -31
  218. package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-Py1iu9GU.js +77 -0
  219. package/dist/vue/{RequestsSection-BPuMdmMc.js → RequestsSection-CTu4jPZ_.js} +143 -147
  220. package/dist/vue/{RoutesSection-NKo3Rbq3.js → RoutesSection-zQZDedL7.js} +1 -1
  221. package/dist/vue/TimelineTab-DHfXsX7t.js +334 -0
  222. package/dist/vue/components/shared/RelatedLogs.vue.d.ts +1 -4
  223. package/dist/vue/composables/useDashboardData.d.ts +12 -23
  224. package/dist/vue/index-CM3yNVUR.js +1232 -0
  225. package/dist/vue/index.js +1 -1
  226. package/dist/vue/style.css +1 -1
  227. package/package.json +1 -1
  228. package/dist/react/CacheSection-UCMptWyn.js +0 -146
  229. package/dist/react/CacheTab-CA8LB1J5.js +0 -123
  230. package/dist/react/CustomPaneTab-Bxtv_8Rw.js +0 -104
  231. package/dist/react/EmailsSection-CM7stSyh.js +0 -262
  232. package/dist/react/EmailsTab-BDhEiomM.js +0 -153
  233. package/dist/react/EventsTab-CMfY98Rl.js +0 -63
  234. package/dist/react/InternalsSection-t7ihcWO-.js +0 -32
  235. package/dist/react/InternalsTab-Oij0A2fN.js +0 -30
  236. package/dist/react/JobsSection-DF3qEv9O.js +0 -187
  237. package/dist/react/JobsTab-BbrBWIOb.js +0 -141
  238. package/dist/react/LogsSection-DcFTZY7b.js +0 -227
  239. package/dist/react/LogsTab-CicucmVk.js +0 -103
  240. package/dist/react/QueriesSection-PswteoF9.js +0 -461
  241. package/dist/react/RelatedLogs-DFDOyUMr.js +0 -40
  242. package/dist/react/RequestsSection-Nag30rEA.js +0 -341
  243. package/dist/react/RoutesTab-DgVzd2PZ.js +0 -74
  244. package/dist/react/index-Cflz9Ebj.js +0 -1069
  245. package/dist/vue/CustomPaneTab-BJxT5Dp7.js +0 -172
  246. package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-CB2_TzYW.js +0 -84
  247. package/dist/vue/TimelineTab-zj5Z5OdT.js +0 -338
  248. package/dist/vue/index-Dtgysd26.js +0 -1229
@@ -1,27 +1,31 @@
1
- import { mkdir, stat as fsStat } from 'node:fs/promises';
1
+ import { mkdir } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { safeParseJson, safeParseJsonArray } from '../utils/json_helpers.js';
4
3
  import { log } from '../utils/logger.js';
5
- import { extractAddresses } from '../utils/mail_helpers.js';
6
- import { round } from '../utils/math_helpers.js';
7
- import { rangeToCutoff, rangeToMinutes, roundBucket } from '../utils/time_helpers.js';
8
4
  import { ChartAggregator } from './chart_aggregator.js';
5
+ import { CoalesceCache } from './coalesce_cache.js';
6
+ import { buildEmailRecordFromEvent } from './email_event_builder.js';
7
+ import { executeExplain } from './explain_query.js';
8
+ import { FlushManager } from './flush_manager.js';
9
+ import { createKnexConnection, applyPragmas } from './knex_factory.js';
9
10
  import { autoMigrate, runRetentionCleanup } from './migrator.js';
10
- // ---------------------------------------------------------------------------
11
- // Warn-once tracking for write-path and overview catch blocks
12
- // ---------------------------------------------------------------------------
13
- const warnedWritePaths = new Set();
14
- let overviewWidgetWarned = false;
15
- // ---------------------------------------------------------------------------
16
- // DashboardStore
17
- // ---------------------------------------------------------------------------
18
- /**
19
- * Bridges the in-memory RingBuffer collectors to SQLite persistence
20
- * and provides query methods for the dashboard API.
21
- *
22
- * Handles auto-migration, retention cleanup, periodic metrics aggregation,
23
- * and self-exclusion of dashboard routes and server_stats connection queries.
24
- */
11
+ import { fetchOverviewMetrics, fetchChartData, fetchOverviewWidgets, fetchSparklineData, } from './overview_store_queries.js';
12
+ import { queryRequests, queryQueries, queryQueriesGrouped, queryEvents, queryEmails, queryEmailHtml, queryLogs, queryTraces, queryTraceDetail, queryRequestDetail, } from './read_queries.js';
13
+ import { fetchSavedFilters, insertSavedFilter, removeSavedFilter } from './saved_filter_queries.js';
14
+ import { fetchStorageStats } from './storage_stats.js';
15
+ const EMPTY_PAGINATED = (p, pp) => ({
16
+ data: [],
17
+ total: 0,
18
+ page: p,
19
+ perPage: pp,
20
+ lastPage: 0,
21
+ });
22
+ const EMPTY_WIDGETS = {
23
+ topEvents: [],
24
+ emailActivity: { sent: 0, queued: 0, failed: 0 },
25
+ logLevelBreakdown: { error: 0, warn: 0, info: 0, debug: 0 },
26
+ statusDistribution: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
27
+ slowestQueries: [],
28
+ };
25
29
  export class DashboardStore {
26
30
  db = null;
27
31
  emitter = null;
@@ -32,150 +36,31 @@ export class DashboardStore {
32
36
  handlers = [];
33
37
  dbFilePath = '';
34
38
  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)
39
+ cache = new CoalesceCache();
63
40
  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;
41
+ flushMgr = new FlushManager(() => this.db);
42
+ static STORAGE_TTL = 10_000;
81
43
  constructor(config) {
82
44
  this.config = config;
83
45
  this.dashboardPath = config.dashboardPath;
84
46
  }
85
- // =========================================================================
86
- // Lifecycle
87
- // =========================================================================
88
- /**
89
- * Initialize the SQLite connection, run migrations and retention
90
- * cleanup, start chart aggregation, and wire event listeners.
91
- */
92
47
  async start(_lucidDb, emitter, appRoot) {
93
48
  this.emitter = emitter;
94
49
  this.dbFilePath = appRoot + '/' + this.config.dbPath;
95
- const dbFilePath = this.dbFilePath;
96
- await mkdir(dirname(dbFilePath), { recursive: true });
97
- // Create a standalone Knex connection to SQLite — bypasses Lucid's
98
- // connection manager entirely so we never pollute the app's pool.
99
- //
100
- // Must use appImport — bare import('knex') resolves to this package's
101
- // devDep copy when symlinked (file: dependency), which may have
102
- // different native bindings or conflict with the app's copies.
103
- log.info('dashboard: loading knex...');
104
- const { appImportWithPath } = await import('../utils/app_import.js');
105
- let knexModule;
106
- let knexPath;
107
- try {
108
- const result = await appImportWithPath('knex');
109
- knexModule = result.module;
110
- knexPath = result.resolvedPath;
111
- }
112
- catch (err) {
113
- throw new Error(`Could not load knex: ${err?.message}. ` +
114
- 'Install it with: npm install knex better-sqlite3');
115
- }
116
- let sqlite3Path;
117
- try {
118
- const result = await appImportWithPath('better-sqlite3');
119
- sqlite3Path = result.resolvedPath;
120
- }
121
- catch (err) {
122
- throw new Error(`Could not load better-sqlite3: ${err?.message}. ` +
123
- 'Install it with: npm install better-sqlite3');
124
- }
125
- log.info(`dashboard: knex resolved from ${knexPath}`);
126
- log.info(`dashboard: better-sqlite3 resolved from ${sqlite3Path}`);
127
- const knexFactory = knexModule.default ?? knexModule;
128
- log.info(`dashboard: opening SQLite database at ${dbFilePath}`);
129
- const db = knexFactory({
130
- client: 'better-sqlite3',
131
- connection: { filename: dbFilePath },
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
- },
162
- });
163
- this.db = db;
164
- // Ensure PRAGMAs are set (fallback if afterCreate didn't work)
165
- log.info('dashboard: setting PRAGMA...');
166
- await db.raw('PRAGMA journal_mode=WAL');
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');
172
- log.info('dashboard: PRAGMA set');
50
+ await mkdir(dirname(this.dbFilePath), { recursive: true });
51
+ this.db = await createKnexConnection(this.dbFilePath);
52
+ await applyPragmas(this.db);
53
+ await this.initMigrations();
54
+ this.chartAggregator = new ChartAggregator(this.db);
55
+ this.chartAggregator.start();
56
+ this.wireEventListeners();
57
+ log.info('dashboard: store initialized');
58
+ }
59
+ async initMigrations() {
173
60
  log.info('dashboard: running migrations...');
174
- await autoMigrate(db);
61
+ await autoMigrate(this.db);
175
62
  log.info('dashboard: migrations complete');
176
- // Defer retention cleanup not critical during startup.
177
- // Run first cleanup after 30s, then hourly.
178
- const runCleanup = async () => {
63
+ const cleanup = async () => {
179
64
  try {
180
65
  if (this.db) {
181
66
  await runRetentionCleanup(this.db, this.config.retentionDays);
@@ -187,36 +72,17 @@ export class DashboardStore {
187
72
  log.warn('dashboard: retention cleanup failed — ' + err?.message);
188
73
  }
189
74
  };
190
- setTimeout(() => runCleanup(), 30_000);
191
- this.retentionTimer = setInterval(() => runCleanup(), 60 * 60 * 1000);
192
- // Start chart aggregation (every 60s)
193
- this.chartAggregator = new ChartAggregator(db);
194
- this.chartAggregator.start();
195
- // Wire email event listeners
196
- this.wireEventListeners();
197
- log.info('dashboard: store initialized');
75
+ setTimeout(() => cleanup(), 30_000);
76
+ this.retentionTimer = setInterval(() => cleanup(), 3_600_000);
198
77
  }
199
- /** Shut down timers, event listeners, and database connection. */
200
78
  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(() => { });
79
+ await this.flushMgr.stop();
207
80
  if (this.retentionTimer) {
208
81
  clearInterval(this.retentionTimer);
209
82
  this.retentionTimer = null;
210
83
  }
211
84
  this.chartAggregator?.stop();
212
- if (this.emitter) {
213
- for (const h of this.handlers) {
214
- if (typeof this.emitter.off === 'function') {
215
- this.emitter.off(h.event, h.fn);
216
- }
217
- }
218
- }
219
- this.handlers = [];
85
+ this.removeListeners();
220
86
  if (this.db && typeof this.db.destroy === 'function') {
221
87
  try {
222
88
  await this.db.destroy();
@@ -227,1007 +93,140 @@ export class DashboardStore {
227
93
  }
228
94
  this.db = null;
229
95
  }
230
- /** Get the raw Knex database connection (for DashboardController). */
96
+ removeListeners() {
97
+ if (this.emitter) {
98
+ for (const h of this.handlers) {
99
+ if (typeof this.emitter.off === 'function')
100
+ this.emitter.off(h.event, h.fn);
101
+ }
102
+ }
103
+ this.handlers = [];
104
+ }
231
105
  getDb() {
232
106
  return this.db;
233
107
  }
234
- /** Whether the store is initialized and ready. */
235
108
  isReady() {
236
109
  return this.db !== null;
237
110
  }
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
- */
243
111
  async getStorageStats() {
244
- if (!this.db) {
245
- return {
246
- ready: false,
247
- dbPath: this.config.dbPath,
248
- fileSizeMb: 0,
249
- walSizeMb: 0,
250
- retentionDays: this.config.retentionDays,
251
- tables: [],
252
- lastCleanupAt: null,
253
- };
254
- }
255
- // Serve cached stats if still fresh (avoids 8 COUNT queries per 3s poll)
112
+ const empty = {
113
+ ready: false,
114
+ dbPath: this.config.dbPath,
115
+ fileSizeMb: 0,
116
+ walSizeMb: 0,
117
+ retentionDays: this.config.retentionDays,
118
+ tables: [],
119
+ lastCleanupAt: null,
120
+ };
121
+ if (!this.db)
122
+ return empty;
256
123
  if (this.cachedStorageStats &&
257
- Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_STATS_TTL_MS) {
124
+ Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_TTL)
258
125
  return this.cachedStorageStats.data;
259
- }
260
- return this.coalesce('storageStats', async () => {
261
- let fileSizeMb = 0;
262
- let walSizeMb = 0;
263
- try {
264
- const s = await fsStat(this.dbFilePath);
265
- fileSizeMb = Math.round((s.size / (1024 * 1024)) * 100) / 100;
266
- }
267
- catch {
268
- // File may not exist yet
269
- }
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;
126
+ return fetchStorageStats({
127
+ db: this.db,
128
+ cache: this.cache,
129
+ dbFilePath: this.dbFilePath,
130
+ dbPath: this.config.dbPath,
131
+ retentionDays: this.config.retentionDays,
132
+ lastCleanupAt: this.lastCleanupAt,
133
+ onResult: (stats) => {
134
+ this.cachedStorageStats = { data: stats, cachedAt: Date.now() };
135
+ },
312
136
  });
313
137
  }
314
- // =========================================================================
315
- // Write methods — queued writes with batch flushing
316
- // =========================================================================
317
- /**
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.
324
- */
325
138
  persistRequest(input) {
326
- if (!this.db)
327
- return Promise.resolve(null);
328
- if (input.url.startsWith(this.dashboardPath))
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));
333
- }
334
- this.writeQueue.push(input);
335
- this.scheduleFlush();
139
+ this.flushMgr.persistRequest(input, this.dashboardPath);
336
140
  return Promise.resolve(null);
337
141
  }
338
- /** Queue events to be attached to a request during flush. */
339
142
  queueEvents(requestIndex, events) {
340
- if (events.length === 0)
341
- return;
342
- this.pendingEvents.push({ requestIndex, events });
143
+ this.flushMgr.queueEvents(requestIndex, events);
343
144
  }
344
- /** Record a single log entry — queued for batch flush. */
345
145
  recordLog(entry) {
346
- if (!this.db)
347
- return;
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));
351
- }
352
- this.pendingLogs.push(entry);
353
- this.scheduleFlush();
146
+ this.flushMgr.recordLog(entry);
354
147
  }
355
- /** Record a single email — queued for batch flush (avoids bypassing the write queue). */
356
148
  recordEmail(record) {
357
- if (!this.db)
358
- return;
359
- if (this.pendingEmails.length >= DashboardStore.MAX_QUEUE_SIZE) {
360
- this.pendingEmails.splice(0, Math.floor(DashboardStore.MAX_QUEUE_SIZE / 4));
361
- }
362
- this.pendingEmails.push(record);
363
- this.scheduleFlush();
149
+ this.flushMgr.recordEmail(record);
364
150
  }
365
- /** Schedule the next batch flush if not already scheduled. */
366
- scheduleFlush() {
367
- if (this.flushTimer)
368
- return;
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
- }
376
- });
377
- }, DashboardStore.FLUSH_INTERVAL_MS);
378
- }
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
151
  async flushWriteQueue() {
389
- if (this.flushing || !this.db)
390
- return;
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
- try {
402
- // Pre-stringify JSON OUTSIDE the transaction so the synchronous
403
- // better-sqlite3 execution doesn't block the event loop on large spans.
404
- const preparedRequests = requests.map((input) => ({
405
- input,
406
- filteredQueries: input.queries
407
- .filter((q) => q.connection !== 'server_stats')
408
- .map((q) => ({
409
- sql_text: q.sql,
410
- sql_normalized: normalizeSql(q.sql),
411
- bindings: q.bindings ? JSON.stringify(q.bindings) : null,
412
- duration: round(q.duration),
413
- method: q.method,
414
- model: q.model,
415
- connection: q.connection,
416
- in_transaction: q.inTransaction ? 1 : 0,
417
- })),
418
- traceRow: input.trace
419
- ? {
420
- method: input.trace.method,
421
- url: input.trace.url,
422
- status_code: input.trace.statusCode,
423
- total_duration: round(input.trace.totalDuration),
424
- span_count: input.trace.spanCount,
425
- spans: JSON.stringify(input.trace.spans),
426
- warnings: input.trace.warnings.length > 0 ? JSON.stringify(input.trace.warnings) : null,
427
- }
428
- : null,
429
- }));
430
- const preparedLogs = logs.map((entry) => {
431
- const levelName = typeof entry.levelName === 'string' ? entry.levelName : String(entry.level || 'unknown');
432
- return {
433
- level: levelName,
434
- message: String(entry.msg || entry.message || ''),
435
- request_id: entry.request_id || entry.requestId || entry['x-request-id']
436
- ? String(entry.request_id || entry.requestId || entry['x-request-id'])
437
- : null,
438
- data: JSON.stringify(entry),
439
- };
440
- });
441
- await this.db.transaction(async (trx) => {
442
- // -- Requests + queries + traces --
443
- for (const { input, filteredQueries, traceRow } of preparedRequests) {
444
- try {
445
- const row = {
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 (input.httpRequestId)
454
- row.http_request_id = String(input.httpRequestId);
455
- const [requestId] = await trx('server_stats_requests').insert(row);
456
- if (requestId !== null && requestId !== undefined && filteredQueries.length > 0) {
457
- const rows = filteredQueries.map((q) => ({ ...q, request_id: requestId }));
458
- for (let i = 0; i < rows.length; i += 50) {
459
- await trx('server_stats_queries').insert(rows.slice(i, i + 50));
460
- }
461
- }
462
- if (requestId !== null && requestId !== undefined && traceRow) {
463
- await trx('server_stats_traces').insert({ ...traceRow, request_id: requestId });
464
- }
465
- }
466
- catch (err) {
467
- if (!warnedWritePaths.has('persistRequest')) {
468
- warnedWritePaths.add('persistRequest');
469
- log.warn(`dashboard: persistRequest failed — ${err?.message}`);
470
- }
471
- }
472
- }
473
- // -- Events --
474
- for (const { events: evts } of events) {
475
- try {
476
- const rows = evts.map((e) => ({
477
- request_id: null,
478
- event_name: e.event,
479
- data: e.data,
480
- }));
481
- for (let i = 0; i < rows.length; i += 50) {
482
- await trx('server_stats_events').insert(rows.slice(i, i + 50));
483
- }
484
- }
485
- catch (err) {
486
- if (!warnedWritePaths.has('recordEvents')) {
487
- warnedWritePaths.add('recordEvents');
488
- log.warn(`dashboard: recordEvents failed — ${err?.message}`);
489
- }
490
- }
491
- }
492
- // -- Emails --
493
- if (emails.length > 0) {
494
- try {
495
- const rows = emails.map((record) => ({
496
- from_addr: record.from,
497
- to_addr: record.to,
498
- cc: record.cc,
499
- bcc: record.bcc,
500
- subject: record.subject,
501
- html: record.html,
502
- text_body: record.text,
503
- mailer: record.mailer,
504
- status: record.status,
505
- message_id: record.messageId,
506
- attachment_count: record.attachmentCount,
507
- }));
508
- for (let i = 0; i < rows.length; i += 50) {
509
- await trx('server_stats_emails').insert(rows.slice(i, i + 50));
510
- }
511
- }
512
- catch (err) {
513
- if (!warnedWritePaths.has('recordEmail')) {
514
- warnedWritePaths.add('recordEmail');
515
- log.warn(`dashboard: recordEmail failed — ${err?.message}`);
516
- }
517
- }
518
- }
519
- // -- Logs --
520
- if (preparedLogs.length > 0) {
521
- try {
522
- for (let i = 0; i < preparedLogs.length; i += 50) {
523
- await trx('server_stats_logs').insert(preparedLogs.slice(i, i + 50));
524
- }
525
- }
526
- catch (err) {
527
- if (!warnedWritePaths.has('recordLog')) {
528
- warnedWritePaths.add('recordLog');
529
- log.warn(`dashboard: recordLog failed — ${err?.message}`);
530
- }
531
- }
532
- }
533
- });
534
- }
535
- catch (err) {
536
- if (!warnedWritePaths.has('flush')) {
537
- warnedWritePaths.add('flush');
538
- log.warn(`dashboard: flush transaction failed — ${err?.message}`);
539
- }
540
- }
541
- finally {
542
- this.flushing = false;
543
- }
544
- // Yield to the event loop after the transaction so HTTP requests
545
- // and timers get a chance to run between flush cycles.
546
- await new Promise((resolve) => setImmediate(resolve));
547
- // If more data arrived during flush, schedule another
548
- if (this.writeQueue.length > 0 ||
549
- this.pendingLogs.length > 0 ||
550
- this.pendingEmails.length > 0) {
551
- this.scheduleFlush();
552
- }
152
+ return this.flushMgr.flush();
553
153
  }
554
- // =========================================================================
555
- // Read methods query data for dashboard API
556
- // =========================================================================
557
- /** Paginated request history with optional filters. */
558
- async getRequests(page = 1, perPage = 50, filters) {
559
- const fk = filters ? JSON.stringify(filters) : '';
560
- return this.paginate('server_stats_requests', page, perPage, (query) => {
561
- if (filters?.method)
562
- query.where('method', filters.method);
563
- if (filters?.url)
564
- query.where('url', 'like', `%${filters.url}%`);
565
- if (filters?.status)
566
- query.where('status_code', filters.status);
567
- if (filters?.statusMin)
568
- query.where('status_code', '>=', filters.statusMin);
569
- if (filters?.statusMax)
570
- query.where('status_code', '<=', filters.statusMax);
571
- if (filters?.durationMin)
572
- query.where('duration', '>=', filters.durationMin);
573
- if (filters?.durationMax)
574
- query.where('duration', '<=', filters.durationMax);
575
- if (filters?.search) {
576
- const term = `%${filters.search}%`;
577
- query.where((qb) => {
578
- qb.where('url', 'like', term).orWhere('method', 'like', term);
579
- });
580
- }
581
- }, fk);
154
+ get readCtx() {
155
+ return { db: this.db, cache: this.cache };
582
156
  }
583
- /** Paginated query history with optional filters. */
584
- async getQueries(page = 1, perPage = 50, filters) {
585
- const fk = filters ? JSON.stringify(filters) : '';
586
- return this.paginate('server_stats_queries', page, perPage, (query) => {
587
- if (filters?.method)
588
- query.where('method', filters.method);
589
- if (filters?.model)
590
- query.where('model', filters.model);
591
- if (filters?.connection)
592
- query.where('connection', filters.connection);
593
- if (filters?.durationMin)
594
- query.where('duration', '>=', filters.durationMin);
595
- if (filters?.durationMax)
596
- query.where('duration', '<=', filters.durationMax);
597
- if (filters?.requestId)
598
- query.where('request_id', filters.requestId);
599
- if (filters?.search) {
600
- const term = `%${filters.search}%`;
601
- query.where((qb) => {
602
- qb.where('sql_text', 'like', term)
603
- .orWhere('model', 'like', term)
604
- .orWhere('connection', 'like', term);
605
- });
606
- }
607
- }, fk);
157
+ async getRequests(p = 1, pp = 50, f) {
158
+ return this.db ? queryRequests(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
159
+ }
160
+ async getQueries(p = 1, pp = 50, f) {
161
+ return this.db ? queryQueries(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
608
162
  }
609
- /**
610
- * Grouped query patterns: aggregated by sql_normalized
611
- * with count, avg/min/max/total duration.
612
- */
613
163
  async getQueriesGrouped(limit = 200, sort = 'total_duration', search) {
614
- if (!this.db)
615
- return [];
616
- return this.cached('queriesGrouped:' + limit + ':' + sort + ':' + (search || ''), DashboardStore.QUERIES_GROUPED_CACHE_TTL_MS, async () => {
617
- const validSorts = {
618
- count: 'count',
619
- avg_duration: 'avg_duration',
620
- total_duration: 'total_duration',
621
- };
622
- const orderCol = validSorts[sort] || 'total_duration';
623
- // Apply a time cutoff to avoid scanning the entire table
624
- const cutoff = rangeToCutoff('7d');
625
- const query = this.db('server_stats_queries')
626
- .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'))
627
- .where('created_at', '>=', cutoff)
628
- .groupBy('sql_normalized')
629
- .orderBy(orderCol, 'desc')
630
- .limit(limit);
631
- if (search) {
632
- query.where('sql_normalized', 'like', `%${search}%`);
633
- }
634
- return query;
635
- });
164
+ return this.db ? queryQueriesGrouped(this.readCtx, { limit, sort, search }) : [];
636
165
  }
637
- /** Paginated event history with optional filters. */
638
- async getEvents(page = 1, perPage = 50, filters) {
639
- const fk = filters ? JSON.stringify(filters) : '';
640
- return this.paginate('server_stats_events', page, perPage, (query) => {
641
- if (filters?.eventName)
642
- query.where('event_name', 'like', `%${filters.eventName}%`);
643
- if (filters?.search) {
644
- query.where('event_name', 'like', `%${filters.search}%`);
645
- }
646
- }, fk);
166
+ async getEvents(p = 1, pp = 50, f) {
167
+ return this.db ? queryEvents(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
647
168
  }
648
- /** Paginated email history with optional filters. */
649
- async getEmails(page = 1, perPage = 50, filters, excludeBody = false) {
650
- const fk = (filters ? JSON.stringify(filters) : '') + (excludeBody ? ':noBody' : '');
651
- return this.paginate('server_stats_emails', page, perPage, (query) => {
652
- if (filters?.search) {
653
- const term = `%${filters.search}%`;
654
- query.where((sub) => {
655
- sub
656
- .where('from_addr', 'like', term)
657
- .orWhere('to_addr', 'like', term)
658
- .orWhere('subject', 'like', term);
659
- });
660
- }
661
- if (filters?.from)
662
- query.where('from_addr', 'like', `%${filters.from}%`);
663
- if (filters?.to)
664
- query.where('to_addr', 'like', `%${filters.to}%`);
665
- if (filters?.subject)
666
- query.where('subject', 'like', `%${filters.subject}%`);
667
- if (filters?.mailer)
668
- query.where('mailer', filters.mailer);
669
- if (filters?.status)
670
- query.where('status', filters.status);
671
- if (excludeBody) {
672
- query.select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at');
673
- }
674
- }, fk);
169
+ async getEmails(p = 1, pp = 50, f, excludeBody = false) {
170
+ return this.db
171
+ ? queryEmails(this.readCtx, { page: p, perPage: pp, filters: f, excludeBody })
172
+ : EMPTY_PAGINATED(p, pp);
675
173
  }
676
- /** Get email HTML body for preview (falls back to text_body). */
677
174
  async getEmailHtml(id) {
678
- if (!this.db)
679
- return null;
680
- return this.coalesce('emailHtml:' + id, async () => {
681
- const row = await this.db('server_stats_emails')
682
- .where('id', id)
683
- .select('html', 'text_body')
684
- .first();
685
- if (!row)
686
- return null;
687
- return row.html || row.text_body || null;
688
- });
175
+ return this.db ? queryEmailHtml(this.readCtx, id) : null;
689
176
  }
690
- /**
691
- * Paginated log history with structured search support.
692
- *
693
- * Structured filters query into the JSON `data` column using
694
- * SQLite's `json_extract()`.
695
- */
696
- async getLogs(page = 1, perPage = 50, filters) {
697
- const fk = filters ? JSON.stringify(filters) : '';
698
- return this.paginate('server_stats_logs', page, perPage, (query) => {
699
- if (filters?.level)
700
- query.where('level', filters.level);
701
- if (filters?.requestId)
702
- query.where('request_id', filters.requestId);
703
- if (filters?.search) {
704
- query.where('message', 'like', `%${filters.search}%`);
705
- }
706
- if (filters?.structured && filters.structured.length > 0) {
707
- for (const sf of filters.structured) {
708
- const jsonPath = `$.${sf.field}`;
709
- switch (sf.operator) {
710
- case 'equals':
711
- query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, sf.value]);
712
- break;
713
- case 'contains':
714
- query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${sf.value}%`]);
715
- break;
716
- case 'startsWith':
717
- query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${sf.value}%`]);
718
- break;
719
- }
720
- }
721
- }
722
- }, fk);
177
+ async getLogs(p = 1, pp = 50, f) {
178
+ return this.db ? queryLogs(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
723
179
  }
724
- /** Paginated trace history with optional filters. */
725
- async getTraces(page = 1, perPage = 50, filters) {
726
- const fk = filters ? JSON.stringify(filters) : '';
727
- return this.paginate('server_stats_traces', page, perPage, (query) => {
728
- if (filters?.method)
729
- query.where('method', filters.method);
730
- if (filters?.url)
731
- query.where('url', 'like', `%${filters.url}%`);
732
- if (filters?.statusMin)
733
- query.where('status_code', '>=', filters.statusMin);
734
- if (filters?.statusMax)
735
- query.where('status_code', '<=', filters.statusMax);
736
- if (filters?.search) {
737
- const term = `%${filters.search}%`;
738
- query.where((qb) => {
739
- qb.where('url', 'like', term).orWhere('method', 'like', term);
740
- });
741
- }
742
- }, fk);
180
+ async getTraces(p = 1, pp = 50, f) {
181
+ return this.db ? queryTraces(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
743
182
  }
744
- /** Single trace with full span data. */
745
183
  async getTraceDetail(id) {
746
- if (!this.db)
747
- return null;
748
- return this.coalesce('traceDetail:' + id, async () => {
749
- const row = await this.db('server_stats_traces').where('id', id).first();
750
- if (!row)
751
- return null;
752
- // Look up correlated logs
753
- let logs = [];
754
- let httpRequestId = null;
755
- // Get the linked request to find http_request_id
756
- if (row.request_id) {
757
- const linkedRequest = await this.db('server_stats_requests')
758
- .where('id', row.request_id)
759
- .select('http_request_id', 'created_at')
760
- .first();
761
- if (linkedRequest?.http_request_id) {
762
- httpRequestId = linkedRequest.http_request_id;
763
- logs = await this.db('server_stats_logs')
764
- .where('request_id', linkedRequest.http_request_id)
765
- .orderBy('created_at', 'asc');
766
- }
767
- }
768
- // Fallback: time-window query if no precise match
769
- if (logs.length === 0 && row.created_at) {
770
- const windowSec = Math.ceil((row.total_duration || 0) / 1000) + 2;
771
- logs = await this.db('server_stats_logs')
772
- .where('created_at', '>=', this.db.raw(`datetime(?, '-${windowSec} seconds')`, [row.created_at]))
773
- .where('created_at', '<=', this.db.raw(`datetime(?, '+${windowSec} seconds')`, [row.created_at]))
774
- .orderBy('created_at', 'asc')
775
- .limit(100);
776
- }
777
- return {
778
- ...row,
779
- spans: safeParseJson(row.spans) ?? [],
780
- warnings: safeParseJsonArray(row.warnings),
781
- logs,
782
- http_request_id: httpRequestId,
783
- };
784
- });
184
+ return this.db ? queryTraceDetail(this.readCtx, id) : null;
785
185
  }
786
- /**
787
- * Single request with associated queries, events, and trace.
788
- * Wrapped in a transaction — 1 pool acquire instead of 4.
789
- */
790
186
  async getRequestDetail(id) {
791
- if (!this.db)
792
- return null;
793
- return this.coalesce('requestDetail:' + id, async () => {
794
- return this.db.transaction(async (trx) => {
795
- const request = await trx('server_stats_requests').where('id', id).first();
796
- if (!request)
797
- return null;
798
- const queries = await trx('server_stats_queries')
799
- .where('request_id', id)
800
- .orderBy('created_at', 'asc');
801
- const events = await trx('server_stats_events')
802
- .where('request_id', id)
803
- .orderBy('created_at', 'asc');
804
- const trace = await trx('server_stats_traces').where('request_id', id).first();
805
- // Correlated logs
806
- let logs = [];
807
- if (request.http_request_id) {
808
- logs = await trx('server_stats_logs')
809
- .where('request_id', request.http_request_id)
810
- .orderBy('created_at', 'asc');
811
- }
812
- // Fallback: time-window
813
- if (logs.length === 0 && request.created_at) {
814
- const windowSec = Math.ceil((request.duration || 0) / 1000) + 2;
815
- logs = await trx('server_stats_logs')
816
- .where('created_at', '>=', trx.raw(`datetime(?, '-${windowSec} seconds')`, [request.created_at]))
817
- .where('created_at', '<=', trx.raw(`datetime(?, '+${windowSec} seconds')`, [request.created_at]))
818
- .orderBy('created_at', 'asc')
819
- .limit(100);
820
- }
821
- return {
822
- ...request,
823
- queries,
824
- events,
825
- logs,
826
- trace: trace
827
- ? {
828
- ...trace,
829
- spans: safeParseJson(trace.spans) ?? [],
830
- warnings: safeParseJsonArray(trace.warnings),
831
- }
832
- : null,
833
- };
834
- });
835
- });
187
+ return this.db ? queryRequestDetail(this.readCtx, id) : null;
836
188
  }
837
- // =========================================================================
838
- // Overview & Charts
839
- // =========================================================================
840
- /**
841
- * Aggregated overview metrics for the dashboard cards.
842
- *
843
- * @param range — '1h' | '6h' | '24h' | '7d'
844
- */
845
- /**
846
- * Wrapped in a single transaction — 1 pool acquire instead of 5.
847
- */
848
189
  async getOverviewMetrics(range = '1h') {
849
- if (!this.db)
850
- return null;
851
- return this.cached('overviewMetrics:' + range, 2_000, async () => {
852
- const cutoff = rangeToCutoff(range);
853
- const result = await this.db.transaction(async (trx) => {
854
- const stats = await trx('server_stats_requests')
855
- .where('created_at', '>=', cutoff)
856
- .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'))
857
- .first();
858
- const total = Number(stats?.total ?? 0);
859
- if (total === 0) {
860
- return {
861
- avgResponseTime: 0,
862
- p95ResponseTime: 0,
863
- requestsPerMinute: 0,
864
- errorRate: 0,
865
- totalRequests: 0,
866
- slowestEndpoints: [],
867
- queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
868
- recentErrors: [],
869
- };
870
- }
871
- const avgResponseTime = stats?.avg_duration;
872
- const errorCount = Number(stats?.error_count ?? 0);
873
- const rangeMinutes = rangeToMinutes(range);
874
- const requestsPerMin = total / rangeMinutes;
875
- const p95Offset = Math.floor(total * 0.95);
876
- const p95Row = await trx('server_stats_requests')
877
- .where('created_at', '>=', cutoff)
878
- .orderBy('duration', 'asc')
879
- .offset(Math.min(p95Offset, total - 1))
880
- .limit(1)
881
- .select('duration')
882
- .first();
883
- const p95ResponseTime = p95Row?.duration ?? 0;
884
- const slowestEndpoints = await trx('server_stats_requests')
885
- .where('created_at', '>=', cutoff)
886
- .select('url', trx.raw('COUNT(*) as count'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
887
- .groupBy('url')
888
- .orderBy('avg_duration', 'desc')
889
- .limit(5);
890
- const queryStats = await trx('server_stats_queries')
891
- .where('created_at', '>=', cutoff)
892
- .select(trx.raw('COUNT(*) as total'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
893
- .first();
894
- const recentErrors = await trx('server_stats_logs')
895
- .where('created_at', '>=', cutoff)
896
- .whereIn('level', ['error', 'fatal'])
897
- .orderBy('created_at', 'desc')
898
- .limit(5);
899
- return {
900
- avgResponseTime: round(avgResponseTime),
901
- p95ResponseTime: round(p95ResponseTime),
902
- requestsPerMinute: round(requestsPerMin),
903
- errorRate: round((errorCount / total) * 100),
904
- totalRequests: total,
905
- slowestEndpoints: slowestEndpoints.map((s) => ({
906
- url: s.url,
907
- count: s.count,
908
- avgDuration: s.avg_duration,
909
- })),
910
- queryStats: {
911
- total: queryStats?.total ?? 0,
912
- avgDuration: queryStats?.avg_duration ?? 0,
913
- perRequest: total > 0 ? round((queryStats?.total ?? 0) / total) : 0,
914
- },
915
- recentErrors: recentErrors.map((e) => ({
916
- id: e.id,
917
- message: e.message,
918
- createdAt: e.created_at,
919
- })),
920
- };
921
- });
922
- return result;
923
- });
190
+ return this.db ? fetchOverviewMetrics(this.db, this.cache, range) : null;
924
191
  }
925
- /**
926
- * Time-series chart data from server_stats_metrics.
927
- *
928
- * @param range — '1h' | '6h' | '24h' | '7d'
929
- */
930
192
  async getChartData(range = '1h') {
931
- if (!this.db)
932
- return [];
933
- return this.cached('chartData:' + range, DashboardStore.CHART_CACHE_TTL_MS, async () => {
934
- const cutoff = rangeToCutoff(range);
935
- // For 1h/6h, use the per-minute metrics table.
936
- // For 24h/7d, aggregate metrics into larger buckets.
937
- const rows = await this.db('server_stats_metrics')
938
- .where('bucket', '>=', cutoff)
939
- .orderBy('bucket', 'asc');
940
- if (range === '1h' || range === '6h') {
941
- return rows;
942
- }
943
- // For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
944
- const bucketMinutes = range === '7d' ? 60 : 15;
945
- const grouped = new Map();
946
- for (const row of rows) {
947
- const bucketKey = roundBucket(row.bucket, bucketMinutes);
948
- if (!grouped.has(bucketKey)) {
949
- grouped.set(bucketKey, {
950
- bucket: bucketKey,
951
- request_count: 0,
952
- avg_duration: 0,
953
- p95_duration: 0,
954
- error_count: 0,
955
- query_count: 0,
956
- avg_query_duration: 0,
957
- _count: 0,
958
- });
959
- }
960
- const g = grouped.get(bucketKey);
961
- g.request_count += row.request_count;
962
- g.error_count += row.error_count;
963
- g.query_count += row.query_count;
964
- g.avg_duration += row.avg_duration;
965
- g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
966
- g.avg_query_duration += row.avg_query_duration;
967
- g._count++;
968
- }
969
- return Array.from(grouped.values()).map((g) => ({
970
- bucket: g.bucket,
971
- request_count: g.request_count,
972
- avg_duration: g._count > 0 ? round(g.avg_duration / g._count) : 0,
973
- p95_duration: round(g.p95_duration),
974
- error_count: g.error_count,
975
- query_count: g.query_count,
976
- avg_query_duration: g._count > 0 ? round(g.avg_query_duration / g._count) : 0,
977
- }));
978
- });
193
+ return this.db ? fetchChartData(this.db, this.cache, range) : [];
979
194
  }
980
- /**
981
- * Widget data for the dashboard overview.
982
- *
983
- * @param range — '1h' | '6h' | '24h' | '7d'
984
- */
985
195
  async getOverviewWidgets(range = '1h') {
986
- const empty = {
987
- topEvents: [],
988
- emailActivity: { sent: 0, queued: 0, failed: 0 },
989
- logLevelBreakdown: { error: 0, warn: 0, info: 0, debug: 0 },
990
- statusDistribution: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
991
- slowestQueries: [],
992
- };
993
- if (!this.db)
994
- return empty;
995
- return this.cached('overviewWidgets:' + range, DashboardStore.WIDGETS_CACHE_TTL_MS, async () => {
996
- const cutoff = rangeToCutoff(range);
997
- try {
998
- // Single transaction — 1 pool acquire instead of 5.
999
- const { topEventsRaw, emailStatusRaw, logLevelsRaw, statusRaw, slowQueriesRaw } = await this.db.transaction(async (trx) => ({
1000
- topEventsRaw: await trx('server_stats_events')
1001
- .select('event_name', trx.raw('COUNT(*) as count'))
1002
- .where('created_at', '>=', cutoff)
1003
- .groupBy('event_name')
1004
- .orderBy('count', 'desc')
1005
- .limit(5),
1006
- emailStatusRaw: await trx('server_stats_emails')
1007
- .select('status', trx.raw('COUNT(*) as count'))
1008
- .where('created_at', '>=', cutoff)
1009
- .groupBy('status'),
1010
- logLevelsRaw: await trx('server_stats_logs')
1011
- .select('level', trx.raw('COUNT(*) as count'))
1012
- .where('created_at', '>=', cutoff)
1013
- .groupBy('level'),
1014
- statusRaw: await trx('server_stats_requests')
1015
- .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"`))
1016
- .where('created_at', '>=', cutoff)
1017
- .first(),
1018
- slowQueriesRaw: await trx('server_stats_queries')
1019
- .select('sql_normalized', trx.raw('ROUND(AVG(duration), 2) as avg_duration'), trx.raw('COUNT(*) as count'))
1020
- .where('created_at', '>=', cutoff)
1021
- .groupBy('sql_normalized')
1022
- .orderBy('avg_duration', 'desc')
1023
- .limit(5),
1024
- }));
1025
- // Map top events
1026
- const topEvents = (topEventsRaw || []).map((r) => ({
1027
- eventName: r.event_name,
1028
- count: r.count,
1029
- }));
1030
- // Map email activity
1031
- const emailActivity = { sent: 0, queued: 0, failed: 0 };
1032
- for (const row of emailStatusRaw || []) {
1033
- const status = row.status;
1034
- const count = row.count;
1035
- if (status === 'sent' || status === 'sending')
1036
- emailActivity.sent += count;
1037
- else if (status === 'queued' || status === 'queueing')
1038
- emailActivity.queued += count;
1039
- else if (status === 'failed')
1040
- emailActivity.failed = count;
1041
- }
1042
- // Map log level breakdown
1043
- const logLevelBreakdown = { error: 0, warn: 0, info: 0, debug: 0 };
1044
- for (const row of logLevelsRaw || []) {
1045
- const level = row.level;
1046
- if (level in logLevelBreakdown) {
1047
- logLevelBreakdown[level] = row.count;
1048
- }
1049
- }
1050
- // Map status distribution
1051
- const statusDistribution = {
1052
- '2xx': statusRaw?.s2xx ?? 0,
1053
- '3xx': statusRaw?.s3xx ?? 0,
1054
- '4xx': statusRaw?.s4xx ?? 0,
1055
- '5xx': statusRaw?.s5xx ?? 0,
1056
- };
1057
- // Map slowest queries
1058
- const slowestQueries = (slowQueriesRaw || []).map((r) => ({
1059
- sqlNormalized: r.sql_normalized,
1060
- avgDuration: r.avg_duration,
1061
- count: r.count,
1062
- }));
1063
- return { topEvents, emailActivity, logLevelBreakdown, statusDistribution, slowestQueries };
1064
- }
1065
- catch (err) {
1066
- if (!overviewWidgetWarned) {
1067
- overviewWidgetWarned = true;
1068
- log.warn('dashboard: getOverviewWidgets query failed — ' + err?.message);
1069
- }
1070
- return empty;
1071
- }
1072
- });
196
+ return this.db ? fetchOverviewWidgets(this.db, this.cache, range) : EMPTY_WIDGETS;
1073
197
  }
1074
- /** Get sparkline data points from pre-aggregated metrics. */
1075
198
  async getSparklineData(range) {
1076
- if (!this.db)
1077
- return [];
1078
- return this.cached('sparkline:' + range, DashboardStore.SPARKLINE_CACHE_TTL_MS, async () => {
1079
- const cutoff = rangeToCutoff(range);
1080
- const metrics = await this.db('server_stats_metrics')
1081
- .where('bucket', '>=', cutoff)
1082
- .orderBy('bucket', 'asc');
1083
- return metrics.slice(-15);
1084
- });
199
+ return this.db ? fetchSparklineData(this.db, this.cache, range) : [];
1085
200
  }
1086
- // =========================================================================
1087
- // Saved filters CRUD
1088
- // =========================================================================
1089
201
  async getSavedFilters(section) {
1090
- if (!this.db)
1091
- return [];
1092
- return this.coalesce('savedFilters:' + (section || ''), async () => {
1093
- const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
1094
- if (section)
1095
- query.where('section', section);
1096
- return query;
1097
- });
202
+ return this.db ? fetchSavedFilters(this.db, this.cache, section) : [];
1098
203
  }
1099
204
  async createSavedFilter(name, section, filterConfig) {
1100
- if (!this.db)
1101
- return null;
1102
- const [id] = await this.db('server_stats_saved_filters').insert({
1103
- name,
1104
- section,
1105
- filter_config: JSON.stringify(filterConfig),
1106
- });
1107
- return { id, name, section, filterConfig };
205
+ return this.db ? insertSavedFilter(this.db, name, section, filterConfig) : null;
1108
206
  }
1109
207
  async deleteSavedFilter(id) {
1110
- if (!this.db)
1111
- return false;
1112
- const deleted = await this.db('server_stats_saved_filters').where('id', id).delete();
1113
- return deleted > 0;
208
+ return this.db ? removeSavedFilter(this.db, id) : false;
1114
209
  }
1115
- // =========================================================================
1116
- // EXPLAIN
1117
- // =========================================================================
1118
- /**
1119
- * Run EXPLAIN on a stored query using the app's default database connection.
1120
- * Only allows SELECT queries for safety.
1121
- *
1122
- * @param queryId — ID from server_stats_queries
1123
- * @param appDb — The application's Lucid database manager
1124
- */
1125
210
  async runExplain(queryId, appDb) {
1126
211
  if (!this.db)
1127
212
  return { error: 'Dashboard store not initialized' };
1128
- return this.coalesce('explain:' + queryId, async () => {
1129
- const row = await this.db('server_stats_queries').where('id', queryId).first();
1130
- if (!row)
1131
- return { error: 'Query not found' };
1132
- const sql = row.sql_text.trim();
1133
- if (!sql.toLowerCase().startsWith('select')) {
1134
- return { error: 'EXPLAIN is only supported for SELECT queries' };
1135
- }
1136
- try {
1137
- const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
1138
- return { plan: result.rows || result };
1139
- }
1140
- catch (err) {
1141
- return { error: err.message || 'EXPLAIN failed' };
1142
- }
1143
- });
1144
- }
1145
- // =========================================================================
1146
- // Private helpers
1147
- // =========================================================================
1148
- /**
1149
- * Generic paginated query with filter callback.
1150
- *
1151
- * Wrapped in a single transaction so COUNT + SELECT acquire the pool
1152
- * connection only once instead of two separate acquire/release cycles.
1153
- * With max:1 pool, this halves pool pressure per paginated endpoint.
1154
- */
1155
- async paginate(table, page, perPage, applyFilters, filterKey) {
1156
- if (!this.db) {
1157
- return { data: [], total: 0, page, perPage, lastPage: 0 };
1158
- }
1159
- const coalesceKey = 'paginate:' + table + ':' + page + ':' + perPage + ':' + (filterKey || '');
1160
- return this.cached(coalesceKey, DashboardStore.PAGINATE_CACHE_TTL_MS, async () => {
1161
- return this.db.transaction(async (trx) => {
1162
- const countQuery = trx(table);
1163
- if (applyFilters)
1164
- applyFilters(countQuery);
1165
- const [{ count: totalRaw }] = await countQuery.count('* as count');
1166
- const total = Number(totalRaw);
1167
- const offset = (page - 1) * perPage;
1168
- const dataQuery = trx(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
1169
- if (applyFilters)
1170
- applyFilters(dataQuery);
1171
- const data = await dataQuery;
1172
- return { data, total, page, perPage, lastPage: Math.ceil(total / perPage) };
1173
- });
1174
- });
213
+ return executeExplain(this.db, this.cache, queryId, appDb);
1175
214
  }
1176
- /**
1177
- * Wire email event listeners to persist emails as they arrive.
1178
- */
1179
215
  wireEventListeners() {
1180
216
  if (!this.emitter || typeof this.emitter.on !== 'function') {
1181
217
  log.warn('dashboard: emitter not available — email collection disabled');
1182
218
  return;
1183
219
  }
1184
- const buildAndPersistEmail = (data, status) => {
1185
- const d = data;
1186
- const msg = (d?.message || d);
1187
- const record = {
1188
- from: extractAddresses(msg?.from) || 'unknown',
1189
- to: extractAddresses(msg?.to) || 'unknown',
1190
- cc: extractAddresses(msg?.cc) || null,
1191
- bcc: extractAddresses(msg?.bcc) || null,
1192
- subject: msg?.subject || '(no subject)',
1193
- html: msg?.html || null,
1194
- text: msg?.text || null,
1195
- mailer: d?.mailerName || d?.mailer || 'unknown',
1196
- status,
1197
- messageId: d?.response?.messageId ||
1198
- d?.messageId ||
1199
- null,
1200
- attachmentCount: Array.isArray(msg?.attachments)
1201
- ? msg.attachments.length
1202
- : 0,
1203
- timestamp: Date.now(),
1204
- };
1205
- this.recordEmail(record);
1206
- };
220
+ const persist = (data, status) => this.recordEmail(buildEmailRecordFromEvent(data, status));
1207
221
  this.handlers = [
1208
- { event: 'mail:sending', fn: (data) => buildAndPersistEmail(data, 'sending') },
1209
- { event: 'mail:sent', fn: (data) => buildAndPersistEmail(data, 'sent') },
1210
- { event: 'mail:queueing', fn: (data) => buildAndPersistEmail(data, 'queueing') },
1211
- { event: 'mail:queued', fn: (data) => buildAndPersistEmail(data, 'queued') },
1212
- { event: 'queued:mail:error', fn: (data) => buildAndPersistEmail(data, 'failed') },
222
+ { event: 'mail:sending', fn: (data) => persist(data, 'sending') },
223
+ { event: 'mail:sent', fn: (data) => persist(data, 'sent') },
224
+ { event: 'mail:queueing', fn: (data) => persist(data, 'queueing') },
225
+ { event: 'mail:queued', fn: (data) => persist(data, 'queued') },
226
+ { event: 'queued:mail:error', fn: (data) => persist(data, 'failed') },
1213
227
  ];
1214
- for (const h of this.handlers) {
228
+ for (const h of this.handlers)
1215
229
  this.emitter.on(h.event, h.fn);
1216
- }
1217
230
  log.info(`dashboard: email listeners wired (${this.handlers.length} events)`);
1218
231
  }
1219
232
  }
1220
- // ---------------------------------------------------------------------------
1221
- // Helpers
1222
- // ---------------------------------------------------------------------------
1223
- /**
1224
- * Normalize a SQL query by replacing literal values with `?` placeholders.
1225
- * Used for grouping identical query patterns.
1226
- */
1227
- function normalizeSql(sql) {
1228
- return sql
1229
- .replace(/'[^']*'/g, '?')
1230
- .replace(/\b\d+(\.\d+)?\b/g, '?')
1231
- .replace(/\s+/g, ' ')
1232
- .trim();
1233
- }