adonisjs-server-stats 1.9.0 → 1.10.3

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 (227) 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/formatters-helpers.d.ts +23 -0
  10. package/dist/core/index.js +596 -509
  11. package/dist/core/log-utils-helpers.d.ts +13 -0
  12. package/dist/core/metrics.d.ts +3 -28
  13. package/dist/core/pagination.d.ts +0 -9
  14. package/dist/core/server-stats-controller.d.ts +6 -0
  15. package/dist/core/split-pane.d.ts +18 -0
  16. package/dist/core/trace-utils.d.ts +5 -0
  17. package/dist/core/transmit-helpers.d.ts +7 -0
  18. package/dist/core/types-dashboard.d.ts +178 -0
  19. package/dist/core/types-diagnostics.d.ts +85 -0
  20. package/dist/core/types.d.ts +11 -443
  21. package/dist/react/{CacheSection-xH75hwXu.js → CacheSection-baMZotSn.js} +2 -2
  22. package/dist/react/CacheTab-2cw_rMzj.js +117 -0
  23. package/dist/react/{ConfigSection-D8BO1Ry9.js → ConfigSection-DGgqjAal.js} +1 -1
  24. package/dist/react/{ConfigTab-CcN-tfjv.js → ConfigTab-H3OnYqmK.js} +1 -1
  25. package/dist/react/CustomPaneTab-B6r7ha0u.js +98 -0
  26. package/dist/react/{EmailsSection-BzlsTdPs.js → EmailsSection-C-UZISG-.js} +2 -2
  27. package/dist/react/EmailsTab-DbK4Eobn.js +139 -0
  28. package/dist/react/{EventsSection-CGQWiIdV.js → EventsSection-C7RQW_LY.js} +2 -2
  29. package/dist/react/EventsTab-CfVr7AiM.js +57 -0
  30. package/dist/react/{FilterBar-DQRXpWrb.js → FilterBar-CQ7bD669.js} +15 -15
  31. package/dist/react/{JobsSection-D7AHQmZi.js → JobsSection-CQHNK_Ls.js} +2 -2
  32. package/dist/react/{JobsTab-B3Lfdqed.js → JobsTab-znzf6jzk.js} +54 -42
  33. package/dist/react/{LogsSection-Cly1dpvS.js → LogsSection-Dmm3rE2B.js} +9 -3
  34. package/dist/react/LogsTab-D8unMV5P.js +108 -0
  35. package/dist/react/{OverviewSection-CkBGFEWq.js → OverviewSection-ABP9ueBo.js} +1 -1
  36. package/dist/react/{QueriesSection-CfCpnNUD.js → QueriesSection-CnmSkznA.js} +2 -2
  37. package/dist/react/{QueriesTab-DbBmAqzO.js → QueriesTab-BQzcxEiW.js} +37 -40
  38. package/dist/react/RelatedLogs-3A8RuGKH.js +52 -0
  39. package/dist/react/RequestsSection-kW79_M7k.js +341 -0
  40. package/dist/react/{RoutesSection-CRqF-cNM.js → RoutesSection-BRhxrtjZ.js} +2 -2
  41. package/dist/react/RoutesTab-CpYH5lUw.js +68 -0
  42. package/dist/react/TimelineTab-DjLR35Ce.js +214 -0
  43. package/dist/react/index-CsImORX6.js +1121 -0
  44. package/dist/react/index.js +1 -1
  45. package/dist/react/react/components/{Dashboard/shared → shared}/FilterBar.d.ts +4 -3
  46. package/dist/react/react/components/shared/RelatedLogs.d.ts +7 -0
  47. package/dist/react/react/hooks/useDashboardData.d.ts +4 -8
  48. package/dist/react/style.css +1 -1
  49. package/dist/src/collectors/app_collector.d.ts +0 -8
  50. package/dist/src/collectors/app_collector.js +45 -52
  51. package/dist/src/collectors/auto_detect.d.ts +0 -23
  52. package/dist/src/collectors/auto_detect.js +33 -55
  53. package/dist/src/collectors/db_pool_collector.d.ts +14 -16
  54. package/dist/src/collectors/db_pool_collector.js +72 -57
  55. package/dist/src/collectors/log_collector.d.ts +0 -47
  56. package/dist/src/collectors/log_collector.js +36 -65
  57. package/dist/src/collectors/queue_collector.d.ts +0 -20
  58. package/dist/src/collectors/queue_collector.js +60 -76
  59. package/dist/src/collectors/redis_collector.d.ts +10 -10
  60. package/dist/src/collectors/redis_collector.js +69 -66
  61. package/dist/src/config/deprecation_migration.d.ts +7 -0
  62. package/dist/src/config/deprecation_migration.js +201 -0
  63. package/dist/src/controller/debug_controller.d.ts +1 -1
  64. package/dist/src/controller/debug_controller.js +87 -81
  65. package/dist/src/dashboard/cache_handlers.d.ts +14 -0
  66. package/dist/src/dashboard/cache_handlers.js +52 -0
  67. package/dist/src/dashboard/chart_aggregator.d.ts +0 -7
  68. package/dist/src/dashboard/chart_aggregator.js +68 -50
  69. package/dist/src/dashboard/coalesce_cache.d.ts +25 -0
  70. package/dist/src/dashboard/coalesce_cache.js +47 -0
  71. package/dist/src/dashboard/dashboard_controller.d.ts +11 -37
  72. package/dist/src/dashboard/dashboard_controller.js +52 -532
  73. package/dist/src/dashboard/dashboard_page_assets.d.ts +17 -0
  74. package/dist/src/dashboard/dashboard_page_assets.js +51 -0
  75. package/dist/src/dashboard/dashboard_store.d.ts +19 -217
  76. package/dist/src/dashboard/dashboard_store.js +115 -1069
  77. package/dist/src/dashboard/dashboard_types.d.ts +83 -0
  78. package/dist/src/dashboard/dashboard_types.js +4 -0
  79. package/dist/src/dashboard/detail_queries.d.ts +19 -0
  80. package/dist/src/dashboard/detail_queries.js +98 -0
  81. package/dist/src/dashboard/email_event_builder.d.ts +8 -0
  82. package/dist/src/dashboard/email_event_builder.js +65 -0
  83. package/dist/src/dashboard/explain_query.d.ts +8 -0
  84. package/dist/src/dashboard/explain_query.js +22 -0
  85. package/dist/src/dashboard/filter_handlers.d.ts +23 -0
  86. package/dist/src/dashboard/filter_handlers.js +56 -0
  87. package/dist/src/dashboard/filtered_queries.d.ts +15 -0
  88. package/dist/src/dashboard/filtered_queries.js +155 -0
  89. package/dist/src/dashboard/flush_manager.d.ts +25 -0
  90. package/dist/src/dashboard/flush_manager.js +107 -0
  91. package/dist/src/dashboard/format_helpers.d.ts +126 -0
  92. package/dist/src/dashboard/format_helpers.js +140 -0
  93. package/dist/src/dashboard/inspector_manager.d.ts +36 -0
  94. package/dist/src/dashboard/inspector_manager.js +102 -0
  95. package/dist/src/dashboard/integrations/config_inspector.js +11 -13
  96. package/dist/src/dashboard/integrations/queue_inspector.d.ts +3 -3
  97. package/dist/src/dashboard/integrations/queue_inspector.js +13 -10
  98. package/dist/src/dashboard/jobs_handlers.d.ts +14 -0
  99. package/dist/src/dashboard/jobs_handlers.js +61 -0
  100. package/dist/src/dashboard/knex_factory.d.ts +18 -0
  101. package/dist/src/dashboard/knex_factory.js +91 -0
  102. package/dist/src/dashboard/migrator.js +30 -153
  103. package/dist/src/dashboard/migrator_tables.d.ts +19 -0
  104. package/dist/src/dashboard/migrator_tables.js +153 -0
  105. package/dist/src/dashboard/overview_queries.d.ts +66 -0
  106. package/dist/src/dashboard/overview_queries.js +155 -0
  107. package/dist/src/dashboard/overview_query_runners.d.ts +25 -0
  108. package/dist/src/dashboard/overview_query_runners.js +84 -0
  109. package/dist/src/dashboard/overview_store_queries.d.ts +40 -0
  110. package/dist/src/dashboard/overview_store_queries.js +69 -0
  111. package/dist/src/dashboard/paginate_helper.d.ts +12 -0
  112. package/dist/src/dashboard/paginate_helper.js +33 -0
  113. package/dist/src/dashboard/query_explain_handler.d.ts +10 -0
  114. package/dist/src/dashboard/query_explain_handler.js +80 -0
  115. package/dist/src/dashboard/read_queries.d.ts +32 -0
  116. package/dist/src/dashboard/read_queries.js +107 -0
  117. package/dist/src/dashboard/saved_filter_queries.d.ts +10 -0
  118. package/dist/src/dashboard/saved_filter_queries.js +24 -0
  119. package/dist/src/dashboard/storage_stats.d.ts +41 -0
  120. package/dist/src/dashboard/storage_stats.js +81 -0
  121. package/dist/src/dashboard/write_queue.d.ts +106 -0
  122. package/dist/src/dashboard/write_queue.js +225 -0
  123. package/dist/src/data/data_access.d.ts +6 -36
  124. package/dist/src/data/data_access.js +43 -188
  125. package/dist/src/data/data_access_helpers.d.ts +130 -0
  126. package/dist/src/data/data_access_helpers.js +212 -0
  127. package/dist/src/debug/debug_store.js +37 -32
  128. package/dist/src/debug/email_collector.d.ts +1 -10
  129. package/dist/src/debug/email_collector.js +78 -81
  130. package/dist/src/debug/event_collector.d.ts +0 -9
  131. package/dist/src/debug/event_collector.js +79 -62
  132. package/dist/src/debug/query_collector.js +23 -19
  133. package/dist/src/debug/route_inspector.d.ts +1 -5
  134. package/dist/src/debug/route_inspector.js +50 -51
  135. package/dist/src/debug/trace_collector.d.ts +10 -2
  136. package/dist/src/debug/trace_collector.js +23 -16
  137. package/dist/src/debug/types.d.ts +5 -1
  138. package/dist/src/define_config.d.ts +0 -65
  139. package/dist/src/define_config.js +93 -333
  140. package/dist/src/edge/client/dashboard.js +2 -2
  141. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  142. package/dist/src/edge/client/stats-bar.js +1 -1
  143. package/dist/src/edge/client-vue/dashboard.js +5 -5
  144. package/dist/src/edge/client-vue/debug-panel-deferred.js +3 -3
  145. package/dist/src/edge/client-vue/stats-bar.js +3 -3
  146. package/dist/src/edge/plugin.d.ts +0 -16
  147. package/dist/src/edge/plugin.js +57 -64
  148. package/dist/src/engine/request_metrics.d.ts +1 -0
  149. package/dist/src/engine/request_metrics.js +32 -42
  150. package/dist/src/middleware/request_tracking_middleware.d.ts +3 -8
  151. package/dist/src/middleware/request_tracking_middleware.js +65 -91
  152. package/dist/src/provider/auth_middleware_detector.d.ts +16 -0
  153. package/dist/src/provider/auth_middleware_detector.js +97 -0
  154. package/dist/src/provider/boot_helpers.d.ts +20 -0
  155. package/dist/src/provider/boot_helpers.js +91 -0
  156. package/dist/src/provider/boot_initializer.d.ts +28 -0
  157. package/dist/src/provider/boot_initializer.js +35 -0
  158. package/dist/src/provider/dashboard_init.d.ts +30 -0
  159. package/dist/src/provider/dashboard_init.js +138 -0
  160. package/dist/src/provider/dashboard_setup.d.ts +25 -0
  161. package/dist/src/provider/dashboard_setup.js +78 -0
  162. package/dist/src/provider/diagnostics.d.ts +134 -0
  163. package/dist/src/provider/diagnostics.js +127 -0
  164. package/dist/src/provider/email_bridge.d.ts +43 -0
  165. package/dist/src/provider/email_bridge.js +80 -0
  166. package/dist/src/provider/email_helpers.d.ts +13 -0
  167. package/dist/src/provider/email_helpers.js +68 -0
  168. package/dist/src/provider/pino_hook.d.ts +17 -0
  169. package/dist/src/provider/pino_hook.js +35 -0
  170. package/dist/src/provider/provider_helpers_extra.d.ts +47 -0
  171. package/dist/src/provider/provider_helpers_extra.js +177 -0
  172. package/dist/src/provider/server_stats_provider.d.ts +39 -85
  173. package/dist/src/provider/server_stats_provider.js +131 -936
  174. package/dist/src/provider/shutdown_helpers.d.ts +43 -0
  175. package/dist/src/provider/shutdown_helpers.js +70 -0
  176. package/dist/src/provider/toolbar_setup.d.ts +57 -0
  177. package/dist/src/provider/toolbar_setup.js +141 -0
  178. package/dist/src/routes/dashboard_routes.d.ts +14 -0
  179. package/dist/src/routes/dashboard_routes.js +197 -0
  180. package/dist/src/routes/debug_routes.d.ts +14 -0
  181. package/dist/src/routes/debug_routes.js +101 -0
  182. package/dist/src/routes/register_routes.d.ts +0 -78
  183. package/dist/src/routes/register_routes.js +22 -347
  184. package/dist/src/routes/stats_routes.d.ts +5 -0
  185. package/dist/src/routes/stats_routes.js +14 -0
  186. package/dist/src/styles/components.css +177 -0
  187. package/dist/src/styles/dashboard.css +8 -90
  188. package/dist/src/styles/debug-panel.css +10 -31
  189. package/dist/src/types.d.ts +306 -15
  190. package/dist/vue/{CacheSection-Cx-hj09X.js → CacheSection-ITqvpfH5.js} +1 -1
  191. package/dist/vue/{ConfigSection-CMXyryf6.js → ConfigSection-DTn3GslE.js} +1 -1
  192. package/dist/vue/{EmailsSection-DgKl9xGT.js → EmailsSection-DtLJ4XoS.js} +1 -1
  193. package/dist/vue/{EventsSection-BNMCAim1.js → EventsSection-BOYYz0Ty.js} +1 -1
  194. package/dist/vue/{JobsSection-CCMgMlxd.js → JobsSection-BazTxcJL.js} +1 -1
  195. package/dist/vue/{LogsSection-CvOnTxUu.js → LogsSection-D55PjTKX.js} +9 -3
  196. package/dist/vue/{LogsTab-Bg3o0Mm6.js → LogsTab-47zEK7jL.js} +4 -1
  197. package/dist/vue/{OverviewSection-CHgaKtUR.js → OverviewSection-1uBKo-Tu.js} +1 -1
  198. package/dist/vue/{QueriesSection-BnHRD98z.js → QueriesSection-rpoZ4ogd.js} +1 -1
  199. package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-CB2_TzYW.js +84 -0
  200. package/dist/vue/RequestsSection-x7LvT0MC.js +401 -0
  201. package/dist/vue/{RoutesSection-BrceOcKQ.js → RoutesSection-CCD0zZqQ.js} +1 -1
  202. package/dist/vue/TimelineTab-zj5Z5OdT.js +338 -0
  203. package/dist/vue/components/Dashboard/sections/RequestsSection.vue.d.ts +4 -0
  204. package/dist/vue/components/DebugPanel/tabs/TimelineTab.vue.d.ts +4 -0
  205. package/dist/vue/components/{Dashboard/sections/TimelineSection.vue.d.ts → shared/RelatedLogs.vue.d.ts} +5 -6
  206. package/dist/vue/composables/useDashboardData.d.ts +12 -23
  207. package/dist/vue/index-C8MxnS7Q.js +1232 -0
  208. package/dist/vue/index.js +1 -1
  209. package/dist/vue/style.css +1 -1
  210. package/package.json +1 -1
  211. package/dist/react/CacheTab-DYmsZJJ1.js +0 -123
  212. package/dist/react/CustomPaneTab-D7_o3Ec6.js +0 -104
  213. package/dist/react/EmailsTab-Uh2CQY3o.js +0 -153
  214. package/dist/react/EventsTab-CC6DQzEm.js +0 -63
  215. package/dist/react/LogsTab-BbYK-iyh.js +0 -103
  216. package/dist/react/RequestsSection-Cb5a6MlT.js +0 -209
  217. package/dist/react/RoutesTab-Bwreij3e.js +0 -74
  218. package/dist/react/TimelineSection-B2y06kRE.js +0 -158
  219. package/dist/react/TimelineTab-6hthfdBB.js +0 -193
  220. package/dist/react/WaterfallChart-Cj73WdfM.js +0 -100
  221. package/dist/react/index-CecA4IdQ.js +0 -1075
  222. package/dist/react/react/components/Dashboard/sections/TimelineSection.d.ts +0 -8
  223. package/dist/vue/RequestsSection-B-uSlM0f.js +0 -243
  224. package/dist/vue/TimelineSection-CfvnA2Oo.js +0 -186
  225. package/dist/vue/TimelineTab-Db6lKKsD.js +0 -250
  226. package/dist/vue/WaterfallChart.vue_vue_type_script_setup_true_lang-tZ13cNj1.js +0 -118
  227. package/dist/vue/index-oLxS08vN.js +0 -1235
