@stacksjs/defaults 0.75.482 → 0.75.483
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/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
- package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
- package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
- package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
- package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
- package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
- package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
- package/app/Actions/Dashboard/Content/content-list.ts +69 -0
- package/app/Actions/Dashboard/Content/page-list.ts +108 -0
- package/app/Actions/Dashboard/Content/post-list.ts +140 -0
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
- package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
- package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
- package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
- package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
- package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
- package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
- package/app/Actions/Monitoring/error-provider.ts +85 -7
- package/functions/api.ts +14 -10
- package/functions/commerce/coupons.ts +4 -6
- package/functions/commerce/customers.ts +4 -6
- package/functions/commerce/gift-cards.ts +4 -2
- package/functions/commerce/orders.ts +4 -2
- package/functions/commerce/payments.ts +4 -2
- package/functions/commerce/products/categories.ts +4 -6
- package/functions/commerce/products/manufacturers.ts +4 -6
- package/functions/commerce/products/products.ts +4 -6
- package/functions/commerce/products/reviews.ts +4 -6
- package/functions/commerce/products/units.ts +4 -6
- package/functions/commerce/products/variants.ts +4 -6
- package/functions/commerce/taxes.ts +4 -2
- package/functions/commerce/waitlist/products.ts +4 -6
- package/functions/commerce/waitlist/restaurants.ts +4 -6
- package/functions/monitoring/errors.ts +77 -49
- package/functions/queries.ts +34 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
- package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
- package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
- package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
- package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
- package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
- package/resources/components/Dashboard/UI/Table.stx +30 -7
- package/routes/dashboard-api.ts +1 -0
- package/app/Actions/Buddy/JobsListAction.ts +0 -141
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { db } from '@stacksjs/database/runtime'
|
|
2
|
+
import type { DashboardJobStatus, ModelRecord, NormalizedJob } from './job-records'
|
|
3
|
+
import { normalizeActiveJob, normalizeFailedJob, parseJobReference } from './job-records'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One page of the Jobs list, read by the database (stacksjs/stacks#3201).
|
|
7
|
+
*
|
|
8
|
+
* Jobs live in two tables: `jobs`, waiting or reserved, and `failed_jobs`.
|
|
9
|
+
* The list used to read both whole and page them in memory. Now each is
|
|
10
|
+
* filtered and counted in SQL. A page is newest first across both: for page
|
|
11
|
+
* p of n, each table gives its newest p * n matches, which must hold the
|
|
12
|
+
* page, and the two are merged and sliced. Deep pages read more, never more
|
|
13
|
+
* than the matching rows.
|
|
14
|
+
*
|
|
15
|
+
* Through `db` rather than the models: a model query's `whereLike` is a plain
|
|
16
|
+
* LIKE, which is case-sensitive on PostgreSQL, while `db` compares both sides
|
|
17
|
+
* lowercased on every dialect.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface JobListQuery {
|
|
21
|
+
page: number
|
|
22
|
+
perPage: number
|
|
23
|
+
/** An exact queue, or empty for every queue. */
|
|
24
|
+
queue: string
|
|
25
|
+
status: DashboardJobStatus | ''
|
|
26
|
+
search: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JobListPage {
|
|
30
|
+
data: NormalizedJob[]
|
|
31
|
+
total: number
|
|
32
|
+
page: number
|
|
33
|
+
/** Every queue with a job or a failure, for the queue filter. */
|
|
34
|
+
queues: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STATES: DashboardJobStatus[] = ['queued', 'processing', 'failed']
|
|
38
|
+
|
|
39
|
+
/** The states whose name holds the search, which the list always searched. */
|
|
40
|
+
function statesNamed(term: string): Set<DashboardJobStatus> {
|
|
41
|
+
return new Set(term ? STATES.filter(state => state.includes(term)) : [])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An id the search names: `12`, or a reference such as `job-12`. */
|
|
45
|
+
function idNamed(term: string, source: 'job' | 'failed'): number | null {
|
|
46
|
+
const reference = parseJobReference(term)
|
|
47
|
+
const id = reference.source === null || reference.source === source ? reference.id : ''
|
|
48
|
+
return /^\d+$/.test(id) ? Number(id) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A job is reserved when `reserved_at` is set and not 0, `queuedJobState`'s
|
|
53
|
+
* rule in `@stacksjs/queue`. The queue writes NULL or a timestamp, never 0.
|
|
54
|
+
*/
|
|
55
|
+
function activeQuery(query: JobListQuery): any | null {
|
|
56
|
+
if (query.status === 'failed')
|
|
57
|
+
return null
|
|
58
|
+
|
|
59
|
+
let chain: any = db.selectFrom('jobs')
|
|
60
|
+
if (query.queue)
|
|
61
|
+
chain = chain.where('queue', '=', query.queue)
|
|
62
|
+
if (query.status === 'processing')
|
|
63
|
+
chain = chain.whereNotNull('reserved_at').where('reserved_at', '!=', 0)
|
|
64
|
+
else if (query.status === 'queued')
|
|
65
|
+
chain = chain.whereGroup((group: any) => group.whereNull('reserved_at').orWhere('reserved_at', '=', 0))
|
|
66
|
+
|
|
67
|
+
const term = query.search.toLowerCase()
|
|
68
|
+
if (term) {
|
|
69
|
+
const states = statesNamed(term)
|
|
70
|
+
// Every waiting job's reference is `job-<id>`, so a search inside that
|
|
71
|
+
// word names them all, as it did when the reference itself was searched.
|
|
72
|
+
if ((states.has('queued') && states.has('processing')) || 'job-'.includes(term))
|
|
73
|
+
return chain
|
|
74
|
+
const id = idNamed(term, 'job')
|
|
75
|
+
chain = chain.whereGroup((group: any) => {
|
|
76
|
+
let match = group.whereLike('queue', `%${term}%`).orWhereLike('payload', `%${term}%`)
|
|
77
|
+
if (id !== null)
|
|
78
|
+
match = match.orWhere('id', '=', id)
|
|
79
|
+
if (states.has('queued'))
|
|
80
|
+
match = match.orWhereNull('reserved_at').orWhere('reserved_at', '=', 0)
|
|
81
|
+
if (states.has('processing'))
|
|
82
|
+
match = match.orWhere('reserved_at', '!=', 0)
|
|
83
|
+
return match
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
return chain
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function failedQuery(query: JobListQuery): any | null {
|
|
90
|
+
if (query.status === 'queued' || query.status === 'processing')
|
|
91
|
+
return null
|
|
92
|
+
|
|
93
|
+
let chain: any = db.selectFrom('failed_jobs')
|
|
94
|
+
if (query.queue)
|
|
95
|
+
chain = chain.where('queue', '=', query.queue)
|
|
96
|
+
|
|
97
|
+
const term = query.search.toLowerCase()
|
|
98
|
+
if (term && !statesNamed(term).has('failed') && !'failed-'.includes(term)) {
|
|
99
|
+
const id = idNamed(term, 'failed')
|
|
100
|
+
chain = chain.whereGroup((group: any) => {
|
|
101
|
+
let match = group.whereLike('queue', `%${term}%`)
|
|
102
|
+
.orWhereLike('payload', `%${term}%`)
|
|
103
|
+
.orWhereLike('exception', `%${term}%`)
|
|
104
|
+
.orWhereLike('connection', `%${term}%`)
|
|
105
|
+
if (id !== null)
|
|
106
|
+
match = match.orWhere('id', '=', id)
|
|
107
|
+
return match
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
return chain
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A row as the normalizers read a model record. */
|
|
114
|
+
function asRecord(row: Record<string, unknown>): ModelRecord {
|
|
115
|
+
return { get: (key: string) => row[key] } as unknown as ModelRecord
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function countOf(chain: any | null): Promise<number> {
|
|
119
|
+
if (!chain)
|
|
120
|
+
return 0
|
|
121
|
+
const row = await chain.select(db.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined
|
|
122
|
+
return Number(row?.count ?? 0) || 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function newestRows(chain: any, limit: number): Promise<Record<string, unknown>[]> {
|
|
126
|
+
return await chain.selectAll().orderBy('created_at', 'desc').orderBy('id', 'desc').limit(limit).execute() as Record<string, unknown>[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Newest first; at the same moment a waiting job before a failure, then the higher id, as before. */
|
|
130
|
+
function newestFirst(a: NormalizedJob, b: NormalizedJob): number {
|
|
131
|
+
const byTime = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
132
|
+
if (byTime !== 0)
|
|
133
|
+
return byTime
|
|
134
|
+
if (a.source !== b.source)
|
|
135
|
+
return a.source === 'job' ? -1 : 1
|
|
136
|
+
return Number(b.recordId) - Number(a.recordId)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function readJobPage(query: JobListQuery): Promise<JobListPage> {
|
|
140
|
+
const active = activeQuery(query)
|
|
141
|
+
const failed = failedQuery(query)
|
|
142
|
+
|
|
143
|
+
const [activeTotal, failedTotal, queueRows] = await Promise.all([
|
|
144
|
+
countOf(active),
|
|
145
|
+
countOf(failed),
|
|
146
|
+
db.unsafe('SELECT DISTINCT queue FROM jobs UNION SELECT DISTINCT queue FROM failed_jobs').execute() as Promise<Array<{ queue: unknown }>>,
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
const total = activeTotal + failedTotal
|
|
150
|
+
const page = Math.min(query.page, Math.max(1, Math.ceil(total / query.perPage)))
|
|
151
|
+
const limit = page * query.perPage
|
|
152
|
+
|
|
153
|
+
const [activeRows, failedRows] = await Promise.all([
|
|
154
|
+
activeTotal > 0 ? newestRows(activeQuery(query), limit) : Promise.resolve([]),
|
|
155
|
+
failedTotal > 0 ? newestRows(failedQuery(query), limit) : Promise.resolve([]),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
const data = [...activeRows.map(row => normalizeActiveJob(asRecord(row))), ...failedRows.map(row => normalizeFailedJob(asRecord(row)))]
|
|
159
|
+
.sort(newestFirst)
|
|
160
|
+
.slice((page - 1) * query.perPage, limit)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
data,
|
|
164
|
+
total,
|
|
165
|
+
page,
|
|
166
|
+
queues: [...new Set(queueRows.map(row => String(row.queue ?? '')).filter(Boolean))].sort(),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReadableRecord } from '@stacksjs/orm'
|
|
2
|
+
import { queuedJobState } from '@stacksjs/queue'
|
|
2
3
|
export type JobRecordSource = 'job' | 'failed'
|
|
3
4
|
export type DashboardJobStatus = 'queued' | 'processing' | 'failed'
|
|
4
5
|
|
|
@@ -154,6 +155,10 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
|
|
|
154
155
|
const payload = parsePayload(record.get('payload'))
|
|
155
156
|
const queue = requiredString(record, 'queue')
|
|
156
157
|
const reservedAt = timestampValue(record, 'reserved_at', 'Job', true)
|
|
158
|
+
// Reserved is `queuedJobState`'s rule: set and not 0. A 0 parsed as 1970
|
|
159
|
+
// and read as processing here while the queue and the stats called it
|
|
160
|
+
// waiting (stacksjs/stacks#3201).
|
|
161
|
+
const reserved = queuedJobState({ reserved_at: record.get('reserved_at'), available_at: record.get('available_at') }) === 'processing'
|
|
157
162
|
|
|
158
163
|
return {
|
|
159
164
|
id: jobReference('job', id),
|
|
@@ -162,7 +167,7 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
|
|
|
162
167
|
name: jobName(payload, queue),
|
|
163
168
|
queue,
|
|
164
169
|
connection: null,
|
|
165
|
-
status:
|
|
170
|
+
status: reserved ? 'processing' : 'queued',
|
|
166
171
|
attempts: integerValue(record, 'attempts'),
|
|
167
172
|
maxAttempts: payloadMaxAttempts(payload),
|
|
168
173
|
duration: null,
|
|
@@ -171,8 +176,8 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
|
|
|
171
176
|
created_at: timestampValue(record, 'created_at')!,
|
|
172
177
|
updated_at: timestampValue(record, 'updated_at', 'Job', true),
|
|
173
178
|
available_at: timestampValue(record, 'available_at', 'Job', true),
|
|
174
|
-
reserved_at: reservedAt,
|
|
175
|
-
started_at: reservedAt,
|
|
179
|
+
reserved_at: reserved ? reservedAt : undefined,
|
|
180
|
+
started_at: reserved ? reservedAt : undefined,
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
|
|
@@ -269,11 +269,17 @@ export default new Action({
|
|
|
269
269
|
chain = chain.where(column, value)
|
|
270
270
|
}
|
|
271
271
|
if (q && searchColumns.length > 0) {
|
|
272
|
+
// Lowercased on both sides, so the search ignores case on every
|
|
273
|
+
// dialect. A model query's whereLike is a plain LIKE, which is
|
|
274
|
+
// case-sensitive on PostgreSQL: "donald" did not find "Donald"
|
|
275
|
+
// there (stacksjs/stacks#3202). The column is one of the model's
|
|
276
|
+
// own (isSafeColumn), and the term is bound.
|
|
272
277
|
chain = chain.whereGroup((group: any) => {
|
|
273
278
|
for (const [index, column] of searchColumns.entries()) {
|
|
279
|
+
const match = `LOWER(${column}) LIKE LOWER(?)`
|
|
274
280
|
group = index === 0
|
|
275
|
-
? group.
|
|
276
|
-
: group.
|
|
281
|
+
? group.whereRaw(match, `%${q}%`)
|
|
282
|
+
: group.orWhereRaw(match, `%${q}%`)
|
|
277
283
|
}
|
|
278
284
|
return group
|
|
279
285
|
})
|
|
@@ -1,29 +1,39 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
1
2
|
import { Action } from '@stacksjs/actions/runtime'
|
|
2
3
|
import { config } from '@stacksjs/config'
|
|
3
|
-
import {
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
4
5
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
-
import {
|
|
6
|
+
import { parseQueryListQuery, readQueryLogPage } from './query-list'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* `GET /api/dashboard/queries`: one page of the query log for the Queries
|
|
10
|
+
* pages, with the counts their cards show. The range, filters and page are
|
|
11
|
+
* read by the database (`query-list.ts`, stacksjs/stacks#3204).
|
|
12
|
+
*/
|
|
7
13
|
export default new Action({
|
|
8
14
|
name: 'Query Dashboard',
|
|
9
15
|
description: 'Returns safe, native query log records for the operator dashboard.',
|
|
10
16
|
method: 'GET',
|
|
11
17
|
apiResponse: true,
|
|
12
18
|
|
|
13
|
-
async handle() {
|
|
19
|
+
async handle(request: RequestInstance) {
|
|
20
|
+
const query = parseQueryListQuery(key => request.get<unknown>(key, ''))
|
|
21
|
+
if ('error' in query)
|
|
22
|
+
return response.json({ message: query.error }, 422)
|
|
23
|
+
|
|
14
24
|
const queryLogging = config.database?.queryLogging
|
|
15
25
|
try {
|
|
16
|
-
const
|
|
17
|
-
.selectFrom('query_logs')
|
|
18
|
-
.select(dashboardQueryColumns)
|
|
19
|
-
.orderBy('executed_at', 'desc')
|
|
20
|
-
.limit(2000)
|
|
21
|
-
.execute()
|
|
26
|
+
const result = await readQueryLogPage(query)
|
|
22
27
|
|
|
23
28
|
return {
|
|
24
29
|
enabled: queryLogging?.enabled === true,
|
|
25
30
|
slowThreshold: queryLogging?.slowThreshold || 100,
|
|
26
|
-
queries: rows
|
|
31
|
+
queries: result.rows,
|
|
32
|
+
total: result.counts.total,
|
|
33
|
+
page: result.page,
|
|
34
|
+
perPage: query.perPage,
|
|
35
|
+
counts: result.counts,
|
|
36
|
+
connections: result.connections,
|
|
27
37
|
}
|
|
28
38
|
}
|
|
29
39
|
catch (error) {
|
|
@@ -60,16 +60,56 @@ export interface DashboardQueryLog {
|
|
|
60
60
|
suggestions: string[]
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export const QUERY_TYPES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'OTHER'] as const
|
|
64
|
+
|
|
65
|
+
/** What `queryType` skips before the first word: ASCII whitespace, as SQL can strip it too. */
|
|
66
|
+
const LEADING_SPACE = /^[ \t\n\v\f\r]+/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The statement's kind, from its first word. `queryTypeSql` is the same rule
|
|
70
|
+
* in SQL, so the list can filter and count by kind in the database
|
|
71
|
+
* (stacksjs/stacks#3204); keep the two in step.
|
|
72
|
+
*/
|
|
63
73
|
export function queryType(query: string): DashboardQueryLog['type'] {
|
|
64
|
-
const type = query.
|
|
74
|
+
const type = query.replace(LEADING_SPACE, '').match(/^([a-z]+)/i)?.[1]?.toUpperCase()
|
|
65
75
|
return type === 'SELECT' || type === 'INSERT' || type === 'UPDATE' || type === 'DELETE'
|
|
66
76
|
? type
|
|
67
77
|
: 'OTHER'
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
const ASCII_LETTERS = Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index))
|
|
81
|
+
.flatMap(letter => [letter, letter.toLowerCase()])
|
|
82
|
+
.map(letter => `'${letter}'`)
|
|
83
|
+
.join(', ')
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `queryType` as a SQL expression over `query_logs.query`, for one dialect.
|
|
87
|
+
*
|
|
88
|
+
* The text loses the same leading whitespace, then a kind matches when the
|
|
89
|
+
* text starts with its keyword in any case and the next character is not a
|
|
90
|
+
* letter, which is what `queryType`'s first-word match comes to. MySQL
|
|
91
|
+
* compares bytes here, because its default collation would also match
|
|
92
|
+
* accented letters.
|
|
93
|
+
*/
|
|
94
|
+
export function queryTypeSql(dialect: string): string {
|
|
95
|
+
const space = [9, 10, 11, 12, 13]
|
|
96
|
+
// MySQL's LTRIM strips spaces only. Its pattern holds the whitespace itself
|
|
97
|
+
// in a plain literal, which takes the column's collation where CHAR() would
|
|
98
|
+
// bring its own and be refused.
|
|
99
|
+
const text = dialect === 'mysql'
|
|
100
|
+
? `REGEXP_REPLACE(query, '^[ ${space.map(code => String.fromCharCode(code)).join('')}]+', '')`
|
|
101
|
+
: `LTRIM(query, ' ' || ${space.map(code => `${dialect === 'postgres' ? 'chr' : 'char'}(${code})`).join(' || ')})`
|
|
102
|
+
const exact = (value: string): string => dialect === 'mysql' ? `CAST(${value} AS BINARY)` : value
|
|
103
|
+
const branches = QUERY_TYPES.filter(type => type !== 'OTHER').map(type =>
|
|
104
|
+
`WHEN ${exact(`UPPER(SUBSTR(${text}, 1, ${type.length}))`)} = '${type}' AND ${exact(`SUBSTR(${text}, ${type.length + 1}, 1)`)} NOT IN (${ASCII_LETTERS}) THEN '${type}'`)
|
|
105
|
+
return `(CASE ${branches.join(' ')} ELSE 'OTHER' END)`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseQueryLogList(value?: unknown, label = 'query log list'): string[] {
|
|
109
|
+
if (value == null || value === '')
|
|
72
110
|
return []
|
|
111
|
+
if (typeof value !== 'string')
|
|
112
|
+
throw new TypeError(`${label} must be JSON text`)
|
|
73
113
|
|
|
74
114
|
let parsed: unknown
|
|
75
115
|
try {
|
|
@@ -85,8 +125,15 @@ export function parseQueryLogList(value?: string | null, label = 'query log list
|
|
|
85
125
|
return parsed
|
|
86
126
|
}
|
|
87
127
|
|
|
88
|
-
export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog {
|
|
128
|
+
export function mapDashboardQueryLog(row: QueryLogSourceRow | Record<string, unknown>): DashboardQueryLog {
|
|
89
129
|
const label = `query log ${row.id}`
|
|
130
|
+
if (!((typeof row.id === 'number' && Number.isSafeInteger(row.id) && row.id > 0) || (typeof row.id === 'string' && row.id.trim())))
|
|
131
|
+
throw new TypeError('Query log id must be a positive integer or a non-empty string')
|
|
132
|
+
const text = (value: unknown, field: string): string => {
|
|
133
|
+
if (value == null) return ''
|
|
134
|
+
if (typeof value !== 'string') throw new TypeError(`${label} ${field} must be text or null`)
|
|
135
|
+
return value
|
|
136
|
+
}
|
|
90
137
|
const status = queryStatus(row.status, label)
|
|
91
138
|
if (typeof row.query !== 'string' || !row.query.trim())
|
|
92
139
|
throw new TypeError(`${label} query must be a non-empty string`)
|
|
@@ -97,15 +144,15 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
|
|
|
97
144
|
return {
|
|
98
145
|
id: row.id,
|
|
99
146
|
query: row.query,
|
|
100
|
-
normalizedQuery: row.normalized_query || row.query,
|
|
147
|
+
normalizedQuery: text(row.normalized_query, 'normalized query') || row.query,
|
|
101
148
|
type: queryType(row.query),
|
|
102
149
|
duration: finiteNumber(row.duration, `${label} duration`),
|
|
103
|
-
connection: row.connection || 'unknown',
|
|
150
|
+
connection: text(row.connection, 'connection') || 'unknown',
|
|
104
151
|
status,
|
|
105
|
-
error: row.error
|
|
152
|
+
error: text(row.error, 'error'),
|
|
106
153
|
executedAt,
|
|
107
|
-
model: row.model
|
|
108
|
-
method: row.method
|
|
154
|
+
model: text(row.model, 'model'),
|
|
155
|
+
method: text(row.method, 'method'),
|
|
109
156
|
rowsAffected: nullableFiniteNumber(row.rows_affected, `${label} rows_affected`),
|
|
110
157
|
memoryUsage: nullableFiniteNumber(row.memory_usage, `${label} memory_usage`),
|
|
111
158
|
tags: parseQueryLogList(row.tags, `${label} tags`),
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { db, getDatabaseDialect, sqlDateTime, sqlHelpers } from '@stacksjs/database/runtime'
|
|
2
|
+
import type { QueryReader } from '../Content/content-list'
|
|
3
|
+
import { countOf, LIST_PAGE_SIZES, pageWithin, queryText } from '../Content/content-list'
|
|
4
|
+
import type { DashboardQueryLog } from './query-dashboard'
|
|
5
|
+
import { dashboardQueryColumns, mapDashboardQueryLog, QUERY_TYPES, queryTypeSql } from './query-dashboard'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One page of the query log, read by the database.
|
|
9
|
+
*
|
|
10
|
+
* The Queries pages used to fetch the newest 2,000 logs and filter, count and
|
|
11
|
+
* page those in the browser, so a week, or a slow query older than the
|
|
12
|
+
* newest 2,000, was simply not there (stacksjs/stacks#3204). Now the range,
|
|
13
|
+
* filters, counts and page are all SQL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const QUERY_RANGES = { hour: 1, day: 24, week: 168 } as const
|
|
17
|
+
export const QUERY_STATUSES = ['completed', 'slow', 'failed'] as const
|
|
18
|
+
|
|
19
|
+
export type QueryRange = keyof typeof QUERY_RANGES
|
|
20
|
+
export type QueryStatus = typeof QUERY_STATUSES[number]
|
|
21
|
+
|
|
22
|
+
export interface QueryListQuery {
|
|
23
|
+
range: QueryRange
|
|
24
|
+
status: QueryStatus | 'all'
|
|
25
|
+
type: DashboardQueryLog['type'] | 'all'
|
|
26
|
+
connection: string
|
|
27
|
+
page: number
|
|
28
|
+
perPage: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QueryCounts {
|
|
32
|
+
total: number
|
|
33
|
+
reads: number
|
|
34
|
+
writes: number
|
|
35
|
+
slow: number
|
|
36
|
+
failed: number
|
|
37
|
+
/** Mean duration in milliseconds, 0 when nothing matched. */
|
|
38
|
+
averageDuration: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface QueryLogPage {
|
|
42
|
+
rows: DashboardQueryLog[]
|
|
43
|
+
/** The page the rows are, moved back to the last one when it ran past it. */
|
|
44
|
+
page: number
|
|
45
|
+
/** Counts over every log matching the range and filters; `total` is what the pager counts. */
|
|
46
|
+
counts: QueryCounts
|
|
47
|
+
/** The connections that logged anything in the range, for the connection filter. */
|
|
48
|
+
connections: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The range, filters and page, or the reason they are invalid. Absent values take the page's defaults. */
|
|
52
|
+
export function parseQueryListQuery(read: QueryReader): QueryListQuery | { error: string } {
|
|
53
|
+
const range = queryText(read, 'range') || 'day'
|
|
54
|
+
if (!Object.hasOwn(QUERY_RANGES, range))
|
|
55
|
+
return { error: `range must be one of ${Object.keys(QUERY_RANGES).join(', ')}.` }
|
|
56
|
+
|
|
57
|
+
const status = queryText(read, 'status') || 'all'
|
|
58
|
+
if (status !== 'all' && !(QUERY_STATUSES as readonly string[]).includes(status))
|
|
59
|
+
return { error: `status must be all or one of ${QUERY_STATUSES.join(', ')}.` }
|
|
60
|
+
|
|
61
|
+
const type = queryText(read, 'type') || 'all'
|
|
62
|
+
if (type !== 'all' && !(QUERY_TYPES as readonly string[]).includes(type))
|
|
63
|
+
return { error: `type must be all or one of ${QUERY_TYPES.join(', ')}.` }
|
|
64
|
+
|
|
65
|
+
const connection = queryText(read, 'connection')
|
|
66
|
+
if (connection.length > 200)
|
|
67
|
+
return { error: 'connection must be 200 characters or fewer.' }
|
|
68
|
+
|
|
69
|
+
const pageText = queryText(read, 'page')
|
|
70
|
+
const page = pageText === '' ? 1 : Number(pageText)
|
|
71
|
+
if (!Number.isInteger(page) || page < 1)
|
|
72
|
+
return { error: 'page must be a whole number of 1 or more.' }
|
|
73
|
+
|
|
74
|
+
const sizeText = queryText(read, 'per_page')
|
|
75
|
+
const perPage = sizeText === '' ? 50 : Number(sizeText)
|
|
76
|
+
if (!(LIST_PAGE_SIZES as readonly number[]).includes(perPage))
|
|
77
|
+
return { error: `per_page must be one of ${LIST_PAGE_SIZES.join(', ')}.` }
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
range: range as QueryRange,
|
|
81
|
+
status: status as QueryListQuery['status'],
|
|
82
|
+
type: type as QueryListQuery['type'],
|
|
83
|
+
connection,
|
|
84
|
+
page,
|
|
85
|
+
perPage,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The earliest `executed_at` in the range, in the form the query logger writes it. */
|
|
90
|
+
export function rangeStart(range: QueryRange, now: number = Date.now()): string {
|
|
91
|
+
return sqlDateTime(new Date(now - QUERY_RANGES[range] * 3_600_000))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The WHERE clause for a query, every value bound. The range alone is the
|
|
96
|
+
* connection filter's scope, so it is built separately.
|
|
97
|
+
*/
|
|
98
|
+
function where(query: QueryListQuery, now: number): { range: { sql: string, values: unknown[] }, all: { sql: string, values: unknown[] } } {
|
|
99
|
+
const dialect = getDatabaseDialect()
|
|
100
|
+
const helpers = sqlHelpers(dialect)
|
|
101
|
+
const values: unknown[] = []
|
|
102
|
+
const bind = (value: unknown): string => {
|
|
103
|
+
values.push(value)
|
|
104
|
+
return helpers.param(values.length)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const clauses = [`executed_at >= ${bind(rangeStart(query.range, now))}`]
|
|
108
|
+
const range = { sql: `WHERE ${clauses[0]}`, values: [...values] }
|
|
109
|
+
if (query.status !== 'all')
|
|
110
|
+
clauses.push(`status = ${bind(query.status)}`)
|
|
111
|
+
if (query.type !== 'all')
|
|
112
|
+
clauses.push(`${queryTypeSql(dialect)} = ${bind(query.type)}`)
|
|
113
|
+
if (query.connection)
|
|
114
|
+
clauses.push(`connection = ${bind(query.connection)}`)
|
|
115
|
+
|
|
116
|
+
return { range, all: { sql: `WHERE ${clauses.join(' AND ')}`, values } }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readQueryLogPage(query: QueryListQuery, now: number = Date.now()): Promise<QueryLogPage> {
|
|
120
|
+
const type = queryTypeSql(getDatabaseDialect())
|
|
121
|
+
const filter = where(query, now)
|
|
122
|
+
|
|
123
|
+
const [[counted], connectionRows] = await Promise.all([
|
|
124
|
+
db.unsafe(
|
|
125
|
+
`SELECT COUNT(*) AS total_count,
|
|
126
|
+
SUM(CASE WHEN ${type} = 'SELECT' THEN 1 ELSE 0 END) AS read_count,
|
|
127
|
+
SUM(CASE WHEN ${type} IN ('INSERT', 'UPDATE', 'DELETE') THEN 1 ELSE 0 END) AS write_count,
|
|
128
|
+
SUM(CASE WHEN status = 'slow' THEN 1 ELSE 0 END) AS slow_count,
|
|
129
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
|
|
130
|
+
AVG(duration) AS average_duration
|
|
131
|
+
FROM query_logs ${filter.all.sql}`,
|
|
132
|
+
filter.all.values,
|
|
133
|
+
).execute(),
|
|
134
|
+
db.unsafe(`SELECT DISTINCT connection FROM query_logs ${filter.range.sql} ORDER BY connection`, filter.range.values).execute() as Promise<Array<{ connection: unknown }>>,
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
const counts: QueryCounts = {
|
|
138
|
+
total: countOf(counted?.total_count),
|
|
139
|
+
reads: countOf(counted?.read_count),
|
|
140
|
+
writes: countOf(counted?.write_count),
|
|
141
|
+
slow: countOf(counted?.slow_count),
|
|
142
|
+
failed: countOf(counted?.failed_count),
|
|
143
|
+
averageDuration: countOf(counted?.average_duration),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const page = pageWithin(query.page, counts.total, query.perPage)
|
|
147
|
+
const rows = counts.total === 0
|
|
148
|
+
? []
|
|
149
|
+
: await db.unsafe(
|
|
150
|
+
`SELECT ${dashboardQueryColumns.join(', ')} FROM query_logs ${filter.all.sql}
|
|
151
|
+
ORDER BY executed_at DESC, id DESC LIMIT ${Math.trunc(query.perPage)} OFFSET ${Math.trunc((page - 1) * query.perPage)}`,
|
|
152
|
+
filter.all.values,
|
|
153
|
+
).execute()
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
rows: rows.map(row => mapDashboardQueryLog(row)),
|
|
157
|
+
page,
|
|
158
|
+
counts,
|
|
159
|
+
connections: connectionRows.map(row => String(row.connection ?? 'unknown')),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions/runtime'
|
|
2
2
|
import { FailedJob, Job } from '@stacksjs/orm'
|
|
3
|
-
import { getGlobalMetrics
|
|
3
|
+
import { getGlobalMetrics } from '@stacksjs/queue'
|
|
4
4
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
-
|
|
6
|
-
interface QueueBucket {
|
|
7
|
-
pending: number
|
|
8
|
-
active: number
|
|
9
|
-
completed: number
|
|
10
|
-
failed: number
|
|
11
|
-
total: number
|
|
12
|
-
}
|
|
5
|
+
import { readQueueCounts } from './queue-stats'
|
|
13
6
|
|
|
14
7
|
// A queued job has no `status` column - `queuedJobState` in @stacksjs/queue
|
|
15
8
|
// holds the rule (reserved_at means a worker has it; available_at in the
|
|
@@ -23,54 +16,23 @@ export default new Action({
|
|
|
23
16
|
method: 'GET',
|
|
24
17
|
async handle() {
|
|
25
18
|
try {
|
|
26
|
-
const [totalJobs, failedJobCount,
|
|
19
|
+
const [totalJobs, failedJobCount, counts] = await Promise.all([
|
|
27
20
|
Job.count(),
|
|
28
21
|
FailedJob.count(),
|
|
29
|
-
|
|
30
|
-
FailedJob.all(),
|
|
22
|
+
readQueueCounts(),
|
|
31
23
|
])
|
|
32
24
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
failedByQueue.set(queueName, (failedByQueue.get(queueName) ?? 0) + 1)
|
|
42
|
-
}
|
|
43
|
-
let active = 0
|
|
44
|
-
const completed = 0
|
|
45
|
-
|
|
46
|
-
for (const j of allJobs) {
|
|
47
|
-
const queueName = String(j.get('queue') || 'default')
|
|
48
|
-
const status = queuedJobState({ reserved_at: j.get('reserved_at'), available_at: j.get('available_at') })
|
|
49
|
-
|
|
50
|
-
if (!queueMap[queueName]) {
|
|
51
|
-
queueMap[queueName] = { pending: 0, active: 0, completed: 0, failed: 0, total: 0 }
|
|
52
|
-
}
|
|
53
|
-
const bucket = queueMap[queueName]
|
|
54
|
-
bucket.total++
|
|
55
|
-
|
|
56
|
-
if (status === 'processing') {
|
|
57
|
-
bucket.active++
|
|
58
|
-
active++
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
bucket.pending++
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const queues = Object.entries(queueMap).map(([name, data]) => ({
|
|
66
|
-
name,
|
|
67
|
-
status: data.active > 0 ? 'active' : 'idle',
|
|
68
|
-
pending: data.pending,
|
|
69
|
-
active: data.active,
|
|
70
|
-
completed: data.completed,
|
|
71
|
-
failed: failedByQueue.get(name) ?? data.failed,
|
|
72
|
-
total: data.total,
|
|
25
|
+
const queues = counts.map(queue => ({
|
|
26
|
+
name: queue.name,
|
|
27
|
+
status: queue.active > 0 ? 'active' : 'idle',
|
|
28
|
+
pending: queue.pending,
|
|
29
|
+
active: queue.active,
|
|
30
|
+
completed: 0,
|
|
31
|
+
failed: queue.failed,
|
|
32
|
+
total: queue.total,
|
|
73
33
|
}))
|
|
34
|
+
const active = counts.reduce((sum, queue) => sum + queue.active, 0)
|
|
35
|
+
const completed = 0
|
|
74
36
|
|
|
75
37
|
// The in-memory metrics tracker only sees jobs processed since the
|
|
76
38
|
// current API process started, so it complements (not replaces) the DB
|