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.
- package/README.md +23 -14
- package/dist/core/config-utils.d.ts +8 -0
- package/dist/core/constants.d.ts +4 -0
- package/dist/core/dashboard-data-controller.d.ts +16 -0
- package/dist/core/dashboard-data-helpers.d.ts +12 -0
- package/dist/core/debug-data-controller.d.ts +4 -0
- package/dist/core/define-config-helpers.d.ts +25 -0
- package/dist/core/feature-detect-helpers.d.ts +36 -0
- package/dist/core/formatters-helpers.d.ts +23 -0
- package/dist/core/index.js +596 -509
- package/dist/core/log-utils-helpers.d.ts +13 -0
- package/dist/core/metrics.d.ts +3 -28
- package/dist/core/pagination.d.ts +0 -9
- package/dist/core/server-stats-controller.d.ts +6 -0
- package/dist/core/split-pane.d.ts +18 -0
- package/dist/core/trace-utils.d.ts +5 -0
- package/dist/core/transmit-helpers.d.ts +7 -0
- package/dist/core/types-dashboard.d.ts +178 -0
- package/dist/core/types-diagnostics.d.ts +85 -0
- package/dist/core/types.d.ts +11 -443
- package/dist/react/{CacheSection-xH75hwXu.js → CacheSection-baMZotSn.js} +2 -2
- package/dist/react/CacheTab-2cw_rMzj.js +117 -0
- package/dist/react/{ConfigSection-D8BO1Ry9.js → ConfigSection-DGgqjAal.js} +1 -1
- package/dist/react/{ConfigTab-CcN-tfjv.js → ConfigTab-H3OnYqmK.js} +1 -1
- package/dist/react/CustomPaneTab-B6r7ha0u.js +98 -0
- package/dist/react/{EmailsSection-BzlsTdPs.js → EmailsSection-C-UZISG-.js} +2 -2
- package/dist/react/EmailsTab-DbK4Eobn.js +139 -0
- package/dist/react/{EventsSection-CGQWiIdV.js → EventsSection-C7RQW_LY.js} +2 -2
- package/dist/react/EventsTab-CfVr7AiM.js +57 -0
- package/dist/react/{FilterBar-DQRXpWrb.js → FilterBar-CQ7bD669.js} +15 -15
- package/dist/react/{JobsSection-D7AHQmZi.js → JobsSection-CQHNK_Ls.js} +2 -2
- package/dist/react/{JobsTab-B3Lfdqed.js → JobsTab-znzf6jzk.js} +54 -42
- package/dist/react/{LogsSection-Cly1dpvS.js → LogsSection-Dmm3rE2B.js} +9 -3
- package/dist/react/LogsTab-D8unMV5P.js +108 -0
- package/dist/react/{OverviewSection-CkBGFEWq.js → OverviewSection-ABP9ueBo.js} +1 -1
- package/dist/react/{QueriesSection-CfCpnNUD.js → QueriesSection-CnmSkznA.js} +2 -2
- package/dist/react/{QueriesTab-DbBmAqzO.js → QueriesTab-BQzcxEiW.js} +37 -40
- package/dist/react/RelatedLogs-3A8RuGKH.js +52 -0
- package/dist/react/RequestsSection-kW79_M7k.js +341 -0
- package/dist/react/{RoutesSection-CRqF-cNM.js → RoutesSection-BRhxrtjZ.js} +2 -2
- package/dist/react/RoutesTab-CpYH5lUw.js +68 -0
- package/dist/react/TimelineTab-DjLR35Ce.js +214 -0
- package/dist/react/index-CsImORX6.js +1121 -0
- package/dist/react/index.js +1 -1
- package/dist/react/react/components/{Dashboard/shared → shared}/FilterBar.d.ts +4 -3
- package/dist/react/react/components/shared/RelatedLogs.d.ts +7 -0
- package/dist/react/react/hooks/useDashboardData.d.ts +4 -8
- package/dist/react/style.css +1 -1
- package/dist/src/collectors/app_collector.d.ts +0 -8
- package/dist/src/collectors/app_collector.js +45 -52
- package/dist/src/collectors/auto_detect.d.ts +0 -23
- package/dist/src/collectors/auto_detect.js +33 -55
- package/dist/src/collectors/db_pool_collector.d.ts +14 -16
- package/dist/src/collectors/db_pool_collector.js +72 -57
- package/dist/src/collectors/log_collector.d.ts +0 -47
- package/dist/src/collectors/log_collector.js +36 -65
- package/dist/src/collectors/queue_collector.d.ts +0 -20
- package/dist/src/collectors/queue_collector.js +60 -76
- package/dist/src/collectors/redis_collector.d.ts +10 -10
- package/dist/src/collectors/redis_collector.js +69 -66
- package/dist/src/config/deprecation_migration.d.ts +7 -0
- package/dist/src/config/deprecation_migration.js +201 -0
- package/dist/src/controller/debug_controller.d.ts +1 -1
- package/dist/src/controller/debug_controller.js +87 -81
- package/dist/src/dashboard/cache_handlers.d.ts +14 -0
- package/dist/src/dashboard/cache_handlers.js +52 -0
- package/dist/src/dashboard/chart_aggregator.d.ts +0 -7
- package/dist/src/dashboard/chart_aggregator.js +68 -50
- package/dist/src/dashboard/coalesce_cache.d.ts +25 -0
- package/dist/src/dashboard/coalesce_cache.js +47 -0
- package/dist/src/dashboard/dashboard_controller.d.ts +11 -37
- package/dist/src/dashboard/dashboard_controller.js +52 -532
- package/dist/src/dashboard/dashboard_page_assets.d.ts +17 -0
- package/dist/src/dashboard/dashboard_page_assets.js +51 -0
- package/dist/src/dashboard/dashboard_store.d.ts +19 -217
- package/dist/src/dashboard/dashboard_store.js +115 -1069
- package/dist/src/dashboard/dashboard_types.d.ts +83 -0
- package/dist/src/dashboard/dashboard_types.js +4 -0
- package/dist/src/dashboard/detail_queries.d.ts +19 -0
- package/dist/src/dashboard/detail_queries.js +98 -0
- package/dist/src/dashboard/email_event_builder.d.ts +8 -0
- package/dist/src/dashboard/email_event_builder.js +65 -0
- package/dist/src/dashboard/explain_query.d.ts +8 -0
- package/dist/src/dashboard/explain_query.js +22 -0
- package/dist/src/dashboard/filter_handlers.d.ts +23 -0
- package/dist/src/dashboard/filter_handlers.js +56 -0
- package/dist/src/dashboard/filtered_queries.d.ts +15 -0
- package/dist/src/dashboard/filtered_queries.js +155 -0
- package/dist/src/dashboard/flush_manager.d.ts +25 -0
- package/dist/src/dashboard/flush_manager.js +107 -0
- package/dist/src/dashboard/format_helpers.d.ts +126 -0
- package/dist/src/dashboard/format_helpers.js +140 -0
- package/dist/src/dashboard/inspector_manager.d.ts +36 -0
- package/dist/src/dashboard/inspector_manager.js +102 -0
- package/dist/src/dashboard/integrations/config_inspector.js +11 -13
- package/dist/src/dashboard/integrations/queue_inspector.d.ts +3 -3
- package/dist/src/dashboard/integrations/queue_inspector.js +13 -10
- package/dist/src/dashboard/jobs_handlers.d.ts +14 -0
- package/dist/src/dashboard/jobs_handlers.js +61 -0
- package/dist/src/dashboard/knex_factory.d.ts +18 -0
- package/dist/src/dashboard/knex_factory.js +91 -0
- package/dist/src/dashboard/migrator.js +30 -153
- package/dist/src/dashboard/migrator_tables.d.ts +19 -0
- package/dist/src/dashboard/migrator_tables.js +153 -0
- package/dist/src/dashboard/overview_queries.d.ts +66 -0
- package/dist/src/dashboard/overview_queries.js +155 -0
- package/dist/src/dashboard/overview_query_runners.d.ts +25 -0
- package/dist/src/dashboard/overview_query_runners.js +84 -0
- package/dist/src/dashboard/overview_store_queries.d.ts +40 -0
- package/dist/src/dashboard/overview_store_queries.js +69 -0
- package/dist/src/dashboard/paginate_helper.d.ts +12 -0
- package/dist/src/dashboard/paginate_helper.js +33 -0
- package/dist/src/dashboard/query_explain_handler.d.ts +10 -0
- package/dist/src/dashboard/query_explain_handler.js +80 -0
- package/dist/src/dashboard/read_queries.d.ts +32 -0
- package/dist/src/dashboard/read_queries.js +107 -0
- package/dist/src/dashboard/saved_filter_queries.d.ts +10 -0
- package/dist/src/dashboard/saved_filter_queries.js +24 -0
- package/dist/src/dashboard/storage_stats.d.ts +41 -0
- package/dist/src/dashboard/storage_stats.js +81 -0
- package/dist/src/dashboard/write_queue.d.ts +106 -0
- package/dist/src/dashboard/write_queue.js +225 -0
- package/dist/src/data/data_access.d.ts +6 -36
- package/dist/src/data/data_access.js +43 -188
- package/dist/src/data/data_access_helpers.d.ts +130 -0
- package/dist/src/data/data_access_helpers.js +212 -0
- package/dist/src/debug/debug_store.js +37 -32
- package/dist/src/debug/email_collector.d.ts +1 -10
- package/dist/src/debug/email_collector.js +78 -81
- package/dist/src/debug/event_collector.d.ts +0 -9
- package/dist/src/debug/event_collector.js +79 -62
- package/dist/src/debug/query_collector.js +23 -19
- package/dist/src/debug/route_inspector.d.ts +1 -5
- package/dist/src/debug/route_inspector.js +50 -51
- package/dist/src/debug/trace_collector.d.ts +10 -2
- package/dist/src/debug/trace_collector.js +23 -16
- package/dist/src/debug/types.d.ts +5 -1
- package/dist/src/define_config.d.ts +0 -65
- package/dist/src/define_config.js +93 -333
- package/dist/src/edge/client/dashboard.js +2 -2
- package/dist/src/edge/client/debug-panel-deferred.js +1 -1
- package/dist/src/edge/client/stats-bar.js +1 -1
- package/dist/src/edge/client-vue/dashboard.js +5 -5
- package/dist/src/edge/client-vue/debug-panel-deferred.js +3 -3
- package/dist/src/edge/client-vue/stats-bar.js +3 -3
- package/dist/src/edge/plugin.d.ts +0 -16
- package/dist/src/edge/plugin.js +57 -64
- package/dist/src/engine/request_metrics.d.ts +1 -0
- package/dist/src/engine/request_metrics.js +32 -42
- package/dist/src/middleware/request_tracking_middleware.d.ts +3 -8
- package/dist/src/middleware/request_tracking_middleware.js +65 -91
- package/dist/src/provider/auth_middleware_detector.d.ts +16 -0
- package/dist/src/provider/auth_middleware_detector.js +97 -0
- package/dist/src/provider/boot_helpers.d.ts +20 -0
- package/dist/src/provider/boot_helpers.js +91 -0
- package/dist/src/provider/boot_initializer.d.ts +28 -0
- package/dist/src/provider/boot_initializer.js +35 -0
- package/dist/src/provider/dashboard_init.d.ts +30 -0
- package/dist/src/provider/dashboard_init.js +138 -0
- package/dist/src/provider/dashboard_setup.d.ts +25 -0
- package/dist/src/provider/dashboard_setup.js +78 -0
- package/dist/src/provider/diagnostics.d.ts +134 -0
- package/dist/src/provider/diagnostics.js +127 -0
- package/dist/src/provider/email_bridge.d.ts +43 -0
- package/dist/src/provider/email_bridge.js +80 -0
- package/dist/src/provider/email_helpers.d.ts +13 -0
- package/dist/src/provider/email_helpers.js +68 -0
- package/dist/src/provider/pino_hook.d.ts +17 -0
- package/dist/src/provider/pino_hook.js +35 -0
- package/dist/src/provider/provider_helpers_extra.d.ts +47 -0
- package/dist/src/provider/provider_helpers_extra.js +177 -0
- package/dist/src/provider/server_stats_provider.d.ts +39 -85
- package/dist/src/provider/server_stats_provider.js +131 -936
- package/dist/src/provider/shutdown_helpers.d.ts +43 -0
- package/dist/src/provider/shutdown_helpers.js +70 -0
- package/dist/src/provider/toolbar_setup.d.ts +57 -0
- package/dist/src/provider/toolbar_setup.js +141 -0
- package/dist/src/routes/dashboard_routes.d.ts +14 -0
- package/dist/src/routes/dashboard_routes.js +197 -0
- package/dist/src/routes/debug_routes.d.ts +14 -0
- package/dist/src/routes/debug_routes.js +101 -0
- package/dist/src/routes/register_routes.d.ts +0 -78
- package/dist/src/routes/register_routes.js +22 -347
- package/dist/src/routes/stats_routes.d.ts +5 -0
- package/dist/src/routes/stats_routes.js +14 -0
- package/dist/src/styles/components.css +177 -0
- package/dist/src/styles/dashboard.css +8 -90
- package/dist/src/styles/debug-panel.css +10 -31
- package/dist/src/types.d.ts +306 -15
- package/dist/vue/{CacheSection-Cx-hj09X.js → CacheSection-ITqvpfH5.js} +1 -1
- package/dist/vue/{ConfigSection-CMXyryf6.js → ConfigSection-DTn3GslE.js} +1 -1
- package/dist/vue/{EmailsSection-DgKl9xGT.js → EmailsSection-DtLJ4XoS.js} +1 -1
- package/dist/vue/{EventsSection-BNMCAim1.js → EventsSection-BOYYz0Ty.js} +1 -1
- package/dist/vue/{JobsSection-CCMgMlxd.js → JobsSection-BazTxcJL.js} +1 -1
- package/dist/vue/{LogsSection-CvOnTxUu.js → LogsSection-D55PjTKX.js} +9 -3
- package/dist/vue/{LogsTab-Bg3o0Mm6.js → LogsTab-47zEK7jL.js} +4 -1
- package/dist/vue/{OverviewSection-CHgaKtUR.js → OverviewSection-1uBKo-Tu.js} +1 -1
- package/dist/vue/{QueriesSection-BnHRD98z.js → QueriesSection-rpoZ4ogd.js} +1 -1
- package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-CB2_TzYW.js +84 -0
- package/dist/vue/RequestsSection-x7LvT0MC.js +401 -0
- package/dist/vue/{RoutesSection-BrceOcKQ.js → RoutesSection-CCD0zZqQ.js} +1 -1
- package/dist/vue/TimelineTab-zj5Z5OdT.js +338 -0
- package/dist/vue/components/Dashboard/sections/RequestsSection.vue.d.ts +4 -0
- package/dist/vue/components/DebugPanel/tabs/TimelineTab.vue.d.ts +4 -0
- package/dist/vue/components/{Dashboard/sections/TimelineSection.vue.d.ts → shared/RelatedLogs.vue.d.ts} +5 -6
- package/dist/vue/composables/useDashboardData.d.ts +12 -23
- package/dist/vue/index-C8MxnS7Q.js +1232 -0
- package/dist/vue/index.js +1 -1
- package/dist/vue/style.css +1 -1
- package/package.json +1 -1
- package/dist/react/CacheTab-DYmsZJJ1.js +0 -123
- package/dist/react/CustomPaneTab-D7_o3Ec6.js +0 -104
- package/dist/react/EmailsTab-Uh2CQY3o.js +0 -153
- package/dist/react/EventsTab-CC6DQzEm.js +0 -63
- package/dist/react/LogsTab-BbYK-iyh.js +0 -103
- package/dist/react/RequestsSection-Cb5a6MlT.js +0 -209
- package/dist/react/RoutesTab-Bwreij3e.js +0 -74
- package/dist/react/TimelineSection-B2y06kRE.js +0 -158
- package/dist/react/TimelineTab-6hthfdBB.js +0 -193
- package/dist/react/WaterfallChart-Cj73WdfM.js +0 -100
- package/dist/react/index-CecA4IdQ.js +0 -1075
- package/dist/react/react/components/Dashboard/sections/TimelineSection.d.ts +0 -8
- package/dist/vue/RequestsSection-B-uSlM0f.js +0 -243
- package/dist/vue/TimelineSection-CfvnA2Oo.js +0 -186
- package/dist/vue/TimelineTab-Db6lKKsD.js +0 -250
- package/dist/vue/WaterfallChart.vue_vue_type_script_setup_true_lang-tZ13cNj1.js +0 -118
- package/dist/vue/index-oLxS08vN.js +0 -1235
|
@@ -1,27 +1,31 @@
|
|
|
1
|
-
import { mkdir
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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(() =>
|
|
191
|
-
this.retentionTimer = setInterval(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
124
|
+
Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_TTL)
|
|
258
125
|
return this.cachedStorageStats.data;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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) =>
|
|
1162
|
-
{ event: 'mail:sent', fn: (data) =>
|
|
1163
|
-
{ event: 'mail:queueing', fn: (data) =>
|
|
1164
|
-
{ event: 'mail:queued', fn: (data) =>
|
|
1165
|
-
{ event: 'queued:mail:error', fn: (data) =>
|
|
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
|
-
}
|