adonisjs-server-stats 1.2.2 → 1.3.1
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 +148 -9
- package/dist/src/dashboard/chart_aggregator.d.ts +21 -0
- package/dist/src/dashboard/chart_aggregator.d.ts.map +1 -0
- package/dist/src/dashboard/chart_aggregator.js +89 -0
- package/dist/src/dashboard/dashboard_controller.d.ts +147 -0
- package/dist/src/dashboard/dashboard_controller.d.ts.map +1 -0
- package/dist/src/dashboard/dashboard_controller.js +1008 -0
- package/dist/src/dashboard/dashboard_routes.d.ts +16 -0
- package/dist/src/dashboard/dashboard_routes.d.ts.map +1 -0
- package/dist/src/dashboard/dashboard_routes.js +88 -0
- package/dist/src/dashboard/dashboard_store.d.ts +158 -0
- package/dist/src/dashboard/dashboard_store.d.ts.map +1 -0
- package/dist/src/dashboard/dashboard_store.js +723 -0
- package/dist/src/dashboard/integrations/cache_inspector.d.ts +88 -0
- package/dist/src/dashboard/integrations/cache_inspector.d.ts.map +1 -0
- package/dist/src/dashboard/integrations/cache_inspector.js +215 -0
- package/dist/src/dashboard/integrations/config_inspector.d.ts +33 -0
- package/dist/src/dashboard/integrations/config_inspector.d.ts.map +1 -0
- package/dist/src/dashboard/integrations/config_inspector.js +155 -0
- package/dist/src/dashboard/integrations/index.d.ts +7 -0
- package/dist/src/dashboard/integrations/index.d.ts.map +1 -0
- package/dist/src/dashboard/integrations/index.js +3 -0
- package/dist/src/dashboard/integrations/queue_inspector.d.ts +106 -0
- package/dist/src/dashboard/integrations/queue_inspector.d.ts.map +1 -0
- package/dist/src/dashboard/integrations/queue_inspector.js +182 -0
- package/dist/src/dashboard/migrator.d.ts +18 -0
- package/dist/src/dashboard/migrator.d.ts.map +1 -0
- package/dist/src/dashboard/migrator.js +144 -0
- package/dist/src/dashboard/models/stats_email.d.ts +19 -0
- package/dist/src/dashboard/models/stats_email.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_email.js +66 -0
- package/dist/src/dashboard/models/stats_event.d.ts +14 -0
- package/dist/src/dashboard/models/stats_event.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_event.js +43 -0
- package/dist/src/dashboard/models/stats_log.d.ts +12 -0
- package/dist/src/dashboard/models/stats_log.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_log.js +42 -0
- package/dist/src/dashboard/models/stats_metric.d.ts +15 -0
- package/dist/src/dashboard/models/stats_metric.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_metric.js +50 -0
- package/dist/src/dashboard/models/stats_query.d.ts +20 -0
- package/dist/src/dashboard/models/stats_query.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_query.js +67 -0
- package/dist/src/dashboard/models/stats_request.d.ts +21 -0
- package/dist/src/dashboard/models/stats_request.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_request.js +61 -0
- package/dist/src/dashboard/models/stats_saved_filter.d.ts +11 -0
- package/dist/src/dashboard/models/stats_saved_filter.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_saved_filter.js +38 -0
- package/dist/src/dashboard/models/stats_trace.d.ts +19 -0
- package/dist/src/dashboard/models/stats_trace.d.ts.map +1 -0
- package/dist/src/dashboard/models/stats_trace.js +67 -0
- package/dist/src/debug/debug_store.d.ts +5 -0
- package/dist/src/debug/debug_store.d.ts.map +1 -1
- package/dist/src/debug/debug_store.js +10 -0
- 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 +4 -0
- package/dist/src/debug/event_collector.d.ts +2 -0
- package/dist/src/debug/event_collector.d.ts.map +1 -1
- package/dist/src/debug/event_collector.js +11 -2
- package/dist/src/debug/query_collector.d.ts +2 -0
- package/dist/src/debug/query_collector.d.ts.map +1 -1
- package/dist/src/debug/query_collector.js +11 -0
- package/dist/src/debug/ring_buffer.d.ts +3 -0
- package/dist/src/debug/ring_buffer.d.ts.map +1 -1
- package/dist/src/debug/ring_buffer.js +6 -0
- package/dist/src/debug/trace_collector.d.ts +4 -2
- package/dist/src/debug/trace_collector.d.ts.map +1 -1
- package/dist/src/debug/trace_collector.js +7 -2
- package/dist/src/debug/types.d.ts +8 -0
- package/dist/src/debug/types.d.ts.map +1 -1
- package/dist/src/edge/client/dashboard.css +1504 -0
- package/dist/src/edge/client/dashboard.js +2378 -0
- package/dist/src/edge/client/debug-panel.css +528 -108
- package/dist/src/edge/client/debug-panel.js +663 -22
- package/dist/src/edge/client/stats-bar.css +112 -38
- package/dist/src/edge/client/stats-bar.js +37 -3
- package/dist/src/edge/plugin.d.ts.map +1 -1
- package/dist/src/edge/plugin.js +21 -0
- package/dist/src/edge/views/dashboard.edge +382 -0
- package/dist/src/edge/views/debug-panel.edge +60 -14
- package/dist/src/edge/views/stats-bar.edge +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/middleware/request_tracking_middleware.d.ts +20 -0
- package/dist/src/middleware/request_tracking_middleware.d.ts.map +1 -1
- package/dist/src/middleware/request_tracking_middleware.js +66 -2
- package/dist/src/provider/server_stats_provider.d.ts +13 -0
- package/dist/src/provider/server_stats_provider.d.ts.map +1 -1
- package/dist/src/provider/server_stats_provider.js +175 -1
- package/dist/src/types.d.ts +42 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +14 -1
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { autoMigrate, runRetentionCleanup } from './migrator.js';
|
|
4
|
+
import { ChartAggregator } from './chart_aggregator.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// DashboardStore
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Bridges the in-memory RingBuffer collectors to SQLite persistence
|
|
10
|
+
* and provides query methods for the dashboard API.
|
|
11
|
+
*
|
|
12
|
+
* Handles auto-migration, retention cleanup, periodic metrics aggregation,
|
|
13
|
+
* and self-exclusion of dashboard routes and server_stats connection queries.
|
|
14
|
+
*/
|
|
15
|
+
export class DashboardStore {
|
|
16
|
+
db = null;
|
|
17
|
+
emitter = null;
|
|
18
|
+
config;
|
|
19
|
+
dashboardPath;
|
|
20
|
+
retentionTimer = null;
|
|
21
|
+
chartAggregator = null;
|
|
22
|
+
handlers = [];
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.dashboardPath = config.dashboardPath;
|
|
26
|
+
}
|
|
27
|
+
// =========================================================================
|
|
28
|
+
// Lifecycle
|
|
29
|
+
// =========================================================================
|
|
30
|
+
/**
|
|
31
|
+
* Initialize the SQLite connection, run migrations and retention
|
|
32
|
+
* cleanup, start chart aggregation, and wire event listeners.
|
|
33
|
+
*/
|
|
34
|
+
async start(_lucidDb, emitter, appRoot) {
|
|
35
|
+
this.emitter = emitter;
|
|
36
|
+
const dbFilePath = appRoot + '/' + this.config.dbPath;
|
|
37
|
+
await mkdir(dirname(dbFilePath), { recursive: true });
|
|
38
|
+
// Create a standalone Knex connection to SQLite — bypasses Lucid's
|
|
39
|
+
// connection manager entirely so we never pollute the app's pool.
|
|
40
|
+
const knexModule = await import('knex');
|
|
41
|
+
const knexFactory = knexModule.default ?? knexModule;
|
|
42
|
+
this.db = knexFactory({
|
|
43
|
+
client: 'better-sqlite3',
|
|
44
|
+
connection: { filename: dbFilePath },
|
|
45
|
+
useNullAsDefault: true,
|
|
46
|
+
});
|
|
47
|
+
await this.db.raw('PRAGMA journal_mode=WAL');
|
|
48
|
+
await this.db.raw('PRAGMA foreign_keys=ON');
|
|
49
|
+
await autoMigrate(this.db);
|
|
50
|
+
await runRetentionCleanup(this.db, this.config.retentionDays);
|
|
51
|
+
// Hourly retention cleanup
|
|
52
|
+
this.retentionTimer = setInterval(async () => {
|
|
53
|
+
try {
|
|
54
|
+
await runRetentionCleanup(this.db, this.config.retentionDays);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Silently ignore
|
|
58
|
+
}
|
|
59
|
+
}, 60 * 60 * 1000);
|
|
60
|
+
// Start chart aggregation (every 60s)
|
|
61
|
+
this.chartAggregator = new ChartAggregator(this.db);
|
|
62
|
+
this.chartAggregator.start();
|
|
63
|
+
// Wire email event listeners
|
|
64
|
+
this.wireEventListeners();
|
|
65
|
+
}
|
|
66
|
+
/** Shut down timers, event listeners, and database connection. */
|
|
67
|
+
async stop() {
|
|
68
|
+
if (this.retentionTimer) {
|
|
69
|
+
clearInterval(this.retentionTimer);
|
|
70
|
+
this.retentionTimer = null;
|
|
71
|
+
}
|
|
72
|
+
this.chartAggregator?.stop();
|
|
73
|
+
if (this.emitter) {
|
|
74
|
+
for (const h of this.handlers) {
|
|
75
|
+
if (typeof this.emitter.off === 'function') {
|
|
76
|
+
this.emitter.off(h.event, h.fn);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.handlers = [];
|
|
81
|
+
if (this.db && typeof this.db.destroy === 'function') {
|
|
82
|
+
try {
|
|
83
|
+
await this.db.destroy();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Ignore
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.db = null;
|
|
90
|
+
}
|
|
91
|
+
/** Get the raw Knex database connection (for DashboardController). */
|
|
92
|
+
getDb() {
|
|
93
|
+
return this.db;
|
|
94
|
+
}
|
|
95
|
+
/** Whether the store is initialized and ready. */
|
|
96
|
+
isReady() {
|
|
97
|
+
return this.db !== null;
|
|
98
|
+
}
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// Write methods — persist data from collectors
|
|
101
|
+
// =========================================================================
|
|
102
|
+
/**
|
|
103
|
+
* Record a completed request. Returns the inserted row ID, or null
|
|
104
|
+
* if the request was self-excluded or an error occurred.
|
|
105
|
+
*/
|
|
106
|
+
async recordRequest(method, url, statusCode, duration, spanCount = 0, warningCount = 0) {
|
|
107
|
+
if (!this.db)
|
|
108
|
+
return null;
|
|
109
|
+
if (url.startsWith(this.dashboardPath))
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const [id] = await this.db('server_stats_requests').insert({
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
status_code: statusCode,
|
|
116
|
+
duration: Math.round(duration * 100) / 100,
|
|
117
|
+
span_count: spanCount,
|
|
118
|
+
warning_count: warningCount,
|
|
119
|
+
});
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Batch-insert queries for a request. Filters out server_stats connection queries. */
|
|
127
|
+
async recordQueries(requestId, queries) {
|
|
128
|
+
if (!this.db || queries.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const filtered = queries.filter((q) => q.connection !== 'server_stats');
|
|
131
|
+
if (filtered.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
const rows = filtered.map((q) => ({
|
|
135
|
+
request_id: requestId,
|
|
136
|
+
sql_text: q.sql,
|
|
137
|
+
sql_normalized: normalizeSql(q.sql),
|
|
138
|
+
bindings: q.bindings ? JSON.stringify(q.bindings) : null,
|
|
139
|
+
duration: Math.round(q.duration * 100) / 100,
|
|
140
|
+
method: q.method,
|
|
141
|
+
model: q.model,
|
|
142
|
+
connection: q.connection,
|
|
143
|
+
in_transaction: q.inTransaction ? 1 : 0,
|
|
144
|
+
}));
|
|
145
|
+
// SQLite variable limit: batch in chunks of 50
|
|
146
|
+
for (let i = 0; i < rows.length; i += 50) {
|
|
147
|
+
await this.db('server_stats_queries').insert(rows.slice(i, i + 50));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Silently ignore
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Batch-insert events for a request. */
|
|
155
|
+
async recordEvents(requestId, events) {
|
|
156
|
+
if (!this.db || events.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
const rows = events.map((e) => ({
|
|
160
|
+
request_id: requestId,
|
|
161
|
+
event_name: e.event,
|
|
162
|
+
data: e.data,
|
|
163
|
+
}));
|
|
164
|
+
for (let i = 0; i < rows.length; i += 50) {
|
|
165
|
+
await this.db('server_stats_events').insert(rows.slice(i, i + 50));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Silently ignore
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Record a single email. */
|
|
173
|
+
async recordEmail(record) {
|
|
174
|
+
if (!this.db)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
await this.db('server_stats_emails').insert({
|
|
178
|
+
from_addr: record.from,
|
|
179
|
+
to_addr: record.to,
|
|
180
|
+
cc: record.cc,
|
|
181
|
+
bcc: record.bcc,
|
|
182
|
+
subject: record.subject,
|
|
183
|
+
html: record.html,
|
|
184
|
+
text_body: record.text,
|
|
185
|
+
mailer: record.mailer,
|
|
186
|
+
status: record.status,
|
|
187
|
+
message_id: record.messageId,
|
|
188
|
+
attachment_count: record.attachmentCount,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Silently ignore
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Record a single log entry (from LogStreamService). */
|
|
196
|
+
async recordLog(entry) {
|
|
197
|
+
if (!this.db)
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
const levelName = typeof entry.levelName === 'string'
|
|
201
|
+
? entry.levelName
|
|
202
|
+
: String(entry.level || 'unknown');
|
|
203
|
+
await this.db('server_stats_logs').insert({
|
|
204
|
+
level: levelName,
|
|
205
|
+
message: String(entry.msg || entry.message || ''),
|
|
206
|
+
request_id: entry.requestId ? String(entry.requestId) : null,
|
|
207
|
+
data: JSON.stringify(entry),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Silently ignore
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Record a trace for a request. */
|
|
215
|
+
async recordTrace(requestId, trace) {
|
|
216
|
+
if (!this.db)
|
|
217
|
+
return;
|
|
218
|
+
try {
|
|
219
|
+
await this.db('server_stats_traces').insert({
|
|
220
|
+
request_id: requestId,
|
|
221
|
+
method: trace.method,
|
|
222
|
+
url: trace.url,
|
|
223
|
+
status_code: trace.statusCode,
|
|
224
|
+
total_duration: Math.round(trace.totalDuration * 100) / 100,
|
|
225
|
+
span_count: trace.spanCount,
|
|
226
|
+
spans: JSON.stringify(trace.spans),
|
|
227
|
+
warnings: trace.warnings.length > 0 ? JSON.stringify(trace.warnings) : null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Silently ignore
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Convenience: persist a full request with associated queries and trace.
|
|
236
|
+
* Calls recordRequest, recordQueries, and recordTrace in sequence.
|
|
237
|
+
*/
|
|
238
|
+
async persistRequest(method, url, statusCode, duration, queries, trace) {
|
|
239
|
+
const requestId = await this.recordRequest(method, url, statusCode, duration, trace?.spanCount ?? 0, trace?.warnings?.length ?? 0);
|
|
240
|
+
if (requestId === null)
|
|
241
|
+
return null;
|
|
242
|
+
await this.recordQueries(requestId, queries);
|
|
243
|
+
if (trace) {
|
|
244
|
+
await this.recordTrace(requestId, trace);
|
|
245
|
+
}
|
|
246
|
+
return requestId;
|
|
247
|
+
}
|
|
248
|
+
// =========================================================================
|
|
249
|
+
// Read methods — query data for dashboard API
|
|
250
|
+
// =========================================================================
|
|
251
|
+
/** Paginated request history with optional filters. */
|
|
252
|
+
async getRequests(page = 1, perPage = 50, filters) {
|
|
253
|
+
return this.paginate('server_stats_requests', page, perPage, (query) => {
|
|
254
|
+
if (filters?.method)
|
|
255
|
+
query.where('method', filters.method);
|
|
256
|
+
if (filters?.url)
|
|
257
|
+
query.where('url', 'like', `%${filters.url}%`);
|
|
258
|
+
if (filters?.statusMin)
|
|
259
|
+
query.where('status_code', '>=', filters.statusMin);
|
|
260
|
+
if (filters?.statusMax)
|
|
261
|
+
query.where('status_code', '<=', filters.statusMax);
|
|
262
|
+
if (filters?.durationMin)
|
|
263
|
+
query.where('duration', '>=', filters.durationMin);
|
|
264
|
+
if (filters?.durationMax)
|
|
265
|
+
query.where('duration', '<=', filters.durationMax);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Paginated query history with optional filters. */
|
|
269
|
+
async getQueries(page = 1, perPage = 50, filters) {
|
|
270
|
+
return this.paginate('server_stats_queries', page, perPage, (query) => {
|
|
271
|
+
if (filters?.method)
|
|
272
|
+
query.where('method', filters.method);
|
|
273
|
+
if (filters?.model)
|
|
274
|
+
query.where('model', filters.model);
|
|
275
|
+
if (filters?.connection)
|
|
276
|
+
query.where('connection', filters.connection);
|
|
277
|
+
if (filters?.durationMin)
|
|
278
|
+
query.where('duration', '>=', filters.durationMin);
|
|
279
|
+
if (filters?.durationMax)
|
|
280
|
+
query.where('duration', '<=', filters.durationMax);
|
|
281
|
+
if (filters?.requestId)
|
|
282
|
+
query.where('request_id', filters.requestId);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Grouped query patterns: aggregated by sql_normalized
|
|
287
|
+
* with count, avg/min/max/total duration.
|
|
288
|
+
*/
|
|
289
|
+
async getQueriesGrouped() {
|
|
290
|
+
if (!this.db)
|
|
291
|
+
return [];
|
|
292
|
+
return this.db('server_stats_queries')
|
|
293
|
+
.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'))
|
|
294
|
+
.groupBy('sql_normalized')
|
|
295
|
+
.orderBy('count', 'desc')
|
|
296
|
+
.limit(200);
|
|
297
|
+
}
|
|
298
|
+
/** Paginated event history with optional filters. */
|
|
299
|
+
async getEvents(page = 1, perPage = 50, filters) {
|
|
300
|
+
return this.paginate('server_stats_events', page, perPage, (query) => {
|
|
301
|
+
if (filters?.eventName)
|
|
302
|
+
query.where('event_name', 'like', `%${filters.eventName}%`);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Paginated email history with optional filters. */
|
|
306
|
+
async getEmails(page = 1, perPage = 50, filters) {
|
|
307
|
+
return this.paginate('server_stats_emails', page, perPage, (query) => {
|
|
308
|
+
if (filters?.from)
|
|
309
|
+
query.where('from_addr', 'like', `%${filters.from}%`);
|
|
310
|
+
if (filters?.to)
|
|
311
|
+
query.where('to_addr', 'like', `%${filters.to}%`);
|
|
312
|
+
if (filters?.subject)
|
|
313
|
+
query.where('subject', 'like', `%${filters.subject}%`);
|
|
314
|
+
if (filters?.mailer)
|
|
315
|
+
query.where('mailer', filters.mailer);
|
|
316
|
+
if (filters?.status)
|
|
317
|
+
query.where('status', filters.status);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Get email HTML body for preview. */
|
|
321
|
+
async getEmailHtml(id) {
|
|
322
|
+
if (!this.db)
|
|
323
|
+
return null;
|
|
324
|
+
const row = await this.db('server_stats_emails').where('id', id).select('html').first();
|
|
325
|
+
return row?.html ?? null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Paginated log history with structured search support.
|
|
329
|
+
*
|
|
330
|
+
* Structured filters query into the JSON `data` column using
|
|
331
|
+
* SQLite's `json_extract()`.
|
|
332
|
+
*/
|
|
333
|
+
async getLogs(page = 1, perPage = 50, filters) {
|
|
334
|
+
return this.paginate('server_stats_logs', page, perPage, (query) => {
|
|
335
|
+
if (filters?.level)
|
|
336
|
+
query.where('level', filters.level);
|
|
337
|
+
if (filters?.requestId)
|
|
338
|
+
query.where('request_id', filters.requestId);
|
|
339
|
+
if (filters?.search) {
|
|
340
|
+
query.where('message', 'like', `%${filters.search}%`);
|
|
341
|
+
}
|
|
342
|
+
if (filters?.structured && filters.structured.length > 0) {
|
|
343
|
+
for (const sf of filters.structured) {
|
|
344
|
+
const jsonPath = `$.${sf.field}`;
|
|
345
|
+
switch (sf.operator) {
|
|
346
|
+
case 'equals':
|
|
347
|
+
query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, sf.value]);
|
|
348
|
+
break;
|
|
349
|
+
case 'contains':
|
|
350
|
+
query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${sf.value}%`]);
|
|
351
|
+
break;
|
|
352
|
+
case 'startsWith':
|
|
353
|
+
query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${sf.value}%`]);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/** Paginated trace history with optional filters. */
|
|
361
|
+
async getTraces(page = 1, perPage = 50, filters) {
|
|
362
|
+
return this.paginate('server_stats_traces', page, perPage, (query) => {
|
|
363
|
+
if (filters?.method)
|
|
364
|
+
query.where('method', filters.method);
|
|
365
|
+
if (filters?.url)
|
|
366
|
+
query.where('url', 'like', `%${filters.url}%`);
|
|
367
|
+
if (filters?.statusMin)
|
|
368
|
+
query.where('status_code', '>=', filters.statusMin);
|
|
369
|
+
if (filters?.statusMax)
|
|
370
|
+
query.where('status_code', '<=', filters.statusMax);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/** Single trace with full span data. */
|
|
374
|
+
async getTraceDetail(id) {
|
|
375
|
+
if (!this.db)
|
|
376
|
+
return null;
|
|
377
|
+
const row = await this.db('server_stats_traces').where('id', id).first();
|
|
378
|
+
if (!row)
|
|
379
|
+
return null;
|
|
380
|
+
return {
|
|
381
|
+
...row,
|
|
382
|
+
spans: typeof row.spans === 'string' ? JSON.parse(row.spans) : row.spans,
|
|
383
|
+
warnings: row.warnings
|
|
384
|
+
? typeof row.warnings === 'string'
|
|
385
|
+
? JSON.parse(row.warnings)
|
|
386
|
+
: row.warnings
|
|
387
|
+
: [],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** Single request with associated queries, events, and trace. */
|
|
391
|
+
async getRequestDetail(id) {
|
|
392
|
+
if (!this.db)
|
|
393
|
+
return null;
|
|
394
|
+
const request = await this.db('server_stats_requests').where('id', id).first();
|
|
395
|
+
if (!request)
|
|
396
|
+
return null;
|
|
397
|
+
const [queries, events, trace] = await Promise.all([
|
|
398
|
+
this.db('server_stats_queries').where('request_id', id).orderBy('created_at', 'asc'),
|
|
399
|
+
this.db('server_stats_events').where('request_id', id).orderBy('created_at', 'asc'),
|
|
400
|
+
this.db('server_stats_traces').where('request_id', id).first(),
|
|
401
|
+
]);
|
|
402
|
+
return {
|
|
403
|
+
...request,
|
|
404
|
+
queries,
|
|
405
|
+
events,
|
|
406
|
+
trace: trace
|
|
407
|
+
? {
|
|
408
|
+
...trace,
|
|
409
|
+
spans: typeof trace.spans === 'string' ? JSON.parse(trace.spans) : trace.spans,
|
|
410
|
+
warnings: trace.warnings
|
|
411
|
+
? typeof trace.warnings === 'string'
|
|
412
|
+
? JSON.parse(trace.warnings)
|
|
413
|
+
: trace.warnings
|
|
414
|
+
: [],
|
|
415
|
+
}
|
|
416
|
+
: null,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// =========================================================================
|
|
420
|
+
// Overview & Charts
|
|
421
|
+
// =========================================================================
|
|
422
|
+
/**
|
|
423
|
+
* Aggregated overview metrics for the dashboard cards.
|
|
424
|
+
*
|
|
425
|
+
* @param range — '1h' | '6h' | '24h' | '7d'
|
|
426
|
+
*/
|
|
427
|
+
async getOverviewMetrics(range = '1h') {
|
|
428
|
+
if (!this.db)
|
|
429
|
+
return null;
|
|
430
|
+
const cutoff = rangeToCutoff(range);
|
|
431
|
+
// Recent requests for calculations
|
|
432
|
+
const requests = await this.db('server_stats_requests')
|
|
433
|
+
.where('created_at', '>=', cutoff)
|
|
434
|
+
.select('duration', 'status_code', 'url', 'created_at');
|
|
435
|
+
const total = requests.length;
|
|
436
|
+
if (total === 0) {
|
|
437
|
+
return {
|
|
438
|
+
avgResponseTime: 0,
|
|
439
|
+
p95ResponseTime: 0,
|
|
440
|
+
requestsPerMinute: 0,
|
|
441
|
+
errorRate: 0,
|
|
442
|
+
totalRequests: 0,
|
|
443
|
+
slowestEndpoints: [],
|
|
444
|
+
queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
|
|
445
|
+
recentErrors: [],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const durations = requests.map((r) => r.duration).sort((a, b) => a - b);
|
|
449
|
+
const avgResponseTime = durations.reduce((s, d) => s + d, 0) / total;
|
|
450
|
+
const p95Index = Math.floor(total * 0.95);
|
|
451
|
+
const p95ResponseTime = durations[Math.min(p95Index, total - 1)];
|
|
452
|
+
const errorCount = requests.filter((r) => r.status_code >= 400).length;
|
|
453
|
+
const rangeMinutes = rangeToMinutes(range);
|
|
454
|
+
const requestsPerMin = total / rangeMinutes;
|
|
455
|
+
// Slowest endpoints (top 5 by average duration)
|
|
456
|
+
const slowestEndpoints = await this.db('server_stats_requests')
|
|
457
|
+
.where('created_at', '>=', cutoff)
|
|
458
|
+
.select('url', this.db.raw('COUNT(*) as count'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
|
|
459
|
+
.groupBy('url')
|
|
460
|
+
.orderBy('avg_duration', 'desc')
|
|
461
|
+
.limit(5);
|
|
462
|
+
// Query stats
|
|
463
|
+
const queryStats = await this.db('server_stats_queries')
|
|
464
|
+
.where('created_at', '>=', cutoff)
|
|
465
|
+
.select(this.db.raw('COUNT(*) as total'), this.db.raw('ROUND(AVG(duration), 2) as avg_duration'))
|
|
466
|
+
.first();
|
|
467
|
+
// Recent errors (last 5 log entries with level error/fatal)
|
|
468
|
+
const recentErrors = await this.db('server_stats_logs')
|
|
469
|
+
.where('created_at', '>=', cutoff)
|
|
470
|
+
.whereIn('level', ['error', 'fatal'])
|
|
471
|
+
.orderBy('created_at', 'desc')
|
|
472
|
+
.limit(5);
|
|
473
|
+
return {
|
|
474
|
+
avgResponseTime: Math.round(avgResponseTime * 100) / 100,
|
|
475
|
+
p95ResponseTime: Math.round(p95ResponseTime * 100) / 100,
|
|
476
|
+
requestsPerMinute: Math.round(requestsPerMin * 100) / 100,
|
|
477
|
+
errorRate: Math.round((errorCount / total) * 10000) / 100,
|
|
478
|
+
totalRequests: total,
|
|
479
|
+
slowestEndpoints: slowestEndpoints.map((s) => ({
|
|
480
|
+
url: s.url,
|
|
481
|
+
count: s.count,
|
|
482
|
+
avgDuration: s.avg_duration,
|
|
483
|
+
})),
|
|
484
|
+
queryStats: {
|
|
485
|
+
total: queryStats?.total ?? 0,
|
|
486
|
+
avgDuration: queryStats?.avg_duration ?? 0,
|
|
487
|
+
perRequest: total > 0 ? Math.round(((queryStats?.total ?? 0) / total) * 100) / 100 : 0,
|
|
488
|
+
},
|
|
489
|
+
recentErrors: recentErrors.map((e) => ({
|
|
490
|
+
id: e.id,
|
|
491
|
+
message: e.message,
|
|
492
|
+
createdAt: e.created_at,
|
|
493
|
+
})),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Time-series chart data from server_stats_metrics.
|
|
498
|
+
*
|
|
499
|
+
* @param range — '1h' | '6h' | '24h' | '7d'
|
|
500
|
+
*/
|
|
501
|
+
async getChartData(range = '1h') {
|
|
502
|
+
if (!this.db)
|
|
503
|
+
return [];
|
|
504
|
+
const cutoff = rangeToCutoff(range);
|
|
505
|
+
// For 1h/6h, use the per-minute metrics table.
|
|
506
|
+
// For 24h/7d, aggregate metrics into larger buckets.
|
|
507
|
+
const rows = await this.db('server_stats_metrics')
|
|
508
|
+
.where('bucket', '>=', cutoff)
|
|
509
|
+
.orderBy('bucket', 'asc');
|
|
510
|
+
if (range === '1h' || range === '6h') {
|
|
511
|
+
return rows;
|
|
512
|
+
}
|
|
513
|
+
// For 24h: group by 15-minute buckets; for 7d: group by hourly buckets
|
|
514
|
+
const bucketMinutes = range === '7d' ? 60 : 15;
|
|
515
|
+
const grouped = new Map();
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
const bucketKey = roundBucket(row.bucket, bucketMinutes);
|
|
518
|
+
if (!grouped.has(bucketKey)) {
|
|
519
|
+
grouped.set(bucketKey, {
|
|
520
|
+
bucket: bucketKey,
|
|
521
|
+
request_count: 0,
|
|
522
|
+
avg_duration: 0,
|
|
523
|
+
p95_duration: 0,
|
|
524
|
+
error_count: 0,
|
|
525
|
+
query_count: 0,
|
|
526
|
+
avg_query_duration: 0,
|
|
527
|
+
_count: 0,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const g = grouped.get(bucketKey);
|
|
531
|
+
g.request_count += row.request_count;
|
|
532
|
+
g.error_count += row.error_count;
|
|
533
|
+
g.query_count += row.query_count;
|
|
534
|
+
g.avg_duration += row.avg_duration;
|
|
535
|
+
g.p95_duration = Math.max(g.p95_duration, row.p95_duration);
|
|
536
|
+
g.avg_query_duration += row.avg_query_duration;
|
|
537
|
+
g._count++;
|
|
538
|
+
}
|
|
539
|
+
return Array.from(grouped.values()).map((g) => ({
|
|
540
|
+
bucket: g.bucket,
|
|
541
|
+
request_count: g.request_count,
|
|
542
|
+
avg_duration: g._count > 0 ? Math.round((g.avg_duration / g._count) * 100) / 100 : 0,
|
|
543
|
+
p95_duration: Math.round(g.p95_duration * 100) / 100,
|
|
544
|
+
error_count: g.error_count,
|
|
545
|
+
query_count: g.query_count,
|
|
546
|
+
avg_query_duration: g._count > 0 ? Math.round((g.avg_query_duration / g._count) * 100) / 100 : 0,
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
// =========================================================================
|
|
550
|
+
// Saved filters CRUD
|
|
551
|
+
// =========================================================================
|
|
552
|
+
async getSavedFilters(section) {
|
|
553
|
+
if (!this.db)
|
|
554
|
+
return [];
|
|
555
|
+
const query = this.db('server_stats_saved_filters').orderBy('created_at', 'desc');
|
|
556
|
+
if (section)
|
|
557
|
+
query.where('section', section);
|
|
558
|
+
return query;
|
|
559
|
+
}
|
|
560
|
+
async createSavedFilter(name, section, filterConfig) {
|
|
561
|
+
if (!this.db)
|
|
562
|
+
return null;
|
|
563
|
+
const [id] = await this.db('server_stats_saved_filters').insert({
|
|
564
|
+
name,
|
|
565
|
+
section,
|
|
566
|
+
filter_config: JSON.stringify(filterConfig),
|
|
567
|
+
});
|
|
568
|
+
return { id, name, section, filterConfig };
|
|
569
|
+
}
|
|
570
|
+
async deleteSavedFilter(id) {
|
|
571
|
+
if (!this.db)
|
|
572
|
+
return false;
|
|
573
|
+
const deleted = await this.db('server_stats_saved_filters').where('id', id).delete();
|
|
574
|
+
return deleted > 0;
|
|
575
|
+
}
|
|
576
|
+
// =========================================================================
|
|
577
|
+
// EXPLAIN
|
|
578
|
+
// =========================================================================
|
|
579
|
+
/**
|
|
580
|
+
* Run EXPLAIN on a stored query using the app's default database connection.
|
|
581
|
+
* Only allows SELECT queries for safety.
|
|
582
|
+
*
|
|
583
|
+
* @param queryId — ID from server_stats_queries
|
|
584
|
+
* @param appDb — The application's Lucid database manager
|
|
585
|
+
*/
|
|
586
|
+
async runExplain(queryId, appDb) {
|
|
587
|
+
if (!this.db)
|
|
588
|
+
return { error: 'Dashboard store not initialized' };
|
|
589
|
+
const row = await this.db('server_stats_queries').where('id', queryId).first();
|
|
590
|
+
if (!row)
|
|
591
|
+
return { error: 'Query not found' };
|
|
592
|
+
const sql = row.sql_text.trim();
|
|
593
|
+
if (!sql.toLowerCase().startsWith('select')) {
|
|
594
|
+
return { error: 'EXPLAIN is only supported for SELECT queries' };
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const result = await appDb.rawQuery(`EXPLAIN ${sql}`);
|
|
598
|
+
return { plan: result.rows || result };
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
return { error: err.message || 'EXPLAIN failed' };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// =========================================================================
|
|
605
|
+
// Private helpers
|
|
606
|
+
// =========================================================================
|
|
607
|
+
/** Generic paginated query with filter callback. */
|
|
608
|
+
async paginate(table, page, perPage, applyFilters) {
|
|
609
|
+
if (!this.db) {
|
|
610
|
+
return { data: [], total: 0, page, perPage, lastPage: 0 };
|
|
611
|
+
}
|
|
612
|
+
// Count total
|
|
613
|
+
const countQuery = this.db(table);
|
|
614
|
+
if (applyFilters)
|
|
615
|
+
applyFilters(countQuery);
|
|
616
|
+
const [{ count: total }] = await countQuery.count('* as count');
|
|
617
|
+
// Fetch page
|
|
618
|
+
const offset = (page - 1) * perPage;
|
|
619
|
+
const dataQuery = this.db(table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
|
|
620
|
+
if (applyFilters)
|
|
621
|
+
applyFilters(dataQuery);
|
|
622
|
+
const data = await dataQuery;
|
|
623
|
+
return {
|
|
624
|
+
data,
|
|
625
|
+
total,
|
|
626
|
+
page,
|
|
627
|
+
perPage,
|
|
628
|
+
lastPage: Math.ceil(total / perPage),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Wire email event listeners to persist emails as they arrive.
|
|
633
|
+
*/
|
|
634
|
+
wireEventListeners() {
|
|
635
|
+
if (!this.emitter || typeof this.emitter.on !== 'function')
|
|
636
|
+
return;
|
|
637
|
+
const buildAndPersistEmail = (data, status) => {
|
|
638
|
+
const msg = data?.message || data;
|
|
639
|
+
const record = {
|
|
640
|
+
id: 0,
|
|
641
|
+
from: extractAddresses(msg?.from) || 'unknown',
|
|
642
|
+
to: extractAddresses(msg?.to) || 'unknown',
|
|
643
|
+
cc: extractAddresses(msg?.cc) || null,
|
|
644
|
+
bcc: extractAddresses(msg?.bcc) || null,
|
|
645
|
+
subject: msg?.subject || '(no subject)',
|
|
646
|
+
html: msg?.html || null,
|
|
647
|
+
text: msg?.text || null,
|
|
648
|
+
mailer: data?.mailerName || data?.mailer || 'unknown',
|
|
649
|
+
status,
|
|
650
|
+
messageId: data?.response?.messageId || data?.messageId || null,
|
|
651
|
+
attachmentCount: Array.isArray(msg?.attachments) ? msg.attachments.length : 0,
|
|
652
|
+
timestamp: Date.now(),
|
|
653
|
+
};
|
|
654
|
+
this.recordEmail(record);
|
|
655
|
+
};
|
|
656
|
+
this.handlers = [
|
|
657
|
+
{ event: 'mail:sent', fn: (data) => buildAndPersistEmail(data, 'sent') },
|
|
658
|
+
{ event: 'mail:queued', fn: (data) => buildAndPersistEmail(data, 'queued') },
|
|
659
|
+
{ event: 'queued:mail:error', fn: (data) => buildAndPersistEmail(data, 'failed') },
|
|
660
|
+
];
|
|
661
|
+
for (const h of this.handlers) {
|
|
662
|
+
this.emitter.on(h.event, h.fn);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// Helpers
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
/**
|
|
670
|
+
* Normalize a SQL query by replacing literal values with `?` placeholders.
|
|
671
|
+
* Used for grouping identical query patterns.
|
|
672
|
+
*/
|
|
673
|
+
function normalizeSql(sql) {
|
|
674
|
+
return sql
|
|
675
|
+
.replace(/'[^']*'/g, '?')
|
|
676
|
+
.replace(/\b\d+(\.\d+)?\b/g, '?')
|
|
677
|
+
.replace(/\s+/g, ' ')
|
|
678
|
+
.trim();
|
|
679
|
+
}
|
|
680
|
+
/** Extract email addresses from various AdonisJS mail address formats. */
|
|
681
|
+
function extractAddresses(value) {
|
|
682
|
+
if (!value)
|
|
683
|
+
return '';
|
|
684
|
+
if (typeof value === 'string')
|
|
685
|
+
return value;
|
|
686
|
+
if (Array.isArray(value)) {
|
|
687
|
+
return value
|
|
688
|
+
.map((v) => (typeof v === 'string' ? v : v?.address || ''))
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.join(', ');
|
|
691
|
+
}
|
|
692
|
+
if (typeof value === 'object' && value.address)
|
|
693
|
+
return value.address;
|
|
694
|
+
return '';
|
|
695
|
+
}
|
|
696
|
+
/** Convert a range string to a SQLite-compatible datetime cutoff. */
|
|
697
|
+
function rangeToCutoff(range) {
|
|
698
|
+
const minutes = rangeToMinutes(range);
|
|
699
|
+
const cutoff = new Date(Date.now() - minutes * 60_000);
|
|
700
|
+
return cutoff.toISOString().replace('T', ' ').slice(0, 19);
|
|
701
|
+
}
|
|
702
|
+
/** Convert a range string to total minutes. */
|
|
703
|
+
function rangeToMinutes(range) {
|
|
704
|
+
switch (range) {
|
|
705
|
+
case '1h':
|
|
706
|
+
return 60;
|
|
707
|
+
case '6h':
|
|
708
|
+
return 360;
|
|
709
|
+
case '24h':
|
|
710
|
+
return 1440;
|
|
711
|
+
case '7d':
|
|
712
|
+
return 10080;
|
|
713
|
+
default:
|
|
714
|
+
return 60;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Round a bucket timestamp string down to the nearest N minutes. */
|
|
718
|
+
function roundBucket(bucket, minutes) {
|
|
719
|
+
const date = new Date(bucket.replace(' ', 'T') + 'Z');
|
|
720
|
+
const ms = minutes * 60_000;
|
|
721
|
+
const rounded = new Date(Math.floor(date.getTime() / ms) * ms);
|
|
722
|
+
return rounded.toISOString().replace('T', ' ').slice(0, 19);
|
|
723
|
+
}
|