@@ -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,960 +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();
364
- }
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);
149
+ this.flushMgr.recordEmail(record);
378
150
  }
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
- // 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) => {
430
- const levelName = typeof entry.levelName === 'string' ? entry.levelName : String(entry.level || 'unknown');
431
- return {
432
- level: levelName,
433
- message: String(entry.msg || entry.message || ''),
434
- request_id: entry.request_id || entry.requestId || entry['x-request-id']
435
- ? String(entry.request_id || entry.requestId || entry['x-request-id'])
436
- : null,
437
- data: JSON.stringify(entry),
438
- };
439
- });
440
- try {
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
- }
530
- });
531
- }
532
- catch (err) {
533
- if (!warnedWritePaths.has('flush')) {
534
- warnedWritePaths.add('flush');
535
- log.warn(`dashboard: flush transaction failed — ${err?.message}`);
536
- }
537
- }
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();
549
- }
152
+ return this.flushMgr.flush();
550
153
  }
551
- // =========================================================================
552
- // Read methods query data for dashboard API
553
- // =========================================================================
554
- /** Paginated request history with optional filters. */
555
- async getRequests(page = 1, perPage = 50, filters) {
556
- const fk = filters ? JSON.stringify(filters) : '';
557
- return this.paginate('server_stats_requests', page, perPage, (query) => {
558
- if (filters?.method)
559
- query.where('method', filters.method);
560
- if (filters?.url)
561
- query.where('url', 'like', `%${filters.url}%`);
562
- if (filters?.status)
563
- query.where('status_code', filters.status);
564
- if (filters?.statusMin)
565
- query.where('status_code', '>=', filters.statusMin);
566
- if (filters?.statusMax)
567
- query.where('status_code', '<=', filters.statusMax);
568
- if (filters?.durationMin)
569
- query.where('duration', '>=', filters.durationMin);
570
- if (filters?.durationMax)
571
- query.where('duration', '<=', filters.durationMax);
572
- if (filters?.search) {
573
- const term = `%${filters.search}%`;
574
- query.where((qb) => {
575
- qb.where('url', 'like', term).orWhere('method', 'like', term);
576
- });
577
- }
578
- }, fk);
154
+ get readCtx() {
155
+ return { db: this.db, cache: this.cache };
579
156
  }
580
- /** Paginated query history with optional filters. */
581
- async getQueries(page = 1, perPage = 50, filters) {
582
- const fk = filters ? JSON.stringify(filters) : '';
583
- return this.paginate('server_stats_queries', page, perPage, (query) => {
584
- if (filters?.method)
585
- query.where('method', filters.method);
586
- if (filters?.model)
587
- query.where('model', filters.model);
588
- if (filters?.connection)
589
- query.where('connection', filters.connection);
590
- if (filters?.durationMin)
591
- query.where('duration', '>=', filters.durationMin);
592
- if (filters?.durationMax)
593
- query.where('duration', '<=', filters.durationMax);
594
- if (filters?.requestId)
595
- query.where('request_id', filters.requestId);
596
- if (filters?.search) {
597
- const term = `%${filters.search}%`;
598
- query.where((qb) => {
599
- qb.where('sql_text', 'like', term)
600
- .orWhere('model', 'like', term)
601
- .orWhere('connection', 'like', term);
602
- });
603
- }
604
- }, 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);
605
162
  }
606
- /**
607
- * Grouped query patterns: aggregated by sql_normalized
608
- * with count, avg/min/max/total duration.
609
- */
610
163
  async getQueriesGrouped(limit = 200, sort = 'total_duration', search) {
611
- if (!this.db)
612
- return [];
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
- });
164
+ return this.db ? queryQueriesGrouped(this.readCtx, { limit, sort, search }) : [];
633
165
  }
