Skip to content

Commit dad76ca

Browse files
Revert "chore: wip"
This reverts commit d2a1b26.
1 parent fdf8eed commit dad76ca

File tree

17 files changed

+5927
-985
lines changed

17 files changed

+5927
-985
lines changed

app/Controllers/QueryController.ts

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { config } from '@stacksjs/config'
2+
import { db } from '@stacksjs/database'
3+
import { Controller } from '@stacksjs/server'
4+
import { sql } from 'kysely'
5+
6+
export default class QueryController extends Controller {
7+
/**
8+
* Get query statistics for the dashboard
9+
*/
10+
static async getStats() {
11+
try {
12+
// Get the total number of queries
13+
const totalQueries = await db
14+
.selectFrom('query_logs')
15+
.select(db.fn.count('id').as('count'))
16+
.executeTakeFirstOrThrow()
17+
18+
// Get counts by query type
19+
const queryTypeStats = await db
20+
.selectFrom('query_logs')
21+
.select([
22+
sql`json_extract(tags, "$[0]")`.as('type'),
23+
db.fn.count('id').as('count'),
24+
])
25+
.groupBy(sql`json_extract(tags, "$[0]")`)
26+
.execute()
27+
28+
// Get counts by status
29+
const statusStats = await db
30+
.selectFrom('query_logs')
31+
.select(['status', db.fn.count('id').as('count')])
32+
.groupBy('status')
33+
.execute()
34+
35+
// Get average duration by query type
36+
const durationStats = await db
37+
.selectFrom('query_logs')
38+
.select([
39+
sql`json_extract(tags, "$[0]")`.as('type'),
40+
db.fn.avg('duration').as('avg_duration'),
41+
])
42+
.groupBy(sql`json_extract(tags, "$[0]")`)
43+
.execute()
44+
45+
// Get count of slow queries over time (last 24 hours)
46+
const slowQueriesTimeline = await db
47+
.selectFrom('query_logs')
48+
.select([
49+
sql`strftime("%Y-%m-%d %H:00:00", executed_at)`.as('hour'),
50+
db.fn.count('id').as('count'),
51+
])
52+
.where('status', '=', 'slow')
53+
.where('executed_at', '>=', sql`datetime("now", "-1 day")` as any)
54+
.groupBy(sql`strftime("%Y-%m-%d %H:00:00", executed_at)`)
55+
.orderBy('hour')
56+
.execute()
57+
58+
return {
59+
totalQueries: totalQueries.count,
60+
byType: queryTypeStats,
61+
byStatus: statusStats,
62+
avgDuration: durationStats,
63+
slowQueriesTimeline,
64+
// Include system settings for reference
65+
settings: {
66+
slowThreshold: config.database?.queryLogging?.slowThreshold || 100,
67+
},
68+
}
69+
}
70+
catch (error: unknown) {
71+
const err = error as Error
72+
throw new Error(`Failed to fetch query statistics: ${err.message}`)
73+
}
74+
}
75+
76+
/**
77+
* Get a paginated list of recent queries
78+
*/
79+
static async getRecentQueries({
80+
page = 1,
81+
perPage = 10,
82+
connection = 'all',
83+
type = 'all',
84+
status = 'all',
85+
search = '',
86+
}) {
87+
try {
88+
let query = db
89+
.selectFrom('query_logs')
90+
.select([
91+
'id',
92+
'query',
93+
'normalized_query',
94+
'duration',
95+
'connection',
96+
'status',
97+
'executed_at',
98+
'model',
99+
'method',
100+
'rows_affected',
101+
'tags',
102+
])
103+
.orderBy('executed_at', 'desc')
104+
105+
// Apply filters
106+
if (connection !== 'all')
107+
query = query.where('connection', '=', connection)
108+
109+
if (status !== 'all')
110+
query = query.where('status', '=', status as any)
111+
112+
if (type !== 'all')
113+
query = query.where('tags', 'like', `%"${type}"%`)
114+
115+
if (search) {
116+
query = query.where(eb => eb.or([
117+
eb('query', 'like', `%${search}%`),
118+
eb('model', 'like', `%${search}%`),
119+
eb('method', 'like', `%${search}%`),
120+
eb('affected_tables', 'like', `%${search}%`),
121+
]))
122+
}
123+
124+
// Get total count for pagination
125+
const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
126+
const totalResult = await countQuery.executeTakeFirstOrThrow()
127+
const total = Number(totalResult.count)
128+
129+
// Apply pagination
130+
const offset = (page - 1) * perPage
131+
query = query.limit(perPage).offset(offset)
132+
133+
// Execute paginated query
134+
const results = await query.execute()
135+
136+
return {
137+
data: results,
138+
meta: {
139+
current_page: page,
140+
per_page: perPage,
141+
total,
142+
last_page: Math.ceil(total / perPage),
143+
},
144+
}
145+
}
146+
catch (error: unknown) {
147+
const err = error as Error
148+
throw new Error(`Failed to fetch recent queries: ${err.message}`)
149+
}
150+
}
151+
152+
/**
153+
* Get a list of slow queries
154+
*/
155+
static async getSlowQueries({
156+
page = 1,
157+
perPage = 10,
158+
threshold = 0,
159+
connection = 'all',
160+
search = '',
161+
}) {
162+
try {
163+
let slowThreshold = threshold
164+
if (slowThreshold < 0)
165+
slowThreshold = config.database?.queryLogging?.slowThreshold || 100
166+
167+
let query = db
168+
.selectFrom('query_logs')
169+
.select([
170+
'id',
171+
'query',
172+
'normalized_query',
173+
'duration',
174+
'connection',
175+
'status',
176+
'executed_at',
177+
'model',
178+
'method',
179+
'rows_affected',
180+
'optimization_suggestions',
181+
'affected_tables',
182+
'indexes_used',
183+
'missing_indexes',
184+
])
185+
.where('duration', '>=', slowThreshold)
186+
.orderBy('duration', 'desc')
187+
188+
if (connection !== 'all')
189+
query = query.where('connection', '=', connection)
190+
191+
if (search) {
192+
query = query.where(eb => eb.or([
193+
eb('query', 'like', `%${search}%`),
194+
eb('model', 'like', `%${search}%`),
195+
eb('method', 'like', `%${search}%`),
196+
eb('affected_tables', 'like', `%${search}%`),
197+
]))
198+
}
199+
200+
const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
201+
const totalResult = await countQuery.executeTakeFirstOrThrow()
202+
const total = Number(totalResult.count)
203+
204+
const offset = (page - 1) * perPage
205+
query = query.limit(perPage).offset(offset)
206+
207+
const results = await query.execute()
208+
209+
return {
210+
data: results,
211+
meta: {
212+
current_page: page,
213+
per_page: perPage,
214+
total,
215+
last_page: Math.ceil(total / perPage),
216+
threshold: slowThreshold,
217+
},
218+
}
219+
}
220+
catch (error: unknown) {
221+
const err = error as Error
222+
throw new Error(`Failed to fetch slow queries: ${err.message}`)
223+
}
224+
}
225+
226+
/**
227+
* Get a single query by ID
228+
*/
229+
static async getQuery(id: number) {
230+
try {
231+
const query = await db
232+
.selectFrom('query_logs')
233+
.selectAll()
234+
.where('id', '=', id)
235+
.executeTakeFirst()
236+
237+
if (!query)
238+
throw new Error('Query not found')
239+
240+
// Parse JSON fields
241+
return {
242+
...query,
243+
bindings: query.bindings ? JSON.parse(query.bindings) : null,
244+
tags: query.tags ? JSON.parse(query.tags) : [],
245+
affected_tables: query.affected_tables ? JSON.parse(query.affected_tables) : [],
246+
indexes_used: query.indexes_used ? JSON.parse(query.indexes_used) : [],
247+
missing_indexes: query.missing_indexes ? JSON.parse(query.missing_indexes) : [],
248+
optimization_suggestions: query.optimization_suggestions
249+
? JSON.parse(query.optimization_suggestions)
250+
: [],
251+
}
252+
}
253+
catch (error: any) {
254+
throw new Error(`Failed to fetch query: ${error.message}`)
255+
}
256+
}
257+
258+
/**
259+
* Get query timeline data for charts
260+
*/
261+
static async getQueryTimeline({
262+
timeframe = 'day', // 'day', 'week', 'month'
263+
type = 'all',
264+
}) {
265+
try {
266+
let interval: string
267+
let timeConstraint: string
268+
269+
// Set time grouping format and constraint based on timeframe
270+
switch (timeframe) {
271+
case 'week':
272+
interval = '%Y-%m-%d'
273+
timeConstraint = '-7 day'
274+
break
275+
case 'month':
276+
interval = '%Y-%m-%d'
277+
timeConstraint = '-30 day'
278+
break
279+
case 'day':
280+
default:
281+
interval = '%Y-%m-%d %H:00:00'
282+
timeConstraint = '-24 hour'
283+
break
284+
}
285+
286+
let query = db
287+
.selectFrom('query_logs')
288+
.select([
289+
sql`strftime("${interval}", executed_at)`.as('time_interval'),
290+
db.fn.count('id').as('count'),
291+
db.fn.avg('duration').as('avg_duration'),
292+
])
293+
.where('executed_at', '>=', sql`datetime("now", "${timeConstraint}")` as any)
294+
.groupBy('time_interval')
295+
.orderBy('time_interval')
296+
297+
// Apply type filter if specified
298+
if (type !== 'all')
299+
query = query.where('tags', 'like', `%"${type}"%`)
300+
301+
const results = await query.execute()
302+
303+
return {
304+
data: results,
305+
meta: {
306+
timeframe,
307+
type,
308+
},
309+
}
310+
}
311+
catch (error: unknown) {
312+
const err = error as Error
313+
throw new Error(`Failed to fetch query timeline: ${err.message}`)
314+
}
315+
}
316+
317+
/**
318+
* Get the most frequently run normalized queries
319+
*/
320+
static async getFrequentQueries(): Promise<Array<{
321+
normalized_query: string
322+
count: string | number | bigint
323+
avg_duration: string | number
324+
max_duration: number | undefined
325+
}>> {
326+
try {
327+
const results = await db
328+
.selectFrom('query_logs')
329+
.select([
330+
'normalized_query',
331+
db.fn.count('id').as('count'),
332+
db.fn.avg('duration').as('avg_duration'),
333+
db.fn.max('duration').as('max_duration'),
334+
])
335+
.groupBy('normalized_query')
336+
.orderBy('count', 'desc')
337+
.limit(10)
338+
.execute()
339+
340+
return results
341+
}
342+
catch (error: unknown) {
343+
const err = error as Error
344+
throw new Error(`Failed to fetch frequent queries: ${err.message}`)
345+
}
346+
}
347+
348+
/**
349+
* Prune old query logs
350+
*/
351+
static async pruneQueryLogs() {
352+
try {
353+
const retentionDays = config.database?.queryLogging?.retention || 7
354+
355+
const result = await db
356+
.deleteFrom('query_logs')
357+
.where('executed_at', '<', sql`datetime("now", "-${retentionDays} day")` as any)
358+
.executeTakeFirst()
359+
360+
return {
361+
pruned: result.numDeletedRows,
362+
retentionDays,
363+
}
364+
}
365+
catch (error: unknown) {
366+
const err = error as Error
367+
throw new Error(`Failed to prune query logs: ${err.message}`)
368+
}
369+
}
370+
}

0 commit comments

Comments
 (0)