adonisjs-server-stats 1.2.2 → 1.3.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 +144 -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,1008 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CacheInspector } from './integrations/cache_inspector.js';
|
|
6
|
+
import { QueueInspector } from './integrations/queue_inspector.js';
|
|
7
|
+
import { ConfigInspector } from './integrations/config_inspector.js';
|
|
8
|
+
const EDGE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'edge');
|
|
9
|
+
/**
|
|
10
|
+
* Controller for the full-page dashboard.
|
|
11
|
+
*
|
|
12
|
+
* Serves the dashboard HTML page and all JSON API endpoints.
|
|
13
|
+
* Constructor-injected with the SQLite-backed DashboardStore,
|
|
14
|
+
* the in-memory DebugStore, and the AdonisJS application instance.
|
|
15
|
+
*/
|
|
16
|
+
export default class DashboardController {
|
|
17
|
+
dashboardStore;
|
|
18
|
+
debugStore;
|
|
19
|
+
app;
|
|
20
|
+
cacheInspector = null;
|
|
21
|
+
queueInspector = null;
|
|
22
|
+
configInspector;
|
|
23
|
+
cacheAvailable = null;
|
|
24
|
+
queueAvailable = null;
|
|
25
|
+
cachedCss = null;
|
|
26
|
+
cachedJs = null;
|
|
27
|
+
cachedTransmitClient = null;
|
|
28
|
+
constructor(dashboardStore, debugStore, app) {
|
|
29
|
+
this.dashboardStore = dashboardStore;
|
|
30
|
+
this.debugStore = debugStore;
|
|
31
|
+
this.app = app;
|
|
32
|
+
this.configInspector = new ConfigInspector(app);
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Page
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* GET {dashboardPath} — Render the dashboard Edge template.
|
|
39
|
+
*
|
|
40
|
+
* Reads the dashboard CSS/JS assets and passes them as template state
|
|
41
|
+
* along with configuration for tracing and custom panes.
|
|
42
|
+
*/
|
|
43
|
+
async page(ctx) {
|
|
44
|
+
if (!this.checkAccess(ctx)) {
|
|
45
|
+
return ctx.response.forbidden({ error: 'Access denied' });
|
|
46
|
+
}
|
|
47
|
+
// Lazily read and cache the CSS/JS assets
|
|
48
|
+
if (!this.cachedCss) {
|
|
49
|
+
this.cachedCss = readFileSync(join(EDGE_DIR, 'client', 'dashboard.css'), 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
if (!this.cachedJs) {
|
|
52
|
+
this.cachedJs = readFileSync(join(EDGE_DIR, 'client', 'dashboard.js'), 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
if (this.cachedTransmitClient === null) {
|
|
55
|
+
this.cachedTransmitClient = this.loadTransmitClient();
|
|
56
|
+
}
|
|
57
|
+
const config = this.app.config.get('server_stats');
|
|
58
|
+
const toolbarConfig = config?.devToolbar || {};
|
|
59
|
+
const dashPath = this.getDashboardPath();
|
|
60
|
+
return ctx.view.render('ss::dashboard', {
|
|
61
|
+
css: this.cachedCss,
|
|
62
|
+
js: this.cachedJs,
|
|
63
|
+
transmitClient: this.cachedTransmitClient,
|
|
64
|
+
dashboardPath: dashPath,
|
|
65
|
+
showTracing: !!toolbarConfig.tracing,
|
|
66
|
+
customPanes: toolbarConfig.panes || [],
|
|
67
|
+
dashConfig: {
|
|
68
|
+
basePath: dashPath,
|
|
69
|
+
tracing: !!toolbarConfig.tracing,
|
|
70
|
+
panes: (toolbarConfig.panes || []).map((p) => ({
|
|
71
|
+
id: p.id,
|
|
72
|
+
label: p.label,
|
|
73
|
+
})),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Overview
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/**
|
|
81
|
+
* GET {dashboardPath}/api/overview — Overview metrics cards.
|
|
82
|
+
*/
|
|
83
|
+
async overview({ request, response }) {
|
|
84
|
+
const db = this.dashboardStore.getDb();
|
|
85
|
+
if (!db)
|
|
86
|
+
return response.json(emptyOverview());
|
|
87
|
+
try {
|
|
88
|
+
const range = request.qs().range || '1h';
|
|
89
|
+
// Use getOverviewMetrics for accurate stats (computed from raw requests)
|
|
90
|
+
const overview = await this.dashboardStore.getOverviewMetrics(range);
|
|
91
|
+
if (!overview)
|
|
92
|
+
return response.json(emptyOverview());
|
|
93
|
+
// Add sparklines from the pre-aggregated metrics table
|
|
94
|
+
const cutoff = minutesAgo(60);
|
|
95
|
+
const metrics = await db('server_stats_metrics')
|
|
96
|
+
.where('created_at', '>=', cutoff)
|
|
97
|
+
.orderBy('bucket', 'asc');
|
|
98
|
+
const sparklineData = metrics.slice(-15);
|
|
99
|
+
return response.json({
|
|
100
|
+
...overview,
|
|
101
|
+
sparklines: {
|
|
102
|
+
avgResponseTime: sparklineData.map((m) => m.avg_duration),
|
|
103
|
+
p95ResponseTime: sparklineData.map((m) => m.p95_duration),
|
|
104
|
+
requestsPerMinute: sparklineData.map((m) => m.request_count),
|
|
105
|
+
errorRate: sparklineData.map((m) => m.request_count > 0 ? round((m.error_count / m.request_count) * 100) : 0),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return response.json(emptyOverview());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* GET {dashboardPath}/api/overview/chart — Chart data with time range.
|
|
115
|
+
*/
|
|
116
|
+
async overviewChart({ request, response }) {
|
|
117
|
+
const db = this.dashboardStore.getDb();
|
|
118
|
+
if (!db)
|
|
119
|
+
return response.json({ buckets: [] });
|
|
120
|
+
const range = request.qs().range || '1h';
|
|
121
|
+
const { cutoff } = parseRange(range);
|
|
122
|
+
try {
|
|
123
|
+
const buckets = await db('server_stats_metrics')
|
|
124
|
+
.where('bucket', '>=', cutoff)
|
|
125
|
+
.orderBy('bucket', 'asc');
|
|
126
|
+
return response.json({
|
|
127
|
+
range,
|
|
128
|
+
buckets: buckets.map((b) => ({
|
|
129
|
+
bucket: b.bucket,
|
|
130
|
+
requestCount: b.request_count,
|
|
131
|
+
avgDuration: b.avg_duration,
|
|
132
|
+
p95Duration: b.p95_duration,
|
|
133
|
+
errorCount: b.error_count,
|
|
134
|
+
queryCount: b.query_count,
|
|
135
|
+
})),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return response.json({ buckets: [] });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Requests
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* GET {dashboardPath}/api/requests — Paginated request history.
|
|
147
|
+
*/
|
|
148
|
+
async requests({ request, response }) {
|
|
149
|
+
const db = this.dashboardStore.getDb();
|
|
150
|
+
if (!db)
|
|
151
|
+
return response.json({ data: [], total: 0 });
|
|
152
|
+
const qs = request.qs();
|
|
153
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
154
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
155
|
+
try {
|
|
156
|
+
let query = db('server_stats_requests');
|
|
157
|
+
if (qs.method)
|
|
158
|
+
query = query.where('method', qs.method.toUpperCase());
|
|
159
|
+
if (qs.status)
|
|
160
|
+
query = query.where('status_code', Number(qs.status));
|
|
161
|
+
if (qs.url)
|
|
162
|
+
query = query.where('url', 'like', `%${qs.url}%`);
|
|
163
|
+
const countResult = await query.clone().count('* as total').first();
|
|
164
|
+
const total = countResult?.total ?? 0;
|
|
165
|
+
const data = await query
|
|
166
|
+
.orderBy('created_at', 'desc')
|
|
167
|
+
.limit(perPage)
|
|
168
|
+
.offset((page - 1) * perPage);
|
|
169
|
+
return response.json({
|
|
170
|
+
data: data.map(formatRequest),
|
|
171
|
+
total,
|
|
172
|
+
page,
|
|
173
|
+
perPage,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return response.json({ data: [], total: 0 });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* GET {dashboardPath}/api/requests/:id — Single request with trace.
|
|
182
|
+
*/
|
|
183
|
+
async requestDetail({ params, response }) {
|
|
184
|
+
const db = this.dashboardStore.getDb();
|
|
185
|
+
if (!db)
|
|
186
|
+
return response.notFound({ error: 'Not found' });
|
|
187
|
+
try {
|
|
188
|
+
const id = Number(params.id);
|
|
189
|
+
const req = await db('server_stats_requests').where('id', id).first();
|
|
190
|
+
if (!req)
|
|
191
|
+
return response.notFound({ error: 'Request not found' });
|
|
192
|
+
const [queries, trace] = await Promise.all([
|
|
193
|
+
db('server_stats_queries').where('request_id', id).orderBy('created_at', 'asc'),
|
|
194
|
+
db('server_stats_traces').where('request_id', id).first(),
|
|
195
|
+
]);
|
|
196
|
+
return response.json({
|
|
197
|
+
...formatRequest(req),
|
|
198
|
+
queries: queries.map(formatQuery),
|
|
199
|
+
trace: trace ? formatTrace(trace) : null,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return response.notFound({ error: 'Not found' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Queries
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
/**
|
|
210
|
+
* GET {dashboardPath}/api/queries — Paginated query history.
|
|
211
|
+
*/
|
|
212
|
+
async queries({ request, response }) {
|
|
213
|
+
const db = this.dashboardStore.getDb();
|
|
214
|
+
if (!db)
|
|
215
|
+
return response.json({ data: [], total: 0 });
|
|
216
|
+
const qs = request.qs();
|
|
217
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
218
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
219
|
+
try {
|
|
220
|
+
let query = db('server_stats_queries');
|
|
221
|
+
if (qs.duration_min)
|
|
222
|
+
query = query.where('duration', '>=', Number(qs.duration_min));
|
|
223
|
+
if (qs.model)
|
|
224
|
+
query = query.where('model', qs.model);
|
|
225
|
+
if (qs.method)
|
|
226
|
+
query = query.where('method', qs.method);
|
|
227
|
+
if (qs.connection)
|
|
228
|
+
query = query.where('connection', qs.connection);
|
|
229
|
+
const countResult = await query.clone().count('* as total').first();
|
|
230
|
+
const total = countResult?.total ?? 0;
|
|
231
|
+
const data = await query
|
|
232
|
+
.orderBy('created_at', 'desc')
|
|
233
|
+
.limit(perPage)
|
|
234
|
+
.offset((page - 1) * perPage);
|
|
235
|
+
return response.json({
|
|
236
|
+
data: data.map(formatQuery),
|
|
237
|
+
total,
|
|
238
|
+
page,
|
|
239
|
+
perPage,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return response.json({ data: [], total: 0 });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* GET {dashboardPath}/api/queries/grouped — Grouped by normalized SQL.
|
|
248
|
+
*/
|
|
249
|
+
async queriesGrouped({ request, response }) {
|
|
250
|
+
const db = this.dashboardStore.getDb();
|
|
251
|
+
if (!db)
|
|
252
|
+
return response.json({ groups: [] });
|
|
253
|
+
const qs = request.qs();
|
|
254
|
+
const limit = clamp(Number(qs.limit) || 50, 1, 200);
|
|
255
|
+
const sort = qs.sort || 'total_duration';
|
|
256
|
+
try {
|
|
257
|
+
const validSorts = {
|
|
258
|
+
count: 'count',
|
|
259
|
+
avg_duration: 'avg_duration',
|
|
260
|
+
total_duration: 'total_duration',
|
|
261
|
+
};
|
|
262
|
+
const orderCol = validSorts[sort] || 'total_duration';
|
|
263
|
+
const groups = await db('server_stats_queries')
|
|
264
|
+
.select('sql_normalized')
|
|
265
|
+
.select(db.raw('COUNT(*) as count'))
|
|
266
|
+
.select(db.raw('AVG(duration) as avg_duration'))
|
|
267
|
+
.select(db.raw('MIN(duration) as min_duration'))
|
|
268
|
+
.select(db.raw('MAX(duration) as max_duration'))
|
|
269
|
+
.select(db.raw('SUM(duration) as total_duration'))
|
|
270
|
+
.groupBy('sql_normalized')
|
|
271
|
+
.orderBy(orderCol, 'desc')
|
|
272
|
+
.limit(limit);
|
|
273
|
+
// Calculate total time for percentage
|
|
274
|
+
const totalTime = groups.reduce((sum, g) => sum + (g.total_duration || 0), 0);
|
|
275
|
+
return response.json({
|
|
276
|
+
groups: groups.map((g) => ({
|
|
277
|
+
sqlNormalized: g.sql_normalized,
|
|
278
|
+
count: g.count,
|
|
279
|
+
avgDuration: round(g.avg_duration),
|
|
280
|
+
minDuration: round(g.min_duration),
|
|
281
|
+
maxDuration: round(g.max_duration),
|
|
282
|
+
totalDuration: round(g.total_duration),
|
|
283
|
+
percentOfTotal: totalTime > 0 ? round((g.total_duration / totalTime) * 100) : 0,
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return response.json({ groups: [] });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* GET {dashboardPath}/api/queries/:id/explain — Run EXPLAIN on a query.
|
|
293
|
+
*/
|
|
294
|
+
async queryExplain({ params, response }) {
|
|
295
|
+
const db = this.dashboardStore.getDb();
|
|
296
|
+
if (!db)
|
|
297
|
+
return response.notFound({ error: 'Not found' });
|
|
298
|
+
try {
|
|
299
|
+
const id = Number(params.id);
|
|
300
|
+
const query = await db('server_stats_queries').where('id', id).first();
|
|
301
|
+
if (!query)
|
|
302
|
+
return response.notFound({ error: 'Query not found' });
|
|
303
|
+
// Only allow EXPLAIN on SELECT queries
|
|
304
|
+
const sqlTrimmed = query.sql_text.trim().toUpperCase();
|
|
305
|
+
if (!sqlTrimmed.startsWith('SELECT')) {
|
|
306
|
+
return response.badRequest({ error: 'EXPLAIN is only supported for SELECT queries' });
|
|
307
|
+
}
|
|
308
|
+
// Run EXPLAIN on the app's default database connection (not the stats connection)
|
|
309
|
+
let appDb;
|
|
310
|
+
try {
|
|
311
|
+
const lucid = await this.app.container.make('lucid.db');
|
|
312
|
+
appDb = lucid.connection().getWriteClient();
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return response.serviceUnavailable({ error: 'App database connection not available' });
|
|
316
|
+
}
|
|
317
|
+
// Parse stored bindings and pass them to the EXPLAIN query
|
|
318
|
+
let bindings = [];
|
|
319
|
+
if (query.bindings) {
|
|
320
|
+
try {
|
|
321
|
+
bindings = JSON.parse(query.bindings);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// If bindings can't be parsed, run without them
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const explainResult = await appDb.raw(`EXPLAIN (FORMAT JSON) ${query.sql_text}`, bindings);
|
|
328
|
+
// PostgreSQL with Knex: result is { rows: [...] }
|
|
329
|
+
// Each row has a "QUERY PLAN" key containing the JSON plan
|
|
330
|
+
let plan = [];
|
|
331
|
+
const rawRows = explainResult?.rows ?? (Array.isArray(explainResult) ? explainResult : []);
|
|
332
|
+
if (rawRows.length > 0 && rawRows[0]['QUERY PLAN']) {
|
|
333
|
+
// EXPLAIN (FORMAT JSON) returns a single row with a JSON array
|
|
334
|
+
plan = rawRows[0]['QUERY PLAN'];
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
plan = rawRows;
|
|
338
|
+
}
|
|
339
|
+
return response.json({
|
|
340
|
+
queryId: id,
|
|
341
|
+
sql: query.sql_text,
|
|
342
|
+
plan,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
return response.internalServerError({
|
|
347
|
+
error: 'EXPLAIN failed',
|
|
348
|
+
message: error?.message ?? 'Unknown error',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Events
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
/**
|
|
356
|
+
* GET {dashboardPath}/api/events — Paginated event history.
|
|
357
|
+
*/
|
|
358
|
+
async events({ request, response }) {
|
|
359
|
+
const db = this.dashboardStore.getDb();
|
|
360
|
+
if (!db)
|
|
361
|
+
return response.json({ data: [], total: 0 });
|
|
362
|
+
const qs = request.qs();
|
|
363
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
364
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
365
|
+
try {
|
|
366
|
+
let query = db('server_stats_events');
|
|
367
|
+
if (qs.event_name)
|
|
368
|
+
query = query.where('event_name', qs.event_name);
|
|
369
|
+
const countResult = await query.clone().count('* as total').first();
|
|
370
|
+
const total = countResult?.total ?? 0;
|
|
371
|
+
const data = await query
|
|
372
|
+
.orderBy('created_at', 'desc')
|
|
373
|
+
.limit(perPage)
|
|
374
|
+
.offset((page - 1) * perPage);
|
|
375
|
+
return response.json({
|
|
376
|
+
data: data.map((e) => ({
|
|
377
|
+
id: e.id,
|
|
378
|
+
requestId: e.request_id,
|
|
379
|
+
eventName: e.event_name,
|
|
380
|
+
data: safeParseJson(e.data),
|
|
381
|
+
createdAt: e.created_at,
|
|
382
|
+
})),
|
|
383
|
+
total,
|
|
384
|
+
page,
|
|
385
|
+
perPage,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return response.json({ data: [], total: 0 });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Routes
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
/**
|
|
396
|
+
* GET {dashboardPath}/api/routes — Route table (delegates to DebugStore).
|
|
397
|
+
*/
|
|
398
|
+
async routes({ response }) {
|
|
399
|
+
const routes = this.debugStore.routes.getRoutes();
|
|
400
|
+
return response.json({
|
|
401
|
+
routes,
|
|
402
|
+
total: this.debugStore.routes.getRouteCount(),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Logs
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
/**
|
|
409
|
+
* GET {dashboardPath}/api/logs — Paginated logs with structured search.
|
|
410
|
+
*/
|
|
411
|
+
async logs({ request, response }) {
|
|
412
|
+
const db = this.dashboardStore.getDb();
|
|
413
|
+
if (!db)
|
|
414
|
+
return response.json({ data: [], total: 0 });
|
|
415
|
+
const qs = request.qs();
|
|
416
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
417
|
+
const perPage = clamp(Number(qs.perPage) || 50, 1, 200);
|
|
418
|
+
try {
|
|
419
|
+
let query = db('server_stats_logs');
|
|
420
|
+
if (qs.level)
|
|
421
|
+
query = query.where('level', qs.level);
|
|
422
|
+
if (qs.message)
|
|
423
|
+
query = query.where('message', 'like', `%${qs.message}%`);
|
|
424
|
+
if (qs.request_id)
|
|
425
|
+
query = query.where('request_id', qs.request_id);
|
|
426
|
+
// Structured JSON field search: ?field=userId&operator=equals&value=5
|
|
427
|
+
if (qs.field && qs.value !== undefined) {
|
|
428
|
+
const operator = qs.operator || 'equals';
|
|
429
|
+
const jsonPath = `$.${qs.field}`;
|
|
430
|
+
if (operator === 'equals') {
|
|
431
|
+
query = query.whereRaw(`json_extract(data, ?) = ?`, [jsonPath, qs.value]);
|
|
432
|
+
}
|
|
433
|
+
else if (operator === 'contains') {
|
|
434
|
+
query = query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `%${qs.value}%`]);
|
|
435
|
+
}
|
|
436
|
+
else if (operator === 'starts_with') {
|
|
437
|
+
query = query.whereRaw(`json_extract(data, ?) LIKE ?`, [jsonPath, `${qs.value}%`]);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const countResult = await query.clone().count('* as total').first();
|
|
441
|
+
const total = countResult?.total ?? 0;
|
|
442
|
+
const data = await query
|
|
443
|
+
.orderBy('created_at', 'desc')
|
|
444
|
+
.limit(perPage)
|
|
445
|
+
.offset((page - 1) * perPage);
|
|
446
|
+
return response.json({
|
|
447
|
+
data: data.map((l) => ({
|
|
448
|
+
id: l.id,
|
|
449
|
+
level: l.level,
|
|
450
|
+
message: l.message,
|
|
451
|
+
requestId: l.request_id,
|
|
452
|
+
data: safeParseJson(l.data),
|
|
453
|
+
createdAt: l.created_at,
|
|
454
|
+
})),
|
|
455
|
+
total,
|
|
456
|
+
page,
|
|
457
|
+
perPage,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return response.json({ data: [], total: 0 });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Emails
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
/**
|
|
468
|
+
* GET {dashboardPath}/api/emails — Paginated email history.
|
|
469
|
+
*/
|
|
470
|
+
async emails({ request, response }) {
|
|
471
|
+
const db = this.dashboardStore.getDb();
|
|
472
|
+
if (!db)
|
|
473
|
+
return response.json({ data: [], total: 0 });
|
|
474
|
+
const qs = request.qs();
|
|
475
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
476
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
477
|
+
try {
|
|
478
|
+
let query = db('server_stats_emails');
|
|
479
|
+
if (qs.from)
|
|
480
|
+
query = query.where('from_addr', 'like', `%${qs.from}%`);
|
|
481
|
+
if (qs.to)
|
|
482
|
+
query = query.where('to_addr', 'like', `%${qs.to}%`);
|
|
483
|
+
if (qs.subject)
|
|
484
|
+
query = query.where('subject', 'like', `%${qs.subject}%`);
|
|
485
|
+
if (qs.status)
|
|
486
|
+
query = query.where('status', qs.status);
|
|
487
|
+
if (qs.mailer)
|
|
488
|
+
query = query.where('mailer', qs.mailer);
|
|
489
|
+
const countResult = await query.clone().count('* as total').first();
|
|
490
|
+
const total = countResult?.total ?? 0;
|
|
491
|
+
// Strip html/text from list for performance
|
|
492
|
+
const data = await query
|
|
493
|
+
.select('id', 'from_addr', 'to_addr', 'cc', 'bcc', 'subject', 'mailer', 'status', 'message_id', 'attachment_count', 'created_at')
|
|
494
|
+
.orderBy('created_at', 'desc')
|
|
495
|
+
.limit(perPage)
|
|
496
|
+
.offset((page - 1) * perPage);
|
|
497
|
+
return response.json({
|
|
498
|
+
data: data.map(formatEmail),
|
|
499
|
+
total,
|
|
500
|
+
page,
|
|
501
|
+
perPage,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return response.json({ data: [], total: 0 });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* GET {dashboardPath}/api/emails/:id/preview — Email HTML preview.
|
|
510
|
+
*/
|
|
511
|
+
async emailPreview({ params, response }) {
|
|
512
|
+
const db = this.dashboardStore.getDb();
|
|
513
|
+
if (!db)
|
|
514
|
+
return response.notFound({ error: 'Not found' });
|
|
515
|
+
try {
|
|
516
|
+
const id = Number(params.id);
|
|
517
|
+
const email = await db('server_stats_emails')
|
|
518
|
+
.where('id', id)
|
|
519
|
+
.select('html', 'text_body')
|
|
520
|
+
.first();
|
|
521
|
+
if (!email)
|
|
522
|
+
return response.notFound({ error: 'Email not found' });
|
|
523
|
+
const html = email.html || email.text_body || '<p>No content</p>';
|
|
524
|
+
return response.header('Content-Type', 'text/html; charset=utf-8').send(html);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return response.notFound({ error: 'Not found' });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Traces
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
/**
|
|
534
|
+
* GET {dashboardPath}/api/traces — Paginated trace list (lightweight).
|
|
535
|
+
*/
|
|
536
|
+
async traces({ request, response }) {
|
|
537
|
+
const db = this.dashboardStore.getDb();
|
|
538
|
+
if (!db)
|
|
539
|
+
return response.json({ data: [], total: 0 });
|
|
540
|
+
const qs = request.qs();
|
|
541
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
542
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
543
|
+
try {
|
|
544
|
+
const countResult = await db('server_stats_traces').count('* as total').first();
|
|
545
|
+
const total = countResult?.total ?? 0;
|
|
546
|
+
// Strip spans and warnings from list for performance
|
|
547
|
+
const data = await db('server_stats_traces')
|
|
548
|
+
.select('id', 'request_id', 'method', 'url', 'status_code', 'total_duration', 'span_count', 'warnings', 'created_at')
|
|
549
|
+
.orderBy('created_at', 'desc')
|
|
550
|
+
.limit(perPage)
|
|
551
|
+
.offset((page - 1) * perPage);
|
|
552
|
+
return response.json({
|
|
553
|
+
data: data.map((t) => ({
|
|
554
|
+
id: t.id,
|
|
555
|
+
requestId: t.request_id,
|
|
556
|
+
method: t.method,
|
|
557
|
+
url: t.url,
|
|
558
|
+
statusCode: t.status_code,
|
|
559
|
+
totalDuration: t.total_duration,
|
|
560
|
+
spanCount: t.span_count,
|
|
561
|
+
warningCount: safeParseJsonArray(t.warnings).length,
|
|
562
|
+
createdAt: t.created_at,
|
|
563
|
+
})),
|
|
564
|
+
total,
|
|
565
|
+
page,
|
|
566
|
+
perPage,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return response.json({ data: [], total: 0 });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* GET {dashboardPath}/api/traces/:id — Single trace with full spans.
|
|
575
|
+
*/
|
|
576
|
+
async traceDetail({ params, response }) {
|
|
577
|
+
const db = this.dashboardStore.getDb();
|
|
578
|
+
if (!db)
|
|
579
|
+
return response.notFound({ error: 'Not found' });
|
|
580
|
+
try {
|
|
581
|
+
const id = Number(params.id);
|
|
582
|
+
const trace = await db('server_stats_traces').where('id', id).first();
|
|
583
|
+
if (!trace)
|
|
584
|
+
return response.notFound({ error: 'Trace not found' });
|
|
585
|
+
return response.json(formatTrace(trace));
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return response.notFound({ error: 'Not found' });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// Cache
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
/**
|
|
595
|
+
* GET {dashboardPath}/api/cache — Cache stats and key list.
|
|
596
|
+
*/
|
|
597
|
+
async cacheStats({ request, response }) {
|
|
598
|
+
const inspector = await this.getCacheInspector();
|
|
599
|
+
if (!inspector) {
|
|
600
|
+
return response.json({ available: false, stats: null, keys: [] });
|
|
601
|
+
}
|
|
602
|
+
const qs = request.qs();
|
|
603
|
+
const pattern = qs.pattern || '*';
|
|
604
|
+
const cursor = qs.cursor || '0';
|
|
605
|
+
const count = clamp(Number(qs.count) || 100, 1, 500);
|
|
606
|
+
try {
|
|
607
|
+
const [stats, keyList] = await Promise.all([
|
|
608
|
+
inspector.getStats(),
|
|
609
|
+
inspector.listKeys(pattern, cursor, count),
|
|
610
|
+
]);
|
|
611
|
+
return response.json({
|
|
612
|
+
available: true,
|
|
613
|
+
stats,
|
|
614
|
+
keys: keyList.keys,
|
|
615
|
+
cursor: keyList.cursor,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return response.json({ available: false, stats: null, keys: [] });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* GET {dashboardPath}/api/cache/:key — Single cache key detail.
|
|
624
|
+
*/
|
|
625
|
+
async cacheKey({ params, response }) {
|
|
626
|
+
const inspector = await this.getCacheInspector();
|
|
627
|
+
if (!inspector) {
|
|
628
|
+
return response.notFound({ error: 'Cache not available' });
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const key = decodeURIComponent(params.key);
|
|
632
|
+
const detail = await inspector.getKey(key);
|
|
633
|
+
if (!detail)
|
|
634
|
+
return response.notFound({ error: 'Key not found' });
|
|
635
|
+
return response.json(detail);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return response.notFound({ error: 'Key not found' });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// Jobs / Queue
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
/**
|
|
645
|
+
* GET {dashboardPath}/api/jobs — Job list with status filter.
|
|
646
|
+
*/
|
|
647
|
+
async jobs({ request, response }) {
|
|
648
|
+
const inspector = await this.getQueueInspector();
|
|
649
|
+
if (!inspector) {
|
|
650
|
+
return response.json({ available: false, overview: null, jobs: [], total: 0 });
|
|
651
|
+
}
|
|
652
|
+
const qs = request.qs();
|
|
653
|
+
const status = qs.status || 'active';
|
|
654
|
+
const page = Math.max(1, Number(qs.page) || 1);
|
|
655
|
+
const perPage = clamp(Number(qs.perPage) || 25, 1, 100);
|
|
656
|
+
try {
|
|
657
|
+
const [overview, jobList] = await Promise.all([
|
|
658
|
+
inspector.getOverview(),
|
|
659
|
+
inspector.listJobs(status, page, perPage),
|
|
660
|
+
]);
|
|
661
|
+
return response.json({
|
|
662
|
+
available: true,
|
|
663
|
+
overview,
|
|
664
|
+
jobs: jobList.jobs,
|
|
665
|
+
total: jobList.total,
|
|
666
|
+
page,
|
|
667
|
+
perPage,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return response.json({ available: false, overview: null, jobs: [], total: 0 });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* GET {dashboardPath}/api/jobs/:id — Single job detail.
|
|
676
|
+
*/
|
|
677
|
+
async jobDetail({ params, response }) {
|
|
678
|
+
const inspector = await this.getQueueInspector();
|
|
679
|
+
if (!inspector) {
|
|
680
|
+
return response.notFound({ error: 'Queue not available' });
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
const detail = await inspector.getJob(String(params.id));
|
|
684
|
+
if (!detail)
|
|
685
|
+
return response.notFound({ error: 'Job not found' });
|
|
686
|
+
return response.json(detail);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return response.notFound({ error: 'Job not found' });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* POST {dashboardPath}/api/jobs/:id/retry — Retry a failed job.
|
|
694
|
+
*/
|
|
695
|
+
async jobRetry({ params, response }) {
|
|
696
|
+
const inspector = await this.getQueueInspector();
|
|
697
|
+
if (!inspector) {
|
|
698
|
+
return response.notFound({ error: 'Queue not available' });
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const success = await inspector.retryJob(String(params.id));
|
|
702
|
+
if (!success) {
|
|
703
|
+
return response.badRequest({ error: 'Job could not be retried (not in failed state)' });
|
|
704
|
+
}
|
|
705
|
+
return response.json({ success: true });
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return response.internalServerError({ error: 'Retry failed' });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
// Config
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
/**
|
|
715
|
+
* GET {dashboardPath}/api/config — Sanitized app config and env vars.
|
|
716
|
+
*/
|
|
717
|
+
async config({ response }) {
|
|
718
|
+
const configData = this.configInspector.getConfig();
|
|
719
|
+
const envData = this.configInspector.getEnvVars();
|
|
720
|
+
return response.json({
|
|
721
|
+
config: configData.config,
|
|
722
|
+
env: envData.env,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
// Saved Filters
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
/**
|
|
729
|
+
* GET {dashboardPath}/api/filters — List saved filter presets.
|
|
730
|
+
*/
|
|
731
|
+
async savedFilters({ response }) {
|
|
732
|
+
const db = this.dashboardStore.getDb();
|
|
733
|
+
if (!db)
|
|
734
|
+
return response.json({ filters: [] });
|
|
735
|
+
try {
|
|
736
|
+
const filters = await db('server_stats_saved_filters')
|
|
737
|
+
.orderBy('created_at', 'desc');
|
|
738
|
+
return response.json({
|
|
739
|
+
filters: filters.map((f) => ({
|
|
740
|
+
id: f.id,
|
|
741
|
+
name: f.name,
|
|
742
|
+
section: f.section,
|
|
743
|
+
filterConfig: safeParseJson(f.filter_config),
|
|
744
|
+
createdAt: f.created_at,
|
|
745
|
+
})),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return response.json({ filters: [] });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* POST {dashboardPath}/api/filters — Create a saved filter preset.
|
|
754
|
+
*/
|
|
755
|
+
async createSavedFilter({ request, response }) {
|
|
756
|
+
const db = this.dashboardStore.getDb();
|
|
757
|
+
if (!db)
|
|
758
|
+
return response.serviceUnavailable({ error: 'Database not available' });
|
|
759
|
+
try {
|
|
760
|
+
const body = request.body();
|
|
761
|
+
const name = body.name;
|
|
762
|
+
const section = body.section;
|
|
763
|
+
const filterConfig = body.filterConfig;
|
|
764
|
+
if (!name || !section || !filterConfig) {
|
|
765
|
+
return response.badRequest({ error: 'Missing required fields: name, section, filterConfig' });
|
|
766
|
+
}
|
|
767
|
+
const [id] = await db('server_stats_saved_filters').insert({
|
|
768
|
+
name,
|
|
769
|
+
section,
|
|
770
|
+
filter_config: typeof filterConfig === 'string' ? filterConfig : JSON.stringify(filterConfig),
|
|
771
|
+
});
|
|
772
|
+
return response.json({
|
|
773
|
+
id,
|
|
774
|
+
name,
|
|
775
|
+
section,
|
|
776
|
+
filterConfig: typeof filterConfig === 'string' ? safeParseJson(filterConfig) : filterConfig,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return response.internalServerError({ error: 'Failed to create filter' });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* DELETE {dashboardPath}/api/filters/:id — Delete a saved filter preset.
|
|
785
|
+
*/
|
|
786
|
+
async deleteSavedFilter({ params, response }) {
|
|
787
|
+
const db = this.dashboardStore.getDb();
|
|
788
|
+
if (!db)
|
|
789
|
+
return response.serviceUnavailable({ error: 'Database not available' });
|
|
790
|
+
try {
|
|
791
|
+
const id = Number(params.id);
|
|
792
|
+
const deleted = await db('server_stats_saved_filters').where('id', id).del();
|
|
793
|
+
if (!deleted)
|
|
794
|
+
return response.notFound({ error: 'Filter not found' });
|
|
795
|
+
return response.json({ success: true });
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return response.internalServerError({ error: 'Failed to delete filter' });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Private helpers
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
/**
|
|
805
|
+
* Check if the current request is authorized via shouldShow.
|
|
806
|
+
*/
|
|
807
|
+
checkAccess(ctx) {
|
|
808
|
+
const config = this.app.config.get('server_stats');
|
|
809
|
+
if (!config?.shouldShow)
|
|
810
|
+
return true;
|
|
811
|
+
try {
|
|
812
|
+
return config.shouldShow(ctx);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Get the configured dashboard path.
|
|
820
|
+
*/
|
|
821
|
+
getDashboardPath() {
|
|
822
|
+
const config = this.app.config.get('server_stats');
|
|
823
|
+
return config?.devToolbar?.dashboardPath ?? '/__stats';
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Try to locate and read the @adonisjs/transmit-client build file.
|
|
827
|
+
* Returns the file contents wrapped to expose `window.Transmit`, or
|
|
828
|
+
* an empty string if the package is not installed.
|
|
829
|
+
*/
|
|
830
|
+
loadTransmitClient() {
|
|
831
|
+
try {
|
|
832
|
+
const req = createRequire(this.app.makePath('package.json'));
|
|
833
|
+
const clientPath = req.resolve('@adonisjs/transmit-client/build/index.js');
|
|
834
|
+
const src = readFileSync(clientPath, 'utf-8');
|
|
835
|
+
console.log('[server-stats] Transmit client loaded from:', clientPath, `(${src.length} bytes)`);
|
|
836
|
+
// The file is ESM with `export { Transmit }`. Wrap it in an IIFE
|
|
837
|
+
// that captures the export and assigns it to window.Transmit.
|
|
838
|
+
return `(function(){var __exports={};(function(){${src.replace(/^export\s*\{[^}]*\}\s*;?\s*$/m, '')}\n__exports.Transmit=Transmit;})();window.Transmit=__exports.Transmit;})()`;
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
console.log('[server-stats] Transmit client not available:', err?.message || 'unknown error');
|
|
842
|
+
console.log('[server-stats] Dashboard will use polling. Install @adonisjs/transmit-client for real-time updates.');
|
|
843
|
+
return '';
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Lazily initialize and return the CacheInspector (if Redis is available).
|
|
848
|
+
*/
|
|
849
|
+
async getCacheInspector() {
|
|
850
|
+
if (this.cacheAvailable === false)
|
|
851
|
+
return null;
|
|
852
|
+
if (this.cacheInspector)
|
|
853
|
+
return this.cacheInspector;
|
|
854
|
+
try {
|
|
855
|
+
const available = await CacheInspector.isAvailable(this.app);
|
|
856
|
+
this.cacheAvailable = available;
|
|
857
|
+
if (!available)
|
|
858
|
+
return null;
|
|
859
|
+
const redis = await this.app.container.make('redis');
|
|
860
|
+
this.cacheInspector = new CacheInspector(redis);
|
|
861
|
+
return this.cacheInspector;
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
this.cacheAvailable = false;
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Lazily initialize and return the QueueInspector (if Bull Queue is available).
|
|
870
|
+
*/
|
|
871
|
+
async getQueueInspector() {
|
|
872
|
+
if (this.queueAvailable === false)
|
|
873
|
+
return null;
|
|
874
|
+
if (this.queueInspector)
|
|
875
|
+
return this.queueInspector;
|
|
876
|
+
try {
|
|
877
|
+
const available = await QueueInspector.isAvailable(this.app);
|
|
878
|
+
this.queueAvailable = available;
|
|
879
|
+
if (!available)
|
|
880
|
+
return null;
|
|
881
|
+
const queue = await this.app.container.make('queue');
|
|
882
|
+
this.queueInspector = new QueueInspector(queue);
|
|
883
|
+
return this.queueInspector;
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
this.queueAvailable = false;
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
// Formatting helpers
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
function formatRequest(r) {
|
|
895
|
+
return {
|
|
896
|
+
id: r.id,
|
|
897
|
+
method: r.method,
|
|
898
|
+
url: r.url,
|
|
899
|
+
statusCode: r.status_code,
|
|
900
|
+
duration: r.duration,
|
|
901
|
+
spanCount: r.span_count,
|
|
902
|
+
warningCount: r.warning_count,
|
|
903
|
+
createdAt: r.created_at,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function formatQuery(q) {
|
|
907
|
+
return {
|
|
908
|
+
id: q.id,
|
|
909
|
+
requestId: q.request_id,
|
|
910
|
+
sql: q.sql_text,
|
|
911
|
+
sqlNormalized: q.sql_normalized,
|
|
912
|
+
bindings: safeParseJson(q.bindings),
|
|
913
|
+
duration: q.duration,
|
|
914
|
+
method: q.method,
|
|
915
|
+
model: q.model,
|
|
916
|
+
connection: q.connection,
|
|
917
|
+
inTransaction: !!q.in_transaction,
|
|
918
|
+
createdAt: q.created_at,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function formatTrace(t) {
|
|
922
|
+
return {
|
|
923
|
+
id: t.id,
|
|
924
|
+
requestId: t.request_id,
|
|
925
|
+
method: t.method,
|
|
926
|
+
url: t.url,
|
|
927
|
+
statusCode: t.status_code,
|
|
928
|
+
totalDuration: t.total_duration,
|
|
929
|
+
spanCount: t.span_count,
|
|
930
|
+
spans: safeParseJson(t.spans) ?? [],
|
|
931
|
+
warnings: safeParseJsonArray(t.warnings),
|
|
932
|
+
createdAt: t.created_at,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
function formatEmail(e) {
|
|
936
|
+
return {
|
|
937
|
+
id: e.id,
|
|
938
|
+
from: e.from_addr,
|
|
939
|
+
to: e.to_addr,
|
|
940
|
+
cc: e.cc,
|
|
941
|
+
bcc: e.bcc,
|
|
942
|
+
subject: e.subject,
|
|
943
|
+
mailer: e.mailer,
|
|
944
|
+
status: e.status,
|
|
945
|
+
messageId: e.message_id,
|
|
946
|
+
attachmentCount: e.attachment_count,
|
|
947
|
+
createdAt: e.created_at,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
function emptyOverview() {
|
|
951
|
+
return {
|
|
952
|
+
avgResponseTime: 0,
|
|
953
|
+
p95ResponseTime: 0,
|
|
954
|
+
requestsPerMinute: 0,
|
|
955
|
+
errorRate: 0,
|
|
956
|
+
sparklines: {
|
|
957
|
+
avgResponseTime: [],
|
|
958
|
+
p95ResponseTime: [],
|
|
959
|
+
requestsPerMinute: [],
|
|
960
|
+
errorRate: [],
|
|
961
|
+
},
|
|
962
|
+
slowestEndpoints: [],
|
|
963
|
+
queryStats: { total: 0, avgDuration: 0, perRequest: 0 },
|
|
964
|
+
recentErrors: [],
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
// Utility functions
|
|
969
|
+
// ---------------------------------------------------------------------------
|
|
970
|
+
function round(n) {
|
|
971
|
+
return Math.round(n * 100) / 100;
|
|
972
|
+
}
|
|
973
|
+
function clamp(value, min, max) {
|
|
974
|
+
return Math.max(min, Math.min(max, value));
|
|
975
|
+
}
|
|
976
|
+
function minutesAgo(minutes) {
|
|
977
|
+
const d = new Date(Date.now() - minutes * 60_000);
|
|
978
|
+
return d.toISOString().replace('T', ' ').slice(0, 19);
|
|
979
|
+
}
|
|
980
|
+
function parseRange(range) {
|
|
981
|
+
const rangeMap = {
|
|
982
|
+
'5m': 5,
|
|
983
|
+
'15m': 15,
|
|
984
|
+
'30m': 30,
|
|
985
|
+
'1h': 60,
|
|
986
|
+
'6h': 360,
|
|
987
|
+
'24h': 1440,
|
|
988
|
+
'7d': 10080,
|
|
989
|
+
};
|
|
990
|
+
const minutes = rangeMap[range] ?? 60;
|
|
991
|
+
return { cutoff: minutesAgo(minutes) };
|
|
992
|
+
}
|
|
993
|
+
function safeParseJson(value) {
|
|
994
|
+
if (value === null || value === undefined)
|
|
995
|
+
return null;
|
|
996
|
+
if (typeof value !== 'string')
|
|
997
|
+
return value;
|
|
998
|
+
try {
|
|
999
|
+
return JSON.parse(value);
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
return value;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function safeParseJsonArray(value) {
|
|
1006
|
+
const parsed = safeParseJson(value);
|
|
1007
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1008
|
+
}
|