634
- /** Paginated event history with optional filters. */
635
- async getEvents(page = 1, perPage = 50, filters) {
636
- const fk = filters ? JSON.stringify(filters) : '';
637
- return this.paginate('server_stats_events', page, perPage, (query) => {
638
- if (filters?.eventName)
639
- query.where('event_name', 'like', `%${filters.eventName}%`);
640
- if (filters?.search) {
641
- query.where('event_name', 'like', `%${filters.search}%`);
642
- }
643
- }, fk);
166
+ async getEvents(p = 1, pp = 50, f) {
167
+ return this.db ? queryEvents(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
644
168
  }
645
- /** Paginated email history with optional filters. */
646
- async getEmails(page = 1, perPage = 50, filters, excludeBody = false) {
647
- const fk = (filters ? JSON.stringify(filters) : '') + (excludeBody ? ':noBody' : '');
648
- return this.paginate('server_stats_emails', page, perPage, (query) => {
649
- if (filters?.search) {
650
- const term = `%${filters.search}%`;
651
- query.where((sub) => {
652
- sub
653
- .where('from_addr', 'like', term)
654
- .orWhere('to_addr', 'like', term)
655
- .orWhere('subject', 'like', term);
656
- });
657
- }
658
- if (filters?.from)
659
- query.where('from_addr', 'like', `%${filters.from}%`);
660
- if (filters?.to)
661
- query.where('to_addr', 'like', `%${filters.to}%`);
662
- if (filters?.subject)
663
- query.where('subject', 'like', `%${filters.subject}%`);
664
- if (filters?.mailer)
665
- query.where('mailer', filters.mailer);
666
- if (filters?.status)
667
- query.where('status', filters.status);
668
- if (excludeBody) {
669
- query.select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at');
670
- }
671
- }, 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);
672
173
  }
673
- /** Get email HTML body for preview (falls back to text_body). */
674
174
  async getEmailHtml(id) {
675
- if (!this.db)
676
- return 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
- });
175
+ return this.db ? queryEmailHtml(this.readCtx, id) : null;
686
176
  }
