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