adonisjs-server-stats 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/field-resolvers.d.ts +64 -0
- package/dist/core/formatters-helpers.d.ts +23 -0
- package/dist/core/formatters.d.ts +15 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +599 -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/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 +10 -442
- package/dist/react/CacheSection-BYN53kYO.js +135 -0
- package/dist/react/CacheStatsBar-CRodCOeP.js +27 -0
- package/dist/react/CacheTab-DOhuK05d.js +106 -0
- package/dist/react/{ConfigSection-DfFd-WRq.js → ConfigSection-B9EHh4Rp.js} +1 -1
- package/dist/react/{ConfigTab-Bdg8YMer.js → ConfigTab-C8kriE2b.js} +1 -1
- package/dist/react/CustomPaneTab-CvzQS_Wh.js +99 -0
- package/dist/react/EmailPreviewOverlay-BmXOAvqG.js +58 -0
- package/dist/react/EmailsSection-BJyFJf7A.js +226 -0
- package/dist/react/EmailsTab-Ch8jp10B.js +110 -0
- package/dist/react/{EventsSection-ByQ-9blq.js → EventsSection-DJPwHeT8.js} +28 -27
- package/dist/react/EventsTab-B-FoehXC.js +58 -0
- package/dist/react/{FilterBar-DQRXpWrb.js → FilterBar-CQ7bD669.js} +15 -15
- package/dist/react/{InternalsContent-DBzsI0CG.js → InternalsContent-O8ino9oM.js} +133 -109
- package/dist/react/InternalsSection-B6VlVx5f.js +22 -0
- package/dist/react/InternalsTab-CkEKpRMU.js +17 -0
- package/dist/react/JobStatsBar-C7RslAFE.js +30 -0
- package/dist/react/JobsSection-DWF4i1t_.js +167 -0
- package/dist/react/JobsTab-DqnifQXV.js +129 -0
- package/dist/react/LogEntryRow-CMMkqA9M.js +43 -0
- package/dist/react/LogsSection-C1xC5aP4.js +198 -0
- package/dist/react/LogsTab-CS4sLfLw.js +79 -0
- package/dist/react/{OverviewSection-C4T1ur51.js → OverviewSection-CxvfOR0v.js} +70 -80
- package/dist/react/QueriesSection-CrMdU5Ax.js +458 -0
- package/dist/react/{QueriesTab-osLUWd4L.js → QueriesTab-x85PjkyS.js} +38 -40
- package/dist/react/RequestsSection-DETN9oZb.js +321 -0
- package/dist/react/{RoutesSection-BUSkM6PY.js → RoutesSection-CmorkJeC.js} +2 -2
- package/dist/react/RoutesTab-CbzBOzpc.js +68 -0
- package/dist/react/SplitPaneWrapper-BiIgT4ND.js +49 -0
- package/dist/react/TimeAgoCell-o3KigGfM.js +8 -0
- package/dist/react/{TimelineTab-Covg5weo.js → TimelineTab-Ue9tUD_n.js} +76 -102
- package/dist/react/index-DwDK-4oX.js +1121 -0
- package/dist/react/index.js +6 -6
- package/dist/react/react/components/shared/CacheStatsBar.d.ts +13 -0
- package/dist/react/react/components/shared/EmailPreviewOverlay.d.ts +29 -0
- package/dist/react/react/components/{Dashboard/shared → shared}/FilterBar.d.ts +4 -3
- package/dist/react/react/components/shared/JobStatsBar.d.ts +12 -0
- package/dist/react/react/components/shared/LogEntryRow.d.ts +9 -0
- package/dist/react/react/components/shared/RelatedLogs.d.ts +2 -2
- package/dist/react/react/components/shared/SplitPaneWrapper.d.ts +7 -0
- package/dist/react/react/components/shared/TimeAgoCell.d.ts +17 -0
- package/dist/react/react/hooks/useDashboardData.d.ts +4 -8
- package/dist/react/react/hooks/useDiagnosticsData.d.ts +14 -0
- 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 +51 -544
- 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 -218
- package/dist/src/dashboard/dashboard_store.js +115 -1116
- 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 -159
- 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 +2 -39
- package/dist/src/data/data_access.js +17 -193
- 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 +9 -1
- package/dist/src/debug/trace_collector.js +21 -15
- package/dist/src/debug/types.d.ts +1 -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 +4 -4
- 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 +2 -8
- package/dist/src/middleware/request_tracking_middleware.js +65 -93
- 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 +132 -951
- 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 -352
- 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 +163 -0
- package/dist/src/styles/dashboard.css +13 -105
- package/dist/src/styles/debug-panel.css +2 -53
- package/dist/src/styles/utilities.css +3 -1
- package/dist/src/types.d.ts +305 -14
- package/dist/vue/{CacheSection-oFAJL3mo.js → CacheSection-DT2Mwf_s.js} +1 -1
- package/dist/vue/{ConfigSection-BhfJ4KqL.js → ConfigSection-BwKwS9lh.js} +1 -1
- package/dist/vue/CustomPaneTab-Hr1IBHfz.js +172 -0
- package/dist/vue/{EmailsSection-BcNyhyHs.js → EmailsSection-B65g0FVS.js} +1 -1
- package/dist/vue/{EventsSection-r60Q5Lmu.js → EventsSection-CxqtVF-o.js} +1 -1
- package/dist/vue/{JobsSection-BHL-hkQw.js → JobsSection-rMIyMb-g.js} +1 -1
- package/dist/vue/{LogsSection-DRMGzJmg.js → LogsSection-DmmZVJ7D.js} +9 -3
- package/dist/vue/{LogsTab-Bg3o0Mm6.js → LogsTab-47zEK7jL.js} +4 -1
- package/dist/vue/{OverviewSection-CXh6Ja1B.js → OverviewSection-BMabyqw-.js} +49 -50
- package/dist/vue/{QueriesSection-IodIsCJ-.js → QueriesSection-BfDFwGqH.js} +44 -45
- package/dist/vue/{QueriesTab-C8_7oprC.js → QueriesTab-DuTG7cpC.js} +30 -31
- package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-Py1iu9GU.js +77 -0
- package/dist/vue/{RequestsSection-BPuMdmMc.js → RequestsSection-CTu4jPZ_.js} +143 -147
- package/dist/vue/{RoutesSection-NKo3Rbq3.js → RoutesSection-zQZDedL7.js} +1 -1
- package/dist/vue/TimelineTab-DHfXsX7t.js +334 -0
- package/dist/vue/components/shared/RelatedLogs.vue.d.ts +1 -4
- package/dist/vue/composables/useDashboardData.d.ts +12 -23
- package/dist/vue/index-CM3yNVUR.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/CacheSection-UCMptWyn.js +0 -146
- package/dist/react/CacheTab-CA8LB1J5.js +0 -123
- package/dist/react/CustomPaneTab-Bxtv_8Rw.js +0 -104
- package/dist/react/EmailsSection-CM7stSyh.js +0 -262
- package/dist/react/EmailsTab-BDhEiomM.js +0 -153
- package/dist/react/EventsTab-CMfY98Rl.js +0 -63
- package/dist/react/InternalsSection-t7ihcWO-.js +0 -32
- package/dist/react/InternalsTab-Oij0A2fN.js +0 -30
- package/dist/react/JobsSection-DF3qEv9O.js +0 -187
- package/dist/react/JobsTab-BbrBWIOb.js +0 -141
- package/dist/react/LogsSection-DcFTZY7b.js +0 -227
- package/dist/react/LogsTab-CicucmVk.js +0 -103
- package/dist/react/QueriesSection-PswteoF9.js +0 -461
- package/dist/react/RelatedLogs-DFDOyUMr.js +0 -40
- package/dist/react/RequestsSection-Nag30rEA.js +0 -341
- package/dist/react/RoutesTab-DgVzd2PZ.js +0 -74
- package/dist/react/index-Cflz9Ebj.js +0 -1069
- package/dist/vue/CustomPaneTab-BJxT5Dp7.js +0 -172
- package/dist/vue/RelatedLogs.vue_vue_type_script_setup_true_lang-CB2_TzYW.js +0 -84
- package/dist/vue/TimelineTab-zj5Z5OdT.js +0 -338
- package/dist/vue/index-Dtgysd26.js +0 -1229
|
@@ -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,1007 +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();
|
|
149
|
+
this.flushMgr.recordEmail(record);
|
|
364
150
|
}
|
|
365
|
-
/** Schedule the next batch flush if not already scheduled. */
|
|
366
|
-
scheduleFlush() {
|
|
367
|
-
if (this.flushTimer)
|
|
368
|
-
return;
|
|
369
|
-
this.flushTimer = setTimeout(() => {
|
|
370
|
-
this.flushTimer = null;
|
|
371
|
-
this.flushWriteQueue().catch((err) => {
|
|
372
|
-
if (!warnedWritePaths.has('flush')) {
|
|
373
|
-
warnedWritePaths.add('flush');
|
|
374
|
-
log.warn(`dashboard: flush failed — ${err?.message}`);
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
}, DashboardStore.FLUSH_INTERVAL_MS);
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Flush all pending writes in a single transaction.
|
|
381
|
-
*
|
|
382
|
-
* A transaction acquires the pool connection ONCE, runs all INSERTs
|
|
383
|
-
* (synchronous via better-sqlite3), then releases. Without a
|
|
384
|
-
* transaction, each INSERT does its own async acquire/release cycle —
|
|
385
|
-
* under load this creates hundreds of microtasks that starve the
|
|
386
|
-
* event loop and freeze the server.
|
|
387
|
-
*/
|
|
388
151
|
async flushWriteQueue() {
|
|
389
|
-
|
|
390
|
-
return;
|
|
391
|
-
this.flushing = true;
|
|
392
|
-
// Snapshot and clear the queues
|
|
393
|
-
const requests = this.writeQueue.splice(0);
|
|
394
|
-
const logs = this.pendingLogs.splice(0);
|
|
395
|
-
const events = this.pendingEvents.splice(0);
|
|
396
|
-
const emails = this.pendingEmails.splice(0);
|
|
397
|
-
if (requests.length === 0 && logs.length === 0 && events.length === 0 && emails.length === 0) {
|
|
398
|
-
this.flushing = false;
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
try {
|
|
402
|
-
// Pre-stringify JSON OUTSIDE the transaction so the synchronous
|
|
403
|
-
// better-sqlite3 execution doesn't block the event loop on large spans.
|
|
404
|
-
const preparedRequests = requests.map((input) => ({
|
|
405
|
-
input,
|
|
406
|
-
filteredQueries: input.queries
|
|
407
|
-
.filter((q) => q.connection !== 'server_stats')
|
|
408
|
-
.map((q) => ({
|
|
409
|
-
sql_text: q.sql,
|
|
410
|
-
sql_normalized: normalizeSql(q.sql),
|
|
411
|
-
bindings: q.bindings ? JSON.stringify(q.bindings) : null,
|
|
412
|
-
duration: round(q.duration),
|
|
413
|
-
method: q.method,
|
|
414
|
-
model: q.model,
|
|
415
|
-
connection: q.connection,
|
|
416
|
-
in_transaction: q.inTransaction ? 1 : 0,
|
|
417
|
-
})),
|
|
418
|
-
traceRow: input.trace
|
|
419
|
-
? {
|
|
420
|
-
method: input.trace.method,
|
|
421
|
-
url: input.trace.url,
|
|
422
|
-
status_code: input.trace.statusCode,
|
|
423
|
-
total_duration: round(input.trace.totalDuration),
|
|
424
|
-
span_count: input.trace.spanCount,
|
|
425
|
-
spans: JSON.stringify(input.trace.spans),
|
|
426
|
-
warnings: input.trace.warnings.length > 0 ? JSON.stringify(input.trace.warnings) : null,
|
|
427
|
-
}
|
|
428
|
-
: null,
|
|
429
|
-
}));
|
|
430
|
-
const preparedLogs = logs.map((entry) => {
|
|
431
|
-
const levelName = typeof entry.levelName === 'string' ? entry.levelName : String(entry.level || 'unknown');
|
|
432
|
-
return {
|
|
433
|
-
level: levelName,
|
|
434
|
-
message: String(entry.msg || entry.message || ''),
|
|
435
|
-
request_id: entry.request_id || entry.requestId || entry['x-request-id']
|
|
436
|
-
? String(entry.request_id || entry.requestId || entry['x-request-id'])
|
|
437
|
-
: null,
|
|
438
|
-
data: JSON.stringify(entry),
|
|
439
|
-
};
|
|
440
|
-
});
|
|
441
|
-
await this.db.transaction(async (trx) => {
|
|
442
|
-
// -- Requests + queries + traces --
|
|
443
|
-
for (const { input, filteredQueries, traceRow } of preparedRequests) {
|
|
444
|
-
try {
|
|
445
|
-
const row = {
|
|
446
|
-
method: input.method,
|
|
447
|
-
url: input.url,
|
|
448
|
-
status_code: input.statusCode,
|
|
449
|
-
duration: round(input.duration),
|
|
450
|
-
span_count: input.trace?.spanCount ?? 0,
|
|
451
|
-
warning_count: input.trace?.warnings?.length ?? 0,
|
|
452
|
-
};
|
|
453
|
-
if (input.httpRequestId)
|
|
454
|
-
row.http_request_id = String(input.httpRequestId);
|
|
455
|
-
const [requestId] = await trx('server_stats_requests').insert(row);
|
|
456
|
-
if (requestId !== null && requestId !== undefined && filteredQueries.length > 0) {
|
|
457
|
-
const rows = filteredQueries.map((q) => ({ ...q, request_id: requestId }));
|
|
458
|
-
for (let i = 0; i < rows.length; i += 50) {
|
|
459
|
-
await trx('server_stats_queries').insert(rows.slice(i, i + 50));
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
if (requestId !== null && requestId !== undefined && traceRow) {
|
|
463
|
-
await trx('server_stats_traces').insert({ ...traceRow, request_id: requestId });
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
catch (err) {
|
|
467
|
-
if (!warnedWritePaths.has('persistRequest')) {
|
|
468
|
-
warnedWritePaths.add('persistRequest');
|
|
469
|
-
log.warn(`dashboard: persistRequest failed — ${err?.message}`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
// -- Events --
|
|
474
|
-
for (const { events: evts } of events) {
|
|
475
|
-
try {
|
|
476
|
-
const rows = evts.map((e) => ({
|
|
477
|
-
request_id: null,
|
|
478
|
-
event_name: e.event,
|
|
479
|
-
data: e.data,
|
|
480
|
-
}));
|
|
481
|
-
for (let i = 0; i < rows.length; i += 50) {
|
|
482
|
-
await trx('server_stats_events').insert(rows.slice(i, i + 50));
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
catch (err) {
|
|
486
|
-
if (!warnedWritePaths.has('recordEvents')) {
|
|
487
|
-
warnedWritePaths.add('recordEvents');
|
|
488
|
-
log.warn(`dashboard: recordEvents failed — ${err?.message}`);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
// -- Emails --
|
|
493
|
-
if (emails.length > 0) {
|
|
494
|
-
try {
|
|
495
|
-
const rows = emails.map((record) => ({
|
|
496
|
-
from_addr: record.from,
|
|
497
|
-
to_addr: record.to,
|
|
498
|
-
cc: record.cc,
|
|
499
|
-
bcc: record.bcc,
|
|
500
|
-
subject: record.subject,
|
|
501
|
-
html: record.html,
|
|
502
|
-
text_body: record.text,
|
|
503
|
-
mailer: record.mailer,
|
|
504
|
-
status: record.status,
|
|
505
|
-
message_id: record.messageId,
|
|
506
|
-
attachment_count: record.attachmentCount,
|
|
507
|
-
}));
|
|
508
|
-
for (let i = 0; i < rows.length; i += 50) {
|
|
509
|
-
await trx('server_stats_emails').insert(rows.slice(i, i + 50));
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
catch (err) {
|
|
513
|
-
if (!warnedWritePaths.has('recordEmail')) {
|
|
514
|
-
warnedWritePaths.add('recordEmail');
|
|
515
|
-
log.warn(`dashboard: recordEmail failed — ${err?.message}`);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
// -- Logs --
|
|
520
|
-
if (preparedLogs.length > 0) {
|
|
521
|
-
try {
|
|
522
|
-
for (let i = 0; i < preparedLogs.length; i += 50) {
|
|
523
|
-
await trx('server_stats_logs').insert(preparedLogs.slice(i, i + 50));
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
catch (err) {
|
|
527
|
-
if (!warnedWritePaths.has('recordLog')) {
|
|
528
|
-
warnedWritePaths.add('recordLog');
|
|
529
|
-
log.warn(`dashboard: recordLog failed — ${err?.message}`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
catch (err) {
|
|
536
|
-
if (!warnedWritePaths.has('flush')) {
|
|
537
|
-
warnedWritePaths.add('flush');
|
|
538
|
-
log.warn(`dashboard: flush transaction failed — ${err?.message}`);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
finally {
|
|
542
|
-
this.flushing = false;
|
|
543
|
-
}
|
|
544
|
-
// Yield to the event loop after the transaction so HTTP requests
|
|
545
|
-
// and timers get a chance to run between flush cycles.
|
|
546
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
547
|
-
// If more data arrived during flush, schedule another
|
|
548
|
-
if (this.writeQueue.length > 0 ||
|
|
549
|
-
this.pendingLogs.length > 0 ||
|
|
550
|
-
this.pendingEmails.length > 0) {
|
|
551
|
-
this.scheduleFlush();
|
|
552
|
-
}
|
|
152
|
+
return this.flushMgr.flush();
|
|
553
153
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
// =========================================================================
|
|
557
|
-
/** Paginated request history with optional filters. */
|
|
558
|
-
async getRequests(page = 1, perPage = 50, filters) {
|
|
559
|
-
const fk = filters ? JSON.stringify(filters) : '';
|
|
560
|
-
return this.paginate('server_stats_requests', page, perPage, (query) => {
|
|
561
|
-
if (filters?.method)
|
|
562
|
-
query.where('method', filters.method);
|
|
563
|
-
if (filters?.url)
|
|
564
|
-
query.where('url', 'like', `%${filters.url}%`);
|
|
565
|
-
if (filters?.status)
|
|
566
|
-
query.where('status_code', filters.status);
|
|
567
|
-
if (filters?.statusMin)
|
|
568
|
-
query.where('status_code', '>=', filters.statusMin);
|
|
569
|
-
if (filters?.statusMax)
|
|
570
|
-
query.where('status_code', '<=', filters.statusMax);
|
|
571
|
-
if (filters?.durationMin)
|
|
572
|
-
query.where('duration', '>=', filters.durationMin);
|
|
573
|
-
if (filters?.durationMax)
|
|
574
|
-
query.where('duration', '<=', filters.durationMax);
|
|
575
|
-
if (filters?.search) {
|
|
576
|
-
const term = `%${filters.search}%`;
|
|
577
|
-
query.where((qb) => {
|
|
578
|
-
qb.where('url', 'like', term).orWhere('method', 'like', term);
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}, fk);
|
|
154
|
+
get readCtx() {
|
|
155
|
+
return { db: this.db, cache: this.cache };
|
|
582
156
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
query.where('method', filters.method);
|
|
589
|
-
if (filters?.model)
|
|
590
|
-
query.where('model', filters.model);
|
|
591
|
-
if (filters?.connection)
|
|
592
|
-
query.where('connection', filters.connection);
|
|
593
|
-
if (filters?.durationMin)
|
|
594
|
-
query.where('duration', '>=', filters.durationMin);
|
|
595
|
-
if (filters?.durationMax)
|
|
596
|
-
query.where('duration', '<=', filters.durationMax);
|
|
597
|
-
if (filters?.requestId)
|
|
598
|
-
query.where('request_id', filters.requestId);
|
|
599
|
-
if (filters?.search) {
|
|
600
|
-
const term = `%${filters.search}%`;
|
|
601
|
-
query.where((qb) => {
|
|
602
|
-
qb.where('sql_text', 'like', term)
|
|
603
|
-
.orWhere('model', 'like', term)
|
|
604
|
-
.orWhere('connection', 'like', term);
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
}, fk);
|
|
157
|
+
async getRequests(p = 1, pp = 50, f) {
|
|
158
|
+
return this.db ? queryRequests(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
|
|
159
|
+
}
|
|
160
|
+
async getQueries(p = 1, pp = 50, f) {
|
|
161
|
+
return this.db ? queryQueries(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
|
|
608
162
|
}
|
|
609
|
-
/**
|
|
610
|
-
* Grouped query patterns: aggregated by sql_normalized
|
|
611
|
-
* with count, avg/min/max/total duration.
|
|
612
|
-
*/
|
|
613
163
|
async getQueriesGrouped(limit = 200, sort = 'total_duration', search) {
|
|
614
|
-
|
|
615
|
-
return [];
|
|
616
|
-
return this.cached('queriesGrouped:' + limit + ':' + sort + ':' + (search || ''), DashboardStore.QUERIES_GROUPED_CACHE_TTL_MS, async () => {
|
|
617
|
-
const validSorts = {
|
|
618
|
-
count: 'count',
|
|
619
|
-
avg_duration: 'avg_duration',
|
|
620
|
-
total_duration: 'total_duration',
|
|
621
|
-
};
|
|
622
|
-
const orderCol = validSorts[sort] || 'total_duration';
|
|
623
|
-
// Apply a time cutoff to avoid scanning the entire table
|
|
624
|
-
const cutoff = rangeToCutoff('7d');
|
|
625
|
-
const query = this.db('server_stats_queries')
|
|
626
|
-
.select('sql_normalized', this.db.raw('COUNT(*) as count'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'), this.db.raw('ROUND(MIN(duration), 2) as min_duration'), this.db.raw('ROUND(MAX(duration), 2) as max_duration'), this.db.raw('ROUND(SUM(duration), 2) as total_duration'))
|
|
627
|
-
.where('created_at', '>=', cutoff)
|
|
628
|
-
.groupBy('sql_normalized')
|
|
629
|
-
.orderBy(orderCol, 'desc')
|
|
630
|
-
.limit(limit);
|
|
631
|
-
if (search) {
|
|
632
|
-
query.where('sql_normalized', 'like', `%${search}%`);
|
|
633
|
-
}
|
|
634
|
-
return query;
|
|
635
|
-
});
|
|
164
|
+
return this.db ? queryQueriesGrouped(this.readCtx, { limit, sort, search }) : [];
|
|
636
165
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const fk = filters ? JSON.stringify(filters) : '';
|
|
640
|
-
return this.paginate('server_stats_events', page, perPage, (query) => {
|
|
641
|
-
if (filters?.eventName)
|
|
642
|
-
query.where('event_name', 'like', `%${filters.eventName}%`);
|
|
643
|
-
if (filters?.search) {
|
|
644
|
-
query.where('event_name', 'like', `%${filters.search}%`);
|
|
645
|
-
}
|
|
646
|
-
}, fk);
|
|
166
|
+
async getEvents(p = 1, pp = 50, f) {
|
|
167
|
+
return this.db ? queryEvents(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
|
|
647
168
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
if (filters?.search) {
|
|
653
|
-
const term = `%${filters.search}%`;
|
|
654
|
-
query.where((sub) => {
|
|
655
|
-
sub
|
|
656
|
-
.where('from_addr', 'like', term)
|
|
657
|
-
.orWhere('to_addr', 'like', term)
|
|
658
|
-
.orWhere('subject', 'like', term);
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
if (filters?.from)
|
|
662
|
-
query.where('from_addr', 'like', `%${filters.from}%`);
|
|
663
|
-
if (filters?.to)
|
|
664
|
-
query.where('to_addr', 'like', `%${filters.to}%`);
|
|
665
|
-
if (filters?.subject)
|
|
666
|
-
query.where('subject', 'like', `%${filters.subject}%`);
|
|
667
|
-
if (filters?.mailer)
|
|
668
|
-
query.where('mailer', filters.mailer);
|
|
669
|
-
if (filters?.status)
|
|
670
|
-
query.where('status', filters.status);
|
|
671
|
-
if (excludeBody) {
|
|
672
|
-
query.select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at');
|
|
673
|
-
}
|
|
674
|
-
}, fk);
|
|
169
|
+
async getEmails(p = 1, pp = 50, f, excludeBody = false) {
|
|
170
|
+
return this.db
|
|
171
|
+
? queryEmails(this.readCtx, { page: p, perPage: pp, filters: f, excludeBody })
|
|
172
|
+
: EMPTY_PAGINATED(p, pp);
|
|
675
173
|
}
|
|
676
|
-
/** Get email HTML body for preview (falls back to text_body). */
|
|
677
174
|
async getEmailHtml(id) {
|
|
678
|
-
|
|
679
|
-
return null;
|
|
680
|
-
return this.coalesce('emailHtml:' + id, async () => {
|
|
681
|
-
const row = await this.db('server_stats_emails')
|
|
682
|
-
.where('id', id)
|
|
683
|
-
.select('html', 'text_body')
|
|
684
|
-
.first();
|
|
685
|
-
if (!row)
|
|
686
|
-
return null;
|
|
687
|
-
return row.html || row.text_body || null;
|
|
688
|
-
});
|
|
175
|
+
return this.db ? queryEmailHtml(this.readCtx, id) : null;
|
|
689
176
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
*
|
|
693
|
-
* Structured filters query into the JSON `data` column using
|
|
694
|
-
* SQLite's `json_extract()`.
|
|
695
|
-
*/
|
|
696
|
-
async getLogs(page = 1, perPage = 50, filters) {
|
|
697
|
-
const fk = filters ? JSON.stringify(filters) : '';
|
|
698
|
-
return this.paginate('server_stats_logs', page, perPage, (query) => {
|
|
699
|
-
if (filters?.level)
|
|
700
|
-
query.where('level', filters.level);
|
|
701
|
-
if (filters?.requestId)
|
|
702
|
-
query.where('request_id', filters.requestId);
|
|
703
|
-
if (filters?.search) {
|
|
704
|
-
query.where('message', 'like', `%${filters.search}%`);
|
|
705
|
-
}
|
|
706
|
-
if (filters?.structured && filters.structured.length > 0) {
|
|
707
|
-
for (const sf of filters.structured) {
|
|
708
|
-
const jsonPath = `$.${sf.field}`;
|
|
709
|
-
switch (sf.operator) {
|
|
710
|
-
case 'equals':
|
|
711
|
-
query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, sf.value]);
|
|
712
|
-
break;
|
|
713
|
-
case 'contains':
|
|
714
|
-
query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${sf.value}%`]);
|
|
715
|
-
break;
|
|
716
|
-
case 'startsWith':
|
|
717
|
-
query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${sf.value}%`]);
|
|
718
|
-
break;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}, fk);
|
|
177
|
+
async getLogs(p = 1, pp = 50, f) {
|
|
178
|
+
return this.db ? queryLogs(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
|
|
723
179
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const fk = filters ? JSON.stringify(filters) : '';
|
|
727
|
-
return this.paginate('server_stats_traces', page, perPage, (query) => {
|
|
728
|
-
if (filters?.method)
|
|
729
|
-
query.where('method', filters.method);
|
|
730
|
-
if (filters?.url)
|
|
731
|
-
query.where('url', 'like', `%${filters.url}%`);
|
|
732
|
-
if (filters?.statusMin)
|
|
733
|
-
query.where('status_code', '>=', filters.statusMin);
|
|
734
|
-
if (filters?.statusMax)
|
|
735
|
-
query.where('status_code', '<=', filters.statusMax);
|
|
736
|
-
if (filters?.search) {
|
|
737
|
-
const term = `%${filters.search}%`;
|
|
738
|
-
query.where((qb) => {
|
|
739
|
-
qb.where('url', 'like', term).orWhere('method', 'like', term);
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
}, fk);
|
|
180
|
+
async getTraces(p = 1, pp = 50, f) {
|
|
181
|
+
return this.db ? queryTraces(this.readCtx, p, pp, f) : EMPTY_PAGINATED(p, pp);
|
|
743
182
|
}
|
|
744
|
-
/** Single trace with full span data. */
|
|
745
183
|
async getTraceDetail(id) {
|
|
746
|
-
|
|
747
|
-
return null;
|
|
748
|
-
return this.coalesce('traceDetail:' + id, async () => {
|
|
749
|
-
const row = await this.db('server_stats_traces').where('id', id).first();
|
|
750
|
-
if (!row)
|
|
751
|
-
return null;
|
|
752
|
-
// Look up correlated logs
|
|
753
|
-
let logs = [];
|
|
754
|
-
let httpRequestId = null;
|
|
755
|
-
// Get the linked request to find http_request_id
|
|
756
|
-
if (row.request_id) {
|
|
757
|
-
const linkedRequest = await this.db('server_stats_requests')
|
|
758
|
-
.where('id', row.request_id)
|
|
759
|
-
.select('http_request_id', 'created_at')
|
|
760
|
-
.first();
|
|
761
|
-
if (linkedRequest?.http_request_id) {
|
|
762
|
-
httpRequestId = linkedRequest.http_request_id;
|
|
763
|
-
logs = await this.db('server_stats_logs')
|
|
764
|
-
.where('request_id', linkedRequest.http_request_id)
|
|
765
|
-
.orderBy('created_at', 'asc');
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// Fallback: time-window query if no precise match
|
|
769
|
-
if (logs.length === 0 && row.created_at) {
|
|
770
|
-
const windowSec = Math.ceil((row.total_duration || 0) / 1000) + 2;
|
|
771
|
-
logs = await this.db('server_stats_logs')
|
|
772
|
-
.where('created_at', '>=', this.db.raw(`datetime(?, '-${windowSec} seconds')`, [row.created_at]))
|
|
773
|
-
.where('created_at', '<=', this.db.raw(`datetime(?, '+${windowSec} seconds')`, [row.created_at]))
|
|
774
|
-
.orderBy('created_at', 'asc')
|
|
775
|
-
.limit(100);
|
|
776
|
-
}
|
|
777
|
-
return {
|
|
778
|
-
...row,
|
|
779
|
-
spans: safeParseJson(row.spans) ?? [],
|
|
780
|
-
warnings: safeParseJsonArray(row.warnings),
|
|
781
|
-
logs,
|
|
782
|
-
http_request_id: httpRequestId,
|
|
783
|
-
};
|
|
784
|
-
});
|
|
184
|
+
return this.db ? queryTraceDetail(this.readCtx, id) : null;
|
|
785
185
|
}
|
|
786
|
-
/**
|
|
787
|
-
* Single request with associated queries, events, and trace.
|
|
788
|
-
* Wrapped in a transaction — 1 pool acquire instead of 4.
|
|
789
|
-
*/
|
|
790
186
|
async getRequestDetail(id) {
|
|
791
|
-
|
|
792
|
-
return null;
|
|
793
|
-
return this.coalesce('requestDetail:' + id, async () => {
|
|
794
|
-
return this.db.transaction(async (trx) => {
|
|
795
|
-
const request = await trx('server_stats_requests').where('id', id).first();
|
|
796
|
-
if (!request)
|
|
797
|
-
return null;
|
|
798
|
-
const queries = await trx('server_stats_queries')
|
|
799
|
-
.where('request_id', id)
|
|
800
|
-
.orderBy('created_at', 'asc');
|
|
801
|
-
const events = await trx('server_stats_events')
|
|
802
|
-
.where('request_id', id)
|
|
803
|
-
.orderBy('created_at', 'asc');
|
|
804
|
-
const trace = await trx('server_stats_traces').where('request_id', id).first();
|
|
805
|
-
// Correlated logs
|
|
806
|
-
let logs = [];
|
|
807
|
-
if (request.http_request_id) {
|
|
808
|
-
logs = await trx('server_stats_logs')
|
|
809
|
-
.where('request_id', request.http_request_id)
|
|
810
|
-
.orderBy('created_at', 'asc');
|
|
811
|
-
}
|
|
812
|
-
// Fallback: time-window
|
|
813
|
-
if (logs.length === 0 && request.created_at) {
|
|
814
|
-
const windowSec = Math.ceil((request.duration || 0) / 1000) + 2;
|
|
815
|
-
logs = await trx('server_stats_logs')
|
|
816
|
-
.where('created_at', '>=', trx.raw(`datetime(?, '-${windowSec} seconds')`, [request.created_at]))
|
|
817
|
-
.where('created_at', '<=', trx.raw(`datetime(?, '+${windowSec} seconds')`, [request.created_at]))
|
|
818
|
-
.orderBy('created_at', 'asc')
|
|
819
|
-
.limit(100);
|
|
820
|
-
}
|
|
821
|
-
return {
|
|
822
|
-
...request,
|
|
823
|
-
queries,
|
|
824
|
-
events,
|
|
825
|
-
logs,
|
|
826
|
-
trace: trace
|
|
827
|
-
? {
|
|
828
|
-
...trace,
|
|
829
|
-
spans: safeParseJson(trace.spans) ?? [],
|
|
830
|
-
warnings: safeParseJsonArray(trace.warnings),
|
|
831
|
-
}
|
|
832
|
-
: null,
|
|
833
|
-
};
|
|
834
|
-
});
|
|
835
|
-
});
|
|
187
|
+
return this.db ? queryRequestDetail(this.readCtx, id) : null;
|
|
836
188
|
}
|
|
837
|
-
// =========================================================================
|
|
838
|
-
// Overview & Charts
|
|
839
|
-
// =========================================================================
|
|
840
|
-
/**
|
|
841
|
-
* Aggregated overview metrics for the dashboard cards.
|
|
842
|
-
*
|
|
843
|
-
* @param range — '1h' | '6h' | '24h' | '7d'
|
|
844
|
-
*/
|
|
845
|
-
/**
|
|
846
|
-
* Wrapped in a single transaction — 1 pool acquire instead of 5.
|
|
847
|
-
*/
|
|
848
189
|
async getOverviewMetrics(range = '1h') {
|
|
849
|
-
|
|
850
|
-
return null;
|
|
851
|
-
return this.cached('overviewMetrics:' + range, 2_000, async () => {
|
|
852
|
-
const cutoff = rangeToCutoff(range);
|
|
853
|
-
const result = await this.db.transaction(async (trx) => {
|
|
854
|
-
const stats = await trx('server_stats_requests')
|
|
855
|
-
.where('created_at', '>=', cutoff)
|
|
856
|
-
.select(trx.raw('COUNT(*) as total'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'), trx.raw('SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count'))
|
|
857
|
-
.first();
|
|
858
|
-
const total = Number(stats?.total ?? 0);
|
|
859
|
-
if (total === 0) {
|
|
860
|
-
return {
|
|
861
|
-
avgResponseTime: 0,
|
|
862
|
-
p95ResponseTime: 0,
|
|
863
|
-
requestsPerMinute: 0,
|
|
864
|
-
errorRate: 0,
|
|
865
|
-
totalRequests: 0,
|
|
866
|
-
slowestEndpoints: [],
|
|
867
|
-
queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
|
|
868
|
-
recentErrors: [],
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
const avgResponseTime = stats?.avg_duration;
|
|
872
|
-
const errorCount = Number(stats?.error_count ?? 0);
|
|
873
|
-
const rangeMinutes = rangeToMinutes(range);
|
|
874
|
-
const requestsPerMin = total / rangeMinutes;
|
|
875
|
-
const p95Offset = Math.floor(total * 0.95);
|
|
876
|
-
const p95Row = await trx('server_stats_requests')
|
|
877
|
-
.where('created_at', '>=', cutoff)
|
|
878
|
-
.orderBy('duration', 'asc')
|
|
879
|
-
.offset(Math.min(p95Offset, total - 1))
|
|
880
|
-
.limit(1)
|
|
881
|
-
.select('duration')
|
|
882
|
-
.first();
|
|
883
|
-
const p95ResponseTime = p95Row?.duration ?? 0;
|
|
884
|
-
const slowestEndpoints = await trx('server_stats_requests')
|
|
885
|
-
.where('created_at', '>=', cutoff)
|
|
886
|
-
.select('url', trx.raw('COUNT(*) as count'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
|
|
887
|
-
.groupBy('url')
|
|
888
|
-
.orderBy('avg_duration', 'desc')
|
|
889
|
-
.limit(5);
|
|
890
|
-
const queryStats = await trx('server_stats_queries')
|
|
891
|
-
.where('created_at', '>=', cutoff)
|
|
892
|
-
.select(trx.raw('COUNT(*) as total'), trx.raw('ROUND(AVG(duration), 2) as avg_duration'))
|
|
893
|
-
.first();
|
|
894
|
-
const recentErrors = await trx('server_stats_logs')
|
|
895
|
-
.where('created_at', '>=', cutoff)
|
|
896
|
-
.whereIn('level', ['error', 'fatal'])
|
|
897
|
-
.orderBy('created_at', 'desc')
|
|
898
|
-
.limit(5);
|
|
899
|
-
return {
|
|
900
|
-
avgResponseTime: round(avgResponseTime),
|
|
901
|
-
p95ResponseTime: round(p95ResponseTime),
|
|
902
|
-
requestsPerMinute: round(requestsPerMin),
|
|
903
|
-
errorRate: round((errorCount / total) * 100),
|
|
904
|
-
totalRequests: total,
|
|
905
|
-
slowestEndpoints: slowestEndpoints.map((s) => ({
|
|
906
|
-
url: s.url,
|
|
907
|
-
count: s.count,
|
|
908
|
-
avgDuration: s.avg_duration,
|
|
909
|
-
})),
|
|
910
|
-
queryStats: {
|
|
911
|
-
total: queryStats?.total ?? 0,
|
|
912
|
-
avgDuration: queryStats?.avg_duration ?? 0,
|
|
913
|
-
perRequest: total > 0 ? round((queryStats?.total ?? 0) / total) : 0,
|
|
914
|
-
},
|
|
915
|
-
recentErrors: recentErrors.map((e) => ({
|
|
916
|
-
id: e.id,
|
|
917
|
-
message: e.message,
|
|
918
|
-
createdAt: e.created_at,
|
|
919
|
-
})),
|
|
920
|
-
};
|
|
921
|
-
});
|
|
922
|
-
return result;
|
|
923
|
-
});
|
|
190
|
+
return this.db ? fetchOverviewMetrics(this.db, this.cache, range) : null;
|
|
924
191
|
}
|
|
925
|
-
/**
|
|
926
|
-
* Time-series chart data from server_stats_metrics.
|
|
927
|
-
*
|
|
928
|
-
* @param range — '1h' | '6h' | '24h' | '7d'
|
|
929
|
-
*/
|
|
930
192
|
async getChartData(range = '1h') {
|
|
931
|
-
|
|
932
|
-
return [];
|
|
933
|
-
return this.cached('chartData:' + range, DashboardStore.CHART_CACHE_TTL_MS, async () => {
|
|
934
|
-
const cutoff = rangeToCutoff(range);
|
|
935
|
-
// For 1h/6h, use the per-minute metrics table.
|
|
936
|
-
// For 24h/7d, aggregate metrics into larger buckets.
|
|
937
|
-
const rows = await this.db('server_stats_metrics')
|
|
938
|
-
.where('bucket', '>=', cutoff)
|
|
939
|
-
.orderBy('bucket', 'asc');
|
|
940
|
-
if (range === '1h' || range === '6h') {
|
|
941
|
-
return rows;
|
|
942
|
-
}
|
|
943
|
-
// For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
|
|
944
|
-
const bucketMinutes = range === '7d' ? 60 : 15;
|
|
945
|
-
const grouped = new Map();
|
|
946
|
-
for (const row of rows) {
|
|
947
|
-
const bucketKey = roundBucket(row.bucket, bucketMinutes);
|
|
948
|
-
if (!grouped.has(bucketKey)) {
|
|
949
|
-
grouped.set(bucketKey, {
|
|
950
|
-
bucket: bucketKey,
|
|
951
|
-
request_count: 0,
|
|
952
|
-
avg_duration: 0,
|
|
953
|
-
p95_duration: 0,
|
|
954
|
-
error_count: 0,
|
|
955
|
-
query_count: 0,
|
|
956
|
-
avg_query_duration: 0,
|
|
957
|
-
_count: 0,
|
|
958
|
-
});
|
|
959
|
-
}
|
|
960
|
-
const g = grouped.get(bucketKey);
|
|
961
|
-
g.request_count += row.request_count;
|
|
962
|
-
g.error_count += row.error_count;
|
|
963
|
-
g.query_count += row.query_count;
|
|
964
|
-
g.avg_duration += row.avg_duration;
|
|
965
|
-
g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
|
|
966
|
-
g.avg_query_duration += row.avg_query_duration;
|
|
967
|
-
g._count++;
|
|
968
|
-
}
|
|
969
|
-
return Array.from(grouped.values()).map((g) => ({
|
|
970
|
-
bucket: g.bucket,
|
|
971
|
-
request_count: g.request_count,
|
|
972
|
-
avg_duration: g._count > 0 ? round(g.avg_duration / g._count) : 0,
|
|
973
|
-
p95_duration: round(g.p95_duration),
|
|
974
|
-
error_count: g.error_count,
|
|
975
|
-
query_count: g.query_count,
|
|
976
|
-
avg_query_duration: g._count > 0 ? round(g.avg_query_duration / g._count) : 0,
|
|
977
|
-
}));
|
|
978
|
-
});
|
|
193
|
+
return this.db ? fetchChartData(this.db, this.cache, range) : [];
|
|
979
194
|
}
|
|
980
|
-
/**
|
|
981
|
-
* Widget data for the dashboard overview.
|
|
982
|
-
*
|
|
983
|
-
* @param range — '1h' | '6h' | '24h' | '7d'
|
|
984
|
-
*/
|
|
985
195
|
async getOverviewWidgets(range = '1h') {
|
|
986
|
-
|
|
987
|
-
topEvents: [],
|
|
988
|
-
emailActivity: { sent: 0, queued: 0, failed: 0 },
|
|
989
|
-
logLevelBreakdown: { error: 0, warn: 0, info: 0, debug: 0 },
|
|
990
|
-
statusDistribution: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
|
|
991
|
-
slowestQueries: [],
|
|
992
|
-
};
|
|
993
|
-
if (!this.db)
|
|
994
|
-
return empty;
|
|
995
|
-
return this.cached('overviewWidgets:' + range, DashboardStore.WIDGETS_CACHE_TTL_MS, async () => {
|
|
996
|
-
const cutoff = rangeToCutoff(range);
|
|
997
|
-
try {
|
|
998
|
-
// Single transaction — 1 pool acquire instead of 5.
|
|
999
|
-
const { topEventsRaw, emailStatusRaw, logLevelsRaw, statusRaw, slowQueriesRaw } = await this.db.transaction(async (trx) => ({
|
|
1000
|
-
topEventsRaw: await trx('server_stats_events')
|
|
1001
|
-
.select('event_name', trx.raw('COUNT(*) as count'))
|
|
1002
|
-
.where('created_at', '>=', cutoff)
|
|
1003
|
-
.groupBy('event_name')
|
|
1004
|
-
.orderBy('count', 'desc')
|
|
1005
|
-
.limit(5),
|
|
1006
|
-
emailStatusRaw: await trx('server_stats_emails')
|
|
1007
|
-
.select('status', trx.raw('COUNT(*) as count'))
|
|
1008
|
-
.where('created_at', '>=', cutoff)
|
|
1009
|
-
.groupBy('status'),
|
|
1010
|
-
logLevelsRaw: await trx('server_stats_logs')
|
|
1011
|
-
.select('level', trx.raw('COUNT(*) as count'))
|
|
1012
|
-
.where('created_at', '>=', cutoff)
|
|
1013
|
-
.groupBy('level'),
|
|
1014
|
-
statusRaw: await trx('server_stats_requests')
|
|
1015
|
-
.select(trx.raw(`SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as "s2xx"`), trx.raw(`SUM(CASE WHEN status_code >= 300 AND status_code < 400 THEN 1 ELSE 0 END) as "s3xx"`), trx.raw(`SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) as "s4xx"`), trx.raw(`SUM(CASE WHEN status_code >= 500 AND status_code < 600 THEN 1 ELSE 0 END) as "s5xx"`))
|
|
1016
|
-
.where('created_at', '>=', cutoff)
|
|
1017
|
-
.first(),
|
|
1018
|
-
slowQueriesRaw: await trx('server_stats_queries')
|
|
1019
|
-
.select('sql_normalized', trx.raw('ROUND(AVG(duration), 2) as avg_duration'), trx.raw('COUNT(*) as count'))
|
|
1020
|
-
.where('created_at', '>=', cutoff)
|
|
1021
|
-
.groupBy('sql_normalized')
|
|
1022
|
-
.orderBy('avg_duration', 'desc')
|
|
1023
|
-
.limit(5),
|
|
1024
|
-
}));
|
|
1025
|
-
// Map top events
|
|
1026
|
-
const topEvents = (topEventsRaw || []).map((r) => ({
|
|
1027
|
-
eventName: r.event_name,
|
|
1028
|
-
count: r.count,
|
|
1029
|
-
}));
|
|
1030
|
-
// Map email activity
|
|
1031
|
-
const emailActivity = { sent: 0, queued: 0, failed: 0 };
|
|
1032
|
-
for (const row of emailStatusRaw || []) {
|
|
1033
|
-
const status = row.status;
|
|
1034
|
-
const count = row.count;
|
|
1035
|
-
if (status === 'sent' || status === 'sending')
|
|
1036
|
-
emailActivity.sent += count;
|
|
1037
|
-
else if (status === 'queued' || status === 'queueing')
|
|
1038
|
-
emailActivity.queued += count;
|
|
1039
|
-
else if (status === 'failed')
|
|
1040
|
-
emailActivity.failed = count;
|
|
1041
|
-
}
|
|
1042
|
-
// Map log level breakdown
|
|
1043
|
-
const logLevelBreakdown = { error: 0, warn: 0, info: 0, debug: 0 };
|
|
1044
|
-
for (const row of logLevelsRaw || []) {
|
|
1045
|
-
const level = row.level;
|
|
1046
|
-
if (level in logLevelBreakdown) {
|
|
1047
|
-
logLevelBreakdown[level] = row.count;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
// Map status distribution
|
|
1051
|
-
const statusDistribution = {
|
|
1052
|
-
'2xx': statusRaw?.s2xx ?? 0,
|
|
1053
|
-
'3xx': statusRaw?.s3xx ?? 0,
|
|
1054
|
-
'4xx': statusRaw?.s4xx ?? 0,
|
|
1055
|
-
'5xx': statusRaw?.s5xx ?? 0,
|
|
1056
|
-
};
|
|
1057
|
-
// Map slowest queries
|
|
1058
|
-
const slowestQueries = (slowQueriesRaw || []).map((r) => ({
|
|
1059
|
-
sqlNormalized: r.sql_normalized,
|
|
1060
|
-
avgDuration: r.avg_duration,
|
|
1061
|
-
count: r.count,
|
|
1062
|
-
}));
|
|
1063
|
-
return { topEvents, emailActivity, logLevelBreakdown, statusDistribution, slowestQueries };
|
|
1064
|
-
}
|
|
1065
|
-
catch (err) {
|
|
1066
|
-
if (!overviewWidgetWarned) {
|
|
1067
|
-
overviewWidgetWarned = true;
|
|
1068
|
-
log.warn('dashboard: getOverviewWidgets query failed — ' + err?.message);
|
|
1069
|
-
}
|
|
1070
|
-
return empty;
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
196
|
+
return this.db ? fetchOverviewWidgets(this.db, this.cache, range) : EMPTY_WIDGETS;
|
|
1073
197
|
}
|
|
1074
|
-
/** Get sparkline data points from pre-aggregated metrics. */
|
|
1075
198
|
async getSparklineData(range) {
|
|
1076
|
-
|
|
1077
|
-
return [];
|
|
1078
|
-
return this.cached('sparkline:' + range, DashboardStore.SPARKLINE_CACHE_TTL_MS, async () => {
|
|
1079
|
-
const cutoff = rangeToCutoff(range);
|
|
1080
|
-
const metrics = await this.db('server_stats_metrics')
|
|
1081
|
-
.where('bucket', '>=', cutoff)
|
|
1082
|
-
.orderBy('bucket', 'asc');
|
|
1083
|
-
return metrics.slice(-15);
|
|
1084
|
-
});
|
|
199
|
+
return this.db ? fetchSparklineData(this.db, this.cache, range) : [];
|
|
1085
200
|
}
|
|
1086
|
-
// =========================================================================
|
|
1087
|
-
// Saved filters CRUD
|
|
1088
|
-
// =========================================================================
|
|
1089
201
|
async getSavedFilters(section) {
|
|
1090
|
-
|
|
1091
|
-
return [];
|
|
1092
|
-
return this.coalesce('savedFilters:' + (section || ''), async () => {
|
|
1093
|
-
const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
|
|
1094
|
-
if (section)
|
|
1095
|
-
query.where('section', section);
|
|
1096
|
-
return query;
|
|
1097
|
-
});
|
|
202
|
+
return this.db ? fetchSavedFilters(this.db, this.cache, section) : [];
|
|
1098
203
|
}
|
|
1099
204
|
async createSavedFilter(name, section, filterConfig) {
|
|
1100
|
-
|
|
1101
|
-
return null;
|
|
1102
|
-
const [id] = await this.db('server_stats_saved_filters').insert({
|
|
1103
|
-
name,
|
|
1104
|
-
section,
|
|
1105
|
-
filter_config: JSON.stringify(filterConfig),
|
|
1106
|
-
});
|
|
1107
|
-
return { id, name, section, filterConfig };
|
|
205
|
+
return this.db ? insertSavedFilter(this.db, name, section, filterConfig) : null;
|
|
1108
206
|
}
|
|
1109
207
|
async deleteSavedFilter(id) {
|
|
1110
|
-
|
|
1111
|
-
return false;
|
|
1112
|
-
const deleted = await this.db('server_stats_saved_filters').where('id', id).delete();
|
|
1113
|
-
return deleted > 0;
|
|
208
|
+
return this.db ? removeSavedFilter(this.db, id) : false;
|
|
1114
209
|
}
|
|
1115
|
-
// =========================================================================
|
|
1116
|
-
// EXPLAIN
|
|
1117
|
-
// =========================================================================
|
|
1118
|
-
/**
|
|
1119
|
-
* Run EXPLAIN on a stored query using the app's default database connection.
|
|
1120
|
-
* Only allows SELECT queries for safety.
|
|
1121
|
-
*
|
|
1122
|
-
* @param queryId — ID from server_stats_queries
|
|
1123
|
-
* @param appDb — The application's Lucid database manager
|
|
1124
|
-
*/
|
|
1125
210
|
async runExplain(queryId, appDb) {
|
|
1126
211
|
if (!this.db)
|
|
1127
212
|
return { error: 'Dashboard store not initialized' };
|
|
1128
|
-
return this.
|
|
1129
|
-
const row = await this.db('server_stats_queries').where('id', queryId).first();
|
|
1130
|
-
if (!row)
|
|
1131
|
-
return { error: 'Query not found' };
|
|
1132
|
-
const sql = row.sql_text.trim();
|
|
1133
|
-
if (!sql.toLowerCase().startsWith('select')) {
|
|
1134
|
-
return { error: 'EXPLAIN is only supported for SELECT queries' };
|
|
1135
|
-
}
|
|
1136
|
-
try {
|
|
1137
|
-
const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
|
|
1138
|
-
return { plan: result.rows || result };
|
|
1139
|
-
}
|
|
1140
|
-
catch (err) {
|
|
1141
|
-
return { error: err.message || 'EXPLAIN failed' };
|
|
1142
|
-
}
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
// =========================================================================
|
|
1146
|
-
// Private helpers
|
|
1147
|
-
// =========================================================================
|
|
1148
|
-
/**
|
|
1149
|
-
* Generic paginated query with filter callback.
|
|
1150
|
-
*
|
|
1151
|
-
* Wrapped in a single transaction so COUNT + SELECT acquire the pool
|
|
1152
|
-
* connection only once instead of two separate acquire/release cycles.
|
|
1153
|
-
* With max:1 pool, this halves pool pressure per paginated endpoint.
|
|
1154
|
-
*/
|
|
1155
|
-
async paginate(table, page, perPage, applyFilters, filterKey) {
|
|
1156
|
-
if (!this.db) {
|
|
1157
|
-
return { data: [], total: 0, page, perPage, lastPage: 0 };
|
|
1158
|
-
}
|
|
1159
|
-
const coalesceKey = 'paginate:' + table + ':' + page + ':' + perPage + ':' + (filterKey || '');
|
|
1160
|
-
return this.cached(coalesceKey, DashboardStore.PAGINATE_CACHE_TTL_MS, async () => {
|
|
1161
|
-
return this.db.transaction(async (trx) => {
|
|
1162
|
-
const countQuery = trx(table);
|
|
1163
|
-
if (applyFilters)
|
|
1164
|
-
applyFilters(countQuery);
|
|
1165
|
-
const [{ count: totalRaw }] = await countQuery.count('* as count');
|
|
1166
|
-
const total = Number(totalRaw);
|
|
1167
|
-
const offset = (page - 1) * perPage;
|
|
1168
|
-
const dataQuery = trx(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
|
|
1169
|
-
if (applyFilters)
|
|
1170
|
-
applyFilters(dataQuery);
|
|
1171
|
-
const data = await dataQuery;
|
|
1172
|
-
return { data, total, page, perPage, lastPage: Math.ceil(total / perPage) };
|
|
1173
|
-
});
|
|
1174
|
-
});
|
|
213
|
+
return executeExplain(this.db, this.cache, queryId, appDb);
|
|
1175
214
|
}
|
|
1176
|
-
/**
|
|
1177
|
-
* Wire email event listeners to persist emails as they arrive.
|
|
1178
|
-
*/
|
|
1179
215
|
wireEventListeners() {
|
|
1180
216
|
if (!this.emitter || typeof this.emitter.on !== 'function') {
|
|
1181
217
|
log.warn('dashboard: emitter not available — email collection disabled');
|
|
1182
218
|
return;
|
|
1183
219
|
}
|
|
1184
|
-
const
|
|
1185
|
-
const d = data;
|
|
1186
|
-
const msg = (d?.message || d);
|
|
1187
|
-
const record = {
|
|
1188
|
-
from: extractAddresses(msg?.from) || 'unknown',
|
|
1189
|
-
to: extractAddresses(msg?.to) || 'unknown',
|
|
1190
|
-
cc: extractAddresses(msg?.cc) || null,
|
|
1191
|
-
bcc: extractAddresses(msg?.bcc) || null,
|
|
1192
|
-
subject: msg?.subject || '(no subject)',
|
|
1193
|
-
html: msg?.html || null,
|
|
1194
|
-
text: msg?.text || null,
|
|
1195
|
-
mailer: d?.mailerName || d?.mailer || 'unknown',
|
|
1196
|
-
status,
|
|
1197
|
-
messageId: d?.response?.messageId ||
|
|
1198
|
-
d?.messageId ||
|
|
1199
|
-
null,
|
|
1200
|
-
attachmentCount: Array.isArray(msg?.attachments)
|
|
1201
|
-
? msg.attachments.length
|
|
1202
|
-
: 0,
|
|
1203
|
-
timestamp: Date.now(),
|
|
1204
|
-
};
|
|
1205
|
-
this.recordEmail(record);
|
|
1206
|
-
};
|
|
220
|
+
const persist = (data, status) => this.recordEmail(buildEmailRecordFromEvent(data, status));
|
|
1207
221
|
this.handlers = [
|
|
1208
|
-
{ event: 'mail:sending', fn: (data) =>
|
|
1209
|
-
{ event: 'mail:sent', fn: (data) =>
|
|
1210
|
-
{ event: 'mail:queueing', fn: (data) =>
|
|
1211
|
-
{ event: 'mail:queued', fn: (data) =>
|
|
1212
|
-
{ 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') },
|
|
1213
227
|
];
|
|
1214
|
-
for (const h of this.handlers)
|
|
228
|
+
for (const h of this.handlers)
|
|
1215
229
|
this.emitter.on(h.event, h.fn);
|
|
1216
|
-
}
|
|
1217
230
|
log.info(`dashboard: email listeners wired (${this.handlers.length} events)`);
|
|
1218
231
|
}
|
|
1219
232
|
}
|
|
1220
|
-
// ---------------------------------------------------------------------------
|
|
1221
|
-
// Helpers
|
|
1222
|
-
// ---------------------------------------------------------------------------
|
|
1223
|
-
/**
|
|
1224
|
-
* Normalize a SQL query by replacing literal values with `?` placeholders.
|
|
1225
|
-
* Used for grouping identical query patterns.
|
|
1226
|
-
*/
|
|
1227
|
-
function normalizeSql(sql) {
|
|
1228
|
-
return sql
|
|
1229
|
-
.replace(/'[^']*'/g, '?')
|
|
1230
|
-
.replace(/\b\d+(\.\d+)?\b/g, '?')
|
|
1231
|
-
.replace(/\s+/g, ' ')
|
|
1232
|
-
.trim();
|
|
1233
|
-
}
|