687
- /**
688
- * Paginated log history with structured search support.
689
- *
690
- * Structured filters query into the JSON `data` column using
691
- * SQLite's `json_extract()`.
692
- */
693
- async getLogs(page = 1, perPage = 50, filters) {
694
- const fk = filters ? JSON.stringify(filters) : '';
695
- return this.paginate('server_stats_logs', page, perPage, (query) => {
696
- if (filters?.level)
697
- query.where('level', filters.level);
698
- if (filters?.requestId)
699
- query.where('request_id', filters.requestId);
700
- if (filters?.search) {
701
- query.where('message', 'like', `%${filters.search}%`);
702
- }
703
- if (filters?.structured && filters.structured.length > 0) {
704
- for (const sf of filters.structured) {
705
- const jsonPath = `$.${sf.field}`;
706
- switch (sf.operator) {
707
- case 'equals':
708
- query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, sf.value]);
709
- break;
710
- case 'contains':
711
- query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${sf.value}%`]);
712
- break;
713
- case 'startsWith':
714
- query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${sf.value}%`]);
715
- break;
716
- }
717
- }
718
- }
719
- }, fk);
177
+ async getLogs(p = 1, pp = 50, f) {
178
+ return this.db ? queryLogs(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
720
179
  }
721
- /** Paginated trace history with optional filters. */
722
- async getTraces(page = 1, perPage = 50, filters) {
723
- const fk = filters ? JSON.stringify(filters) : '';
724
- return this.paginate('server_stats_traces', page, perPage, (query) => {
725
- if (filters?.method)
726
- query.where('method', filters.method);
727
- if (filters?.url)
728
- query.where('url', 'like', `%${filters.url}%`);
729
- if (filters?.statusMin)
730
- query.where('status_code', '>=', filters.statusMin);
731
- if (filters?.statusMax)
732
- query.where('status_code', '<=', filters.statusMax);
733
- if (filters?.search) {
734
- const term = `%${filters.search}%`;
735
- query.where((qb) => {
736
- qb.where('url', 'like', term).orWhere('method', 'like', term);
737
- });
738
- }
739
- }, fk);
180
+ async getTraces(p = 1, pp = 50, f) {
181
+ return this.db ? queryTraces(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
740
182
  }
741
- /** Single trace with full span data. */
742
183
  async getTraceDetail(id) {
743
- if (!this.db)
744
- return null;
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
- });
184
+ return this.db ? queryTraceDetail(this.readCtx, id) : null;
755
185
  }
756
- /**
757
- * Single request with associated queries, events, and trace.
758
- * Wrapped in a transaction — 1 pool acquire instead of 4.
759
- */
760
186
  async getRequestDetail(id) {
761
- if (!this.db)
762
- return null;
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
- });
187
+ return this.db ? queryRequestDetail(this.readCtx, id) : null;
789
188
  }
790
- // =========================================================================
791
- // Overview & Charts
792
- // =========================================================================
793
- /**
794
- * Aggregated overview metrics for the dashboard cards.
795
- *
796
- * @param range — '1h' | '6h' | '24h' | '7d'
797
- */
798
- /**
799
- * Wrapped in a single transaction — 1 pool acquire instead of 5.
800
- */
801
189
  async getOverviewMetrics(range = '1h') {
802
- if (!this.db)
803
- return null;
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
- });
190
+ return this.db ? fetchOverviewMetrics(this.db, this.cache, range) : null;
877
191
  }
878
- /**
879
- * Time-series chart data from server_stats_metrics.
880
- *
881
- * @param range — '1h' | '6h' | '24h' | '7d'
882
- */
883
192
  async getChartData(range = '1h') {
884
- if (!this.db)
885
- return [];
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;
895
- }
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
- });
193
+ return this.db ? fetchChartData(this.db, this.cache, range) : [];
932
194
  }
933
- /**
934
- * Widget data for the dashboard overview.
935
- *
936
- * @param range — '1h' | '6h' | '24h' | '7d'
937
- */
938
195
  async getOverviewWidgets(range = '1h') {
939
- const empty = {
940
- topEvents: [],
941
- emailActivity: { sent: 0, queued: 0, failed: 0 },
942
- logLevelBreakdown: { error: 0, warn: 0, info: 0, debug: 0 },
943
- statusDistribution: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
944
- slowestQueries: [],
945
- };
946
- if (!this.db)
947
- return empty;
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' || status === 'sending')
989
- emailActivity.sent += count;
990
- else if (status === 'queued' || status === 'queueing')
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
- }
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 };
1017
- }
1018
- catch (err) {
1019
- if (!overviewWidgetWarned) {
1020
- overviewWidgetWarned = true;
1021
- log.warn('dashboard: getOverviewWidgets query failed — ' + err?.message);
1022
- }
1023
- return empty;
1024
- }
1025
- });
196
+ return this.db ? fetchOverviewWidgets(this.db, this.cache, range) : EMPTY_WIDGETS;
1026
197
  }
1027
- /** Get sparkline data points from pre-aggregated metrics. */
1028
198
  async getSparklineData(range) {
1029
- if (!this.db)
1030
- return [];
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
- });
199
+ return this.db ? fetchSparklineData(this.db, this.cache, range) : [];
1038
200
  }
1039
- // =========================================================================
1040
- // Saved filters CRUD
1041
- // =========================================================================
1042
201
  async getSavedFilters(section) {
1043
- if (!this.db)
1044
- return [];
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
- });
202
+ return this.db ? fetchSavedFilters(this.db, this.cache, section) : [];
1051
203
  }
1052
204
  async createSavedFilter(name, section, filterConfig) {
1053
- if (!this.db)
1054
- return null;
1055
- const [id] = await this.db('server_stats_saved_filters').insert({
1056
- name,
1057
- section,
1058
- filter_config: JSON.stringify(filterConfig),
1059
- });
1060
- return { id, name, section, filterConfig };
205
+ return this.db ? insertSavedFilter(this.db, name, section, filterConfig) : null;
1061
206
  }
1062
207
  async deleteSavedFilter(id) {
1063
- if (!this.db)
1064
- return false;
1065
- const deleted = await this.db('server_stats_saved_filters').where('id', id).delete();
1066
- return deleted > 0;
208
+ return this.db ? removeSavedFilter(this.db, id) : false;
1067
209
  }
1068
- // =========================================================================
1069
- // EXPLAIN
1070
- // =========================================================================
1071
- /**
1072
- * Run EXPLAIN on a stored query using the app's default database connection.
1073
- * Only allows SELECT queries for safety.
1074
- *
1075
- * @param queryId — ID from server_stats_queries
1076
- * @param appDb — The application's Lucid database manager
1077
- */
1078
210
  async runExplain(queryId, appDb) {
1079
211
  if (!this.db)
1080
212
  return { error: 'Dashboard store not initialized' };
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
- });
1097
- }
1098
- // =========================================================================
1099
- // Private helpers
1100
- // =========================================================================
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) {
1109
- if (!this.db) {
1110
- return { data: [], total: 0, page, perPage, lastPage: 0 };
1111
- }
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
- });
213
+ return executeExplain(this.db, this.cache, queryId, appDb);
1128
214
  }
1129
- /**
1130
- * Wire email event listeners to persist emails as they arrive.
1131
- */
1132
215
  wireEventListeners() {
1133
216
  if (!this.emitter || typeof this.emitter.on !== 'function') {
1134
217
  log.warn('dashboard: emitter not available — email collection disabled');
1135
218
  return;
1136
219
  }
1137
- const buildAndPersistEmail = (data, status) => {
1138
- const d = data;
1139
- const msg = (d?.message || d);
1140
- const record = {
1141
- from: extractAddresses(msg?.from) || 'unknown',
1142
- to: extractAddresses(msg?.to) || 'unknown',
1143
- cc: extractAddresses(msg?.cc) || null,
1144
- bcc: extractAddresses(msg?.bcc) || null,
1145
- subject: msg?.subject || '(no subject)',
1146
- html: msg?.html || null,
1147
- text: msg?.text || null,
1148
- mailer: d?.mailerName || d?.mailer || 'unknown',
1149
- status,
1150
- messageId: d?.response?.messageId ||
1151
- d?.messageId ||
1152
- null,
1153
- attachmentCount: Array.isArray(msg?.attachments)
1154
- ? msg.attachments.length
1155
- : 0,
1156
- timestamp: Date.now(),
1157
- };
1158
- this.recordEmail(record);
1159
- };
220
+ const persist = (data, status) => this.recordEmail(buildEmailRecordFromEvent(data, status));
1160
221
  this.handlers = [
1161
- { event: 'mail:sending', fn: (data) => buildAndPersistEmail(data, 'sending') },
1162
- { event: 'mail:sent', fn: (data) => buildAndPersistEmail(data, 'sent') },
1163
- { event: 'mail:queueing', fn: (data) => buildAndPersistEmail(data, 'queueing') },
1164
- { event: 'mail:queued', fn: (data) => buildAndPersistEmail(data, 'queued') },
1165
- { 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') },
1166
227
  ];
1167
- for (const h of this.handlers) {
228
+ for (const h of this.handlers)
1168
229
  this.emitter.on(h.event, h.fn);
1169
- }
1170
230
  log.info(`dashboard: email listeners wired (${this.handlers.length} events)`);
1171
231
  }
1172
232
  }
1173
- // ---------------------------------------------------------------------------
1174
- // Helpers
1175
- // ---------------------------------------------------------------------------
1176
- /**
1177
- * Normalize a SQL query by replacing literal values with `?` placeholders.
1178
- * Used for grouping identical query patterns.
1179
- */
1180
- function normalizeSql(sql) {
1181
- return sql
1182
- .replace(/'[^']*'/g, '?')
1183
- .replace(/\b\d+(\.\d+)?\b/g, '?')
1184
- .replace(/\s+/g, ' ')
1185
- .trim();
1186
- }