@stacksjs/defaults 0.75.482 → 0.75.484
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
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
|
|
48
48
|
<ErrorTable
|
|
49
49
|
:errors="filteredErrors"
|
|
50
|
-
:
|
|
50
|
+
:total="groupTotal"
|
|
51
|
+
:currentPage="groupPage"
|
|
52
|
+
:perPage="groupPerPage"
|
|
51
53
|
:emptyTitle="emptyTitle"
|
|
52
54
|
:emptyDescription="emptyDescription"
|
|
53
55
|
@row-click="viewError"
|
|
@@ -55,6 +57,9 @@
|
|
|
55
57
|
@ignore="handleIgnoreError"
|
|
56
58
|
@unresolve="handleUnresolveError"
|
|
57
59
|
@delete="openDeleteModal"
|
|
60
|
+
@page-change="changeGroupPage"
|
|
61
|
+
@page-size-change="changeGroupPageSize"
|
|
62
|
+
@sort="changeGroupSort"
|
|
58
63
|
/>
|
|
59
64
|
</Card>
|
|
60
65
|
|
|
@@ -148,6 +153,7 @@
|
|
|
148
153
|
|
|
149
154
|
<script client>
|
|
150
155
|
import { useErrors } from '../../../../functions/monitoring/errors'
|
|
156
|
+
import { pushToast } from '../../../../functions/toasts'
|
|
151
157
|
|
|
152
158
|
interface ErrorGroup {
|
|
153
159
|
id?: string | number
|
|
@@ -160,12 +166,21 @@ interface ErrorGroup {
|
|
|
160
166
|
stack?: string | null
|
|
161
167
|
}
|
|
162
168
|
|
|
163
|
-
const { groupedErrors, errorStats, fetchGroupedErrors, fetchErrorStats, resolveErrorGroup, ignoreErrorGroup, unresolveErrorGroup, deleteErrorGroup } = useErrors()
|
|
169
|
+
const { groupedErrors, groupedErrorsPage, errorStats, fetchGroupedErrors, fetchErrorStats, resolveErrorGroup, ignoreErrorGroup, unresolveErrorGroup, deleteErrorGroup } = useErrors()
|
|
164
170
|
|
|
165
171
|
const searchQuery = state('')
|
|
166
172
|
const statusFilter = state('')
|
|
167
173
|
const typeFilter = state('')
|
|
168
174
|
|
|
175
|
+
// The groups are one page, searched, filtered and sorted by the server
|
|
176
|
+
// (stacksjs/stacks#3195); these are what it was last asked for.
|
|
177
|
+
const listPage = state(1)
|
|
178
|
+
const perPage = state(10)
|
|
179
|
+
const appliedSearch = state('')
|
|
180
|
+
const sortKey = state('')
|
|
181
|
+
const sortDirection = state<'asc' | 'desc'>('desc')
|
|
182
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
183
|
+
|
|
169
184
|
const selectedError = state<ErrorGroup | null>(null)
|
|
170
185
|
const isDetailModalOpen = state(false)
|
|
171
186
|
const showDeleteModal = state(false)
|
|
@@ -193,34 +208,82 @@ const last24hLabel = derived(() => String(statsValue()?.last_24h ?? 0))
|
|
|
193
208
|
const resolvedStatsLabel = derived(() => String(statsValue()?.resolved ?? 0))
|
|
194
209
|
const trendValue = derived(() => statsValue()?.trend ?? 0)
|
|
195
210
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
211
|
+
function pageInfo(): { total: number, page: number, types: string[] } {
|
|
212
|
+
return (groupedErrorsPage.value as { total: number, page: number, types: string[] } | null) ?? { total: 0, page: 1, types: [] }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Every recorded type, from the server: a page alone would list only its own. */
|
|
216
|
+
const typeOptions = derived(() => [
|
|
217
|
+
{ value: '', label: 'All Types' },
|
|
218
|
+
...pageInfo().types.map(type => ({ value: type, label: type })),
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
/** The page the server chose; its search, filters and order are already applied. */
|
|
222
|
+
const filteredErrors = derived(() => errorList())
|
|
223
|
+
const groupTotal = derived(() => pageInfo().total)
|
|
224
|
+
const groupPage = derived(() => pageInfo().page)
|
|
225
|
+
const groupPerPage = derived(() => perPage())
|
|
226
|
+
|
|
227
|
+
async function loadGroups(): Promise<void> {
|
|
228
|
+
await fetchGroupedErrors({
|
|
229
|
+
page: listPage(),
|
|
230
|
+
perPage: perPage(),
|
|
231
|
+
search: appliedSearch(),
|
|
232
|
+
status: statusFilter(),
|
|
233
|
+
type: typeFilter(),
|
|
234
|
+
sort: sortKey() || undefined,
|
|
235
|
+
direction: sortDirection(),
|
|
236
|
+
})
|
|
237
|
+
// The server moves back to the last page when a delete or a narrower
|
|
238
|
+
// filter leaves this one past the end.
|
|
239
|
+
listPage.set(pageInfo().page)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function changeGroupPage(value: number | CustomEvent<number>): void {
|
|
243
|
+
listPage.set(Number(eventValue(value)) || 1)
|
|
244
|
+
void loadGroups()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function changeGroupPageSize(value: number | CustomEvent<number>): void {
|
|
248
|
+
perPage.set(Number(eventValue(value)) || 10)
|
|
249
|
+
listPage.set(1)
|
|
250
|
+
void loadGroups()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function changeGroupSort(value: { key: string, direction: string } | CustomEvent<{ key: string, direction: string }>): void {
|
|
254
|
+
const sort = eventValue(value)
|
|
255
|
+
sortKey.set(sort.key || '')
|
|
256
|
+
sortDirection.set(sort.direction === 'asc' ? 'asc' : 'desc')
|
|
257
|
+
listPage.set(1)
|
|
258
|
+
void loadGroups()
|
|
259
|
+
}
|
|
203
260
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.sort((a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime())
|
|
261
|
+
// The search and the two filters are model bindings; a change to any asks
|
|
262
|
+
// the server for page 1, the search once typing pauses.
|
|
263
|
+
let shownSearch = searchQuery()
|
|
264
|
+
effect(() => {
|
|
265
|
+
const term = searchQuery().trim()
|
|
266
|
+
if (term === shownSearch)
|
|
267
|
+
return
|
|
268
|
+
shownSearch = term
|
|
269
|
+
if (searchTimer)
|
|
270
|
+
clearTimeout(searchTimer)
|
|
271
|
+
searchTimer = setTimeout(() => {
|
|
272
|
+
appliedSearch.set(term)
|
|
273
|
+
listPage.set(1)
|
|
274
|
+
void loadGroups()
|
|
275
|
+
}, 250)
|
|
220
276
|
})
|
|
221
277
|
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
let shownFilters = JSON.stringify([statusFilter(), typeFilter()])
|
|
279
|
+
effect(() => {
|
|
280
|
+
const filters = JSON.stringify([statusFilter(), typeFilter()])
|
|
281
|
+
if (filters === shownFilters)
|
|
282
|
+
return
|
|
283
|
+
shownFilters = filters
|
|
284
|
+
listPage.set(1)
|
|
285
|
+
void loadGroups()
|
|
286
|
+
})
|
|
224
287
|
|
|
225
288
|
|
|
226
289
|
const hasFilters = derived(() => searchQuery() !== '' || (statusFilter() ?? '') !== '' || (typeFilter() ?? '') !== '')
|
|
@@ -263,24 +326,42 @@ function closeDetailModal(): void {
|
|
|
263
326
|
|
|
264
327
|
async function handleResolveError(value: ErrorGroup | CustomEvent<ErrorGroup>): Promise<void> {
|
|
265
328
|
const error = eventValue(value)
|
|
266
|
-
|
|
267
|
-
|
|
329
|
+
// Only a write that landed moves the group: this used to mark it resolved
|
|
330
|
+
// whatever the server answered (stacksjs/stacks#3194).
|
|
331
|
+
if (!await resolveErrorGroup(error.type, error.message)) {
|
|
332
|
+
pushToast('error', 'Could not resolve this error')
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
// Reread the groups too: the table does not see a status changed in place.
|
|
336
|
+
await Promise.all([loadGroups(), fetchErrorStats()])
|
|
268
337
|
if (isDetailModalOpen() && selectedError())
|
|
269
338
|
selectedError.update(e => e ? { ...e, status: 'resolved' } : e)
|
|
270
339
|
}
|
|
271
340
|
|
|
272
341
|
async function handleIgnoreError(value: ErrorGroup | CustomEvent<ErrorGroup>): Promise<void> {
|
|
273
342
|
const error = eventValue(value)
|
|
274
|
-
|
|
275
|
-
|
|
343
|
+
// Only a write that landed moves the group: this used to mark it ignored
|
|
344
|
+
// whatever the server answered (stacksjs/stacks#3194).
|
|
345
|
+
if (!await ignoreErrorGroup(error.type, error.message)) {
|
|
346
|
+
pushToast('error', 'Could not ignore this error')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
// Reread the groups too: the table does not see a status changed in place.
|
|
350
|
+
await Promise.all([loadGroups(), fetchErrorStats()])
|
|
276
351
|
if (isDetailModalOpen() && selectedError())
|
|
277
352
|
selectedError.update(e => e ? { ...e, status: 'ignored' } : e)
|
|
278
353
|
}
|
|
279
354
|
|
|
280
355
|
async function handleUnresolveError(value: ErrorGroup | CustomEvent<ErrorGroup>): Promise<void> {
|
|
281
356
|
const error = eventValue(value)
|
|
282
|
-
|
|
283
|
-
|
|
357
|
+
// Only a write that landed moves the group: this used to mark it unresolved
|
|
358
|
+
// whatever the server answered (stacksjs/stacks#3194).
|
|
359
|
+
if (!await unresolveErrorGroup(error.type, error.message)) {
|
|
360
|
+
pushToast('error', 'Could not reopen this error')
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
// Reread the groups too: the table does not see a status changed in place.
|
|
364
|
+
await Promise.all([loadGroups(), fetchErrorStats()])
|
|
284
365
|
if (isDetailModalOpen() && selectedError())
|
|
285
366
|
selectedError.update(e => e ? { ...e, status: 'unresolved' } : e)
|
|
286
367
|
}
|
|
@@ -326,8 +407,12 @@ async function confirmDelete(): Promise<void> {
|
|
|
326
407
|
const target = errorToDelete()
|
|
327
408
|
if (!target)
|
|
328
409
|
return
|
|
329
|
-
await deleteErrorGroup(target.type, target.message)
|
|
330
|
-
|
|
410
|
+
if (!await deleteErrorGroup(target.type, target.message)) {
|
|
411
|
+
pushToast('error', 'Could not delete this error')
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
// Reread the groups too: the table does not see a status changed in place.
|
|
415
|
+
await Promise.all([loadGroups(), fetchErrorStats()])
|
|
331
416
|
closeDeleteModal()
|
|
332
417
|
}
|
|
333
418
|
|
|
@@ -347,6 +432,6 @@ function formatDate(date: string): string {
|
|
|
347
432
|
}
|
|
348
433
|
|
|
349
434
|
onMount(async () => {
|
|
350
|
-
await Promise.all([
|
|
435
|
+
await Promise.all([loadGroups(), fetchErrorStats()])
|
|
351
436
|
})
|
|
352
437
|
</script>
|
|
@@ -15,9 +15,13 @@ interface ErrorGroup {
|
|
|
15
15
|
const errors = useReactiveProp('errors', [])
|
|
16
16
|
const emptyTitle = useReactiveProp('emptyTitle', 'No errors found')
|
|
17
17
|
const emptyDescription = useReactiveProp('emptyDescription', 'Your application is running smoothly!')
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// The rows are one page the server chose (stacksjs/stacks#3195): it searches,
|
|
19
|
+
// filters, sorts and pages the groups, so this shows them as given and hands
|
|
20
|
+
// paging and sorting back to the parent.
|
|
21
|
+
const total = useReactiveProp('total', 0)
|
|
22
|
+
const currentPage = useReactiveProp('currentPage', 1)
|
|
23
|
+
const perPage = useReactiveProp('perPage', 10)
|
|
24
|
+
const emit = defineEmits(['view', 'resolve', 'ignore', 'unresolve', 'delete', 'row-click', 'page-change', 'page-size-change', 'sort'])
|
|
21
25
|
|
|
22
26
|
function formatRelativeTime(value: string): string {
|
|
23
27
|
if (!value)
|
|
@@ -39,9 +43,8 @@ function formatRelativeTime(value: string): string {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
|
-
* Last seen reads relative to now
|
|
43
|
-
*
|
|
44
|
-
* recently seen first, is what a header sort falls back to.
|
|
46
|
+
* Last seen reads relative to now, so it is slotted rather than a datetime
|
|
47
|
+
* column. Every sortable key here is one the server sorts by.
|
|
45
48
|
*/
|
|
46
49
|
const COLUMNS = [
|
|
47
50
|
{ key: 'type', label: 'Type', type: 'badge' as const, sortable: true },
|
|
@@ -84,20 +87,38 @@ const rows = derived(() => (errors() as ErrorGroup[]).map((error, index) => ({
|
|
|
84
87
|
function handleRowAction(payload: { action: string, row: ErrorGroup }): void {
|
|
85
88
|
emit(payload.action === 'view' ? 'row-click' : payload.action, payload.row)
|
|
86
89
|
}
|
|
90
|
+
|
|
91
|
+
function changePage(page: number): void {
|
|
92
|
+
emit('page-change', page)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function changePageSize(size: number): void {
|
|
96
|
+
emit('page-size-change', size)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function changeSort(sort: { key: string, direction: string }): void {
|
|
100
|
+
emit('sort', sort)
|
|
101
|
+
}
|
|
87
102
|
</script>
|
|
88
103
|
|
|
89
104
|
<Table
|
|
90
|
-
:page-reset-key="pageResetKey()"
|
|
91
105
|
:columns="COLUMNS"
|
|
92
106
|
:data="rows()"
|
|
93
107
|
:row-actions="ROW_ACTIONS"
|
|
94
108
|
action-label-key="actionName"
|
|
95
109
|
row-key="rowId"
|
|
96
110
|
paginated
|
|
97
|
-
|
|
111
|
+
manual-pagination
|
|
112
|
+
manual-sort
|
|
113
|
+
:total="total()"
|
|
114
|
+
:current-page="currentPage()"
|
|
115
|
+
:page-size="perPage()"
|
|
98
116
|
:empty-title="emptyTitle()"
|
|
99
117
|
:empty-description="emptyDescription()"
|
|
100
118
|
@row-action="handleRowAction"
|
|
119
|
+
@page-change="changePage"
|
|
120
|
+
@page-size-change="changePageSize"
|
|
121
|
+
@sort="changeSort"
|
|
101
122
|
>
|
|
102
123
|
<template #cell="{ row }">
|
|
103
124
|
<span class="text-neutral-500 text-sm whitespace-nowrap dark:text-neutral-400" x-text="row.lastSeenLabel"></span>
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import type { DashboardQueryLog, DashboardQueryResponse } from '../../../../functions/queries'
|
|
2
|
+
import type { DashboardQueryCounts, DashboardQueryFilters, DashboardQueryLog, DashboardQueryResponse } from '../../../../functions/queries'
|
|
3
3
|
import { fetchDashboardQueries } from '../../../../functions/queries'
|
|
4
4
|
|
|
5
|
-
type QueryRange = '
|
|
5
|
+
type QueryRange = DashboardQueryFilters['range']
|
|
6
6
|
type QueryMode = 'all' | 'slow' | 'history'
|
|
7
|
-
type QueryType =
|
|
8
|
-
type QueryStatus =
|
|
7
|
+
type QueryType = DashboardQueryFilters['type']
|
|
8
|
+
type QueryStatus = DashboardQueryFilters['status']
|
|
9
|
+
|
|
10
|
+
const EMPTY_COUNTS: DashboardQueryCounts = { total: 0, reads: 0, writes: 0, slow: 0, failed: 0, averageDuration: 0 }
|
|
9
11
|
|
|
10
12
|
const mode = useReactiveProp<QueryMode>('mode', 'all')
|
|
13
|
+
// One page of the log, chosen by the server with the counts the cards show
|
|
14
|
+
// (stacksjs/stacks#3204): the range, filters and paging are all its work.
|
|
11
15
|
const data = state<DashboardQueryResponse>({
|
|
12
16
|
enabled: false,
|
|
13
17
|
slowThreshold: 100,
|
|
14
18
|
queries: [],
|
|
19
|
+
total: 0,
|
|
20
|
+
page: 1,
|
|
21
|
+
perPage: 50,
|
|
22
|
+
counts: EMPTY_COUNTS,
|
|
23
|
+
connections: [],
|
|
15
24
|
})
|
|
16
25
|
const range = state<QueryRange>(mode() === 'history' ? 'week' : 'day')
|
|
17
26
|
const connection = state('all')
|
|
@@ -21,35 +30,12 @@ const currentPage = state(1)
|
|
|
21
30
|
const itemsPerPage = state(50)
|
|
22
31
|
const loading = state(true)
|
|
23
32
|
const loadError = state('')
|
|
33
|
+
let listRequestId = 0
|
|
24
34
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const rangedQueries = derived(() => {
|
|
32
|
-
const cutoff = Date.now() - rangeHours[range()] * 60 * 60 * 1000
|
|
33
|
-
return data().queries.filter((query) => {
|
|
34
|
-
const executed = new Date(query.executedAt).getTime()
|
|
35
|
-
return Number.isFinite(executed) && executed >= cutoff
|
|
36
|
-
})
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
const filteredQueries = derived(() => rangedQueries().filter((query) => {
|
|
40
|
-
if (mode() === 'slow' && query.status !== 'slow')
|
|
41
|
-
return false
|
|
42
|
-
if (connection() !== 'all' && query.connection !== connection())
|
|
43
|
-
return false
|
|
44
|
-
if (status() !== 'all' && query.status !== status())
|
|
45
|
-
return false
|
|
46
|
-
return type() === 'all' || query.type === type()
|
|
47
|
-
}))
|
|
48
|
-
const totalPages = derived(() => Math.max(1, Math.ceil(filteredQueries().length / itemsPerPage())))
|
|
49
|
-
const visibleQueries = derived(() => {
|
|
50
|
-
const start = (currentPage() - 1) * itemsPerPage()
|
|
51
|
-
return filteredQueries().slice(start, start + itemsPerPage())
|
|
52
|
-
})
|
|
35
|
+
const queries = derived(() => data().queries)
|
|
36
|
+
const counts = derived(() => data().counts)
|
|
37
|
+
const total = derived(() => counts().total)
|
|
38
|
+
const totalPages = derived(() => Math.max(1, Math.ceil(total() / itemsPerPage())))
|
|
53
39
|
|
|
54
40
|
const pageTitle = derived(() => mode() === 'slow' ? 'Slow Queries' : mode() === 'history' ? 'Query History' : 'Queries')
|
|
55
41
|
const pageDescription = derived(() => mode() === 'slow'
|
|
@@ -58,30 +44,28 @@ const pageDescription = derived(() => mode() === 'slow'
|
|
|
58
44
|
? 'Review database work recorded by the native query logger.'
|
|
59
45
|
: 'Inspect query volume and latency recorded by the native database logger.')
|
|
60
46
|
const emptyTitle = derived(() => mode() === 'slow' ? 'No slow queries in this range' : 'No query logs in this range')
|
|
61
|
-
const connections = derived(() =>
|
|
62
|
-
const
|
|
63
|
-
const selectCount = derived(() => filteredQueries().filter(query => query.type === 'SELECT').length)
|
|
64
|
-
const writeCount = derived(() => filteredQueries().filter(query => ['INSERT', 'UPDATE', 'DELETE'].includes(query.type)).length)
|
|
65
|
-
const slowCount = derived(() => filteredQueries().filter(query => query.status === 'slow').length)
|
|
66
|
-
const failedCount = derived(() => filteredQueries().filter(query => query.status === 'failed').length)
|
|
67
|
-
const averageDuration = derived(() => {
|
|
68
|
-
const rows = filteredQueries()
|
|
69
|
-
if (rows.length === 0)
|
|
70
|
-
return '0.00 ms'
|
|
71
|
-
return `${(rows.reduce((sum, query) => sum + query.duration, 0) / rows.length).toFixed(2)} ms`
|
|
72
|
-
})
|
|
47
|
+
const connections = derived(() => data().connections)
|
|
48
|
+
const averageDuration = derived(() => `${counts().averageDuration.toFixed(2)} ms`)
|
|
73
49
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
50
|
+
function filters(): DashboardQueryFilters {
|
|
51
|
+
return {
|
|
52
|
+
range: range(),
|
|
53
|
+
// The slow page lists slow queries whatever the status select last held.
|
|
54
|
+
status: mode() === 'slow' ? 'slow' : status(),
|
|
55
|
+
type: type(),
|
|
56
|
+
connection: connection() === 'all' ? '' : connection(),
|
|
57
|
+
page: currentPage(),
|
|
58
|
+
perPage: itemsPerPage(),
|
|
59
|
+
}
|
|
77
60
|
}
|
|
78
61
|
|
|
79
|
-
function
|
|
80
|
-
|
|
62
|
+
function setRange(value: QueryRange): void {
|
|
63
|
+
range.set(value)
|
|
81
64
|
}
|
|
82
65
|
|
|
83
66
|
function setCurrentPage(page: number): void {
|
|
84
67
|
currentPage.set(Math.min(Math.max(1, page), totalPages()))
|
|
68
|
+
void loadQueries()
|
|
85
69
|
}
|
|
86
70
|
|
|
87
71
|
function formatQuery(query: string): string {
|
|
@@ -121,21 +105,46 @@ function statusClass(value: DashboardQueryLog['status']): string {
|
|
|
121
105
|
}
|
|
122
106
|
|
|
123
107
|
async function loadQueries(): Promise<void> {
|
|
108
|
+
// Only the newest request may land: the range, filters and pager can each
|
|
109
|
+
// start one while another is still out.
|
|
110
|
+
const requestId = ++listRequestId
|
|
124
111
|
loading.set(true)
|
|
125
112
|
loadError.set('')
|
|
126
113
|
try {
|
|
127
|
-
const result = await fetchDashboardQueries()
|
|
128
|
-
|
|
114
|
+
const result = await fetchDashboardQueries(filters())
|
|
115
|
+
if (requestId !== listRequestId)
|
|
116
|
+
return
|
|
117
|
+
data.set({ ...result, counts: result.counts ?? EMPTY_COUNTS, connections: result.connections ?? [] })
|
|
118
|
+
// The server moves back to the last page when this one ran past the end.
|
|
119
|
+
currentPage.set(Number(result.page || 1))
|
|
129
120
|
loadError.set(result.error || '')
|
|
130
121
|
}
|
|
131
122
|
catch (error) {
|
|
123
|
+
if (requestId !== listRequestId)
|
|
124
|
+
return
|
|
132
125
|
loadError.set(error instanceof Error ? error.message : 'Query logs could not be loaded.')
|
|
133
126
|
}
|
|
134
127
|
finally {
|
|
135
|
-
|
|
128
|
+
if (requestId === listRequestId)
|
|
129
|
+
loading.set(false)
|
|
136
130
|
}
|
|
137
131
|
}
|
|
138
132
|
|
|
133
|
+
// The range buttons and the three selects change the query; any change asks
|
|
134
|
+
// the server for page 1. The selects stay plain x-model bindings.
|
|
135
|
+
let shownFilters = ''
|
|
136
|
+
effect(() => {
|
|
137
|
+
const key = [range(), connection(), type(), status()].join('|')
|
|
138
|
+
if (key === shownFilters)
|
|
139
|
+
return
|
|
140
|
+
const first = shownFilters === ''
|
|
141
|
+
shownFilters = key
|
|
142
|
+
if (first)
|
|
143
|
+
return
|
|
144
|
+
currentPage.set(1)
|
|
145
|
+
void loadQueries()
|
|
146
|
+
})
|
|
147
|
+
|
|
139
148
|
onMount(() => {
|
|
140
149
|
void loadQueries()
|
|
141
150
|
})
|
|
@@ -165,16 +174,16 @@ onMount(() => {
|
|
|
165
174
|
<template #value>{{ total() }}</template>
|
|
166
175
|
</StatsCard>
|
|
167
176
|
<StatsCard title="Reads" icon="i-hugeicons-search-01" iconBg="info">
|
|
168
|
-
<template #value>{{
|
|
177
|
+
<template #value>{{ counts().reads }}</template>
|
|
169
178
|
</StatsCard>
|
|
170
179
|
<StatsCard title="Writes" icon="i-hugeicons-pencil-edit-02" iconBg="success">
|
|
171
|
-
<template #value>{{
|
|
180
|
+
<template #value>{{ counts().writes }}</template>
|
|
172
181
|
</StatsCard>
|
|
173
182
|
<StatsCard title="Slow" icon="i-hugeicons-time-02" iconBg="warning">
|
|
174
|
-
<template #value>{{
|
|
183
|
+
<template #value>{{ counts().slow }}</template>
|
|
175
184
|
</StatsCard>
|
|
176
185
|
<StatsCard title="Failed" icon="i-hugeicons-alert-02" iconBg="danger">
|
|
177
|
-
<template #value>{{
|
|
186
|
+
<template #value>{{ counts().failed }}</template>
|
|
178
187
|
</StatsCard>
|
|
179
188
|
<StatsCard title="Average" icon="i-hugeicons-dashboard-speed-01" iconBg="neutral">
|
|
180
189
|
<template #value>{{ averageDuration() }}</template>
|
|
@@ -190,14 +199,14 @@ onMount(() => {
|
|
|
190
199
|
</div>
|
|
191
200
|
<div class="flex gap-2 items-center">
|
|
192
201
|
<label for="query-connection" class="sr-only">Connection</label>
|
|
193
|
-
<select id="query-connection" x-model="connection" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700"
|
|
202
|
+
<select id="query-connection" x-model="connection" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700">
|
|
194
203
|
<option value="all">All connections</option>
|
|
195
204
|
<template :for="item in connections()">
|
|
196
205
|
<option :value="item">{{ item }}</option>
|
|
197
206
|
</template>
|
|
198
207
|
</select>
|
|
199
208
|
<label for="query-type" class="sr-only">Query type</label>
|
|
200
|
-
<select id="query-type" x-model="type" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700"
|
|
209
|
+
<select id="query-type" x-model="type" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700">
|
|
201
210
|
<option value="all">All types</option>
|
|
202
211
|
<option value="SELECT">SELECT</option>
|
|
203
212
|
<option value="INSERT">INSERT</option>
|
|
@@ -206,7 +215,7 @@ onMount(() => {
|
|
|
206
215
|
<option value="OTHER">OTHER</option>
|
|
207
216
|
</select>
|
|
208
217
|
<label :if="mode() !== 'slow'" for="query-status" class="sr-only">Query status</label>
|
|
209
|
-
<select :if="mode() !== 'slow'" id="query-status" x-model="status" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700"
|
|
218
|
+
<select :if="mode() !== 'slow'" id="query-status" x-model="status" class="px-3 py-2 text-neutral-700 text-sm dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 rounded-lg dark:border-neutral-700">
|
|
210
219
|
<option value="all">All statuses</option>
|
|
211
220
|
<option value="completed">Completed</option>
|
|
212
221
|
<option value="slow">Slow</option>
|
|
@@ -218,13 +227,13 @@ onMount(() => {
|
|
|
218
227
|
|
|
219
228
|
<div :if="loading()" aria-live="polite" class="p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">Loading query logs</div>
|
|
220
229
|
<div :if="loadError() && !loading()" role="alert" class="p-10 text-center text-red-600 text-sm dark:text-red-300">{{ loadError() }}</div>
|
|
221
|
-
<div :if="!loading() && !loadError() &&
|
|
230
|
+
<div :if="!loading() && !loadError() && queries().length === 0" class="p-10 text-center">
|
|
222
231
|
<span class="block mb-3 mx-auto h-8 w-8 text-neutral-400 i-hugeicons-database-02"></span>
|
|
223
232
|
<p class="font-medium text-neutral-900 dark:text-white">{{ emptyTitle() }}</p>
|
|
224
233
|
<p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">{{ data().enabled ? (mode() === 'slow' ? 'Queries appear here after they exceed the configured threshold.' : 'Queries will appear as the application executes them.') : 'Enable database.queryLogging in config/database.ts to record queries.' }}</p>
|
|
225
234
|
</div>
|
|
226
235
|
|
|
227
|
-
<div :if="!loading() && !loadError() &&
|
|
236
|
+
<div :if="!loading() && !loadError() && queries().length > 0" class="overflow-x-auto">
|
|
228
237
|
<table class="w-full">
|
|
229
238
|
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
|
230
239
|
<tr>
|
|
@@ -237,7 +246,7 @@ onMount(() => {
|
|
|
237
246
|
</tr>
|
|
238
247
|
</thead>
|
|
239
248
|
<tbody class="divide-neutral-100 divide-y dark:divide-neutral-700">
|
|
240
|
-
<template :for="query in
|
|
249
|
+
<template :for="query in queries">
|
|
241
250
|
<tr>
|
|
242
251
|
<td class="pl-5 pr-4 py-3.5 max-w-2xl">
|
|
243
252
|
<div class="flex gap-2 items-start">
|
|
@@ -262,11 +271,11 @@ onMount(() => {
|
|
|
262
271
|
</table>
|
|
263
272
|
</div>
|
|
264
273
|
|
|
265
|
-
<div :if="!loading() && !loadError() &&
|
|
274
|
+
<div :if="!loading() && !loadError() && queries().length > 0" class="px-5 py-4 border-neutral-200 border-t dark:border-neutral-700">
|
|
266
275
|
<Pagination
|
|
267
276
|
:currentPage="currentPage"
|
|
268
277
|
:totalPages="totalPages"
|
|
269
|
-
:totalItems="
|
|
278
|
+
:totalItems="total()"
|
|
270
279
|
:itemsPerPage="itemsPerPage"
|
|
271
280
|
showTotalItems
|
|
272
281
|
@page="setCurrentPage"
|
|
@@ -195,6 +195,21 @@ const paginated = useReactiveProp('paginated', false)
|
|
|
195
195
|
const pageSize = useReactiveProp('pageSize', 25)
|
|
196
196
|
const pageSizeOptions = useReactiveProp<number[]>('pageSizeOptions', [10, 25, 50, 100])
|
|
197
197
|
const manualSort = useReactiveProp('manualSort', false)
|
|
198
|
+
/**
|
|
199
|
+
* Server-side paging, for a list too large to send whole.
|
|
200
|
+
*
|
|
201
|
+
* With `manual-pagination` the rows are already one page: they are shown as
|
|
202
|
+
* given, `total` is the size of the whole result and `current-page` is the
|
|
203
|
+
* page they are, and the pager only emits `page-change` / `page-size-change`
|
|
204
|
+
* for the parent to fetch. `manual-search` does the same for the search box:
|
|
205
|
+
* it emits `search` and filters nothing, because filtering one page would
|
|
206
|
+
* only find what happens to be on it (stacksjs/stacks#3191). A parent that
|
|
207
|
+
* pages on the server also sorts there, with `manual-sort`.
|
|
208
|
+
*/
|
|
209
|
+
const manualPagination = useReactiveProp('manualPagination', false)
|
|
210
|
+
const manualSearch = useReactiveProp('manualSearch', false)
|
|
211
|
+
const total = useReactiveProp('total', 0)
|
|
212
|
+
const currentPage = useReactiveProp('currentPage', 1)
|
|
198
213
|
const columnToggle = useReactiveProp('columnToggle', false)
|
|
199
214
|
const exportable = useReactiveProp('exportable', false)
|
|
200
215
|
const exportFilename = useReactiveProp('exportFilename', 'table')
|
|
@@ -247,6 +262,11 @@ effect(() => {
|
|
|
247
262
|
pageResetKey()
|
|
248
263
|
page.set(1)
|
|
249
264
|
})
|
|
265
|
+
|
|
266
|
+
effect(() => {
|
|
267
|
+
if (manualPagination())
|
|
268
|
+
page.set(Math.max(1, Number(currentPage()) || 1))
|
|
269
|
+
})
|
|
250
270
|
const columnMenuOpen = state(false)
|
|
251
271
|
const emit = defineEmits(['column-visibility-change', 'page-change', 'page-size-change', 'update:selectedKeys', 'selection-change', 'sort', 'filter-change', 'search', 'export', 'row-action'])
|
|
252
272
|
|
|
@@ -332,7 +352,7 @@ const searchableKeys = derived(() =>
|
|
|
332
352
|
|
|
333
353
|
const searched = derived(() => {
|
|
334
354
|
const term = query().trim().toLowerCase()
|
|
335
|
-
if (!term)
|
|
355
|
+
if (!term || manualSearch())
|
|
336
356
|
return rows()
|
|
337
357
|
|
|
338
358
|
const keys = searchableKeys()
|
|
@@ -434,7 +454,10 @@ const sorted = derived(() => {
|
|
|
434
454
|
|
|
435
455
|
/* ------------------------------------------------------------- pagination */
|
|
436
456
|
|
|
437
|
-
|
|
457
|
+
/** Rows in the whole result: the server's count when it pages, else what is held. */
|
|
458
|
+
const totalRows = derived(() => (manualPagination() ? Math.max(0, Number(total()) || 0) : sorted().length))
|
|
459
|
+
|
|
460
|
+
const pageCount = derived(() => (paginated() ? Math.max(1, Math.ceil(totalRows() / pageSize())) : 1))
|
|
438
461
|
|
|
439
462
|
// A filter that shortens the list must not strand the viewer on a page that
|
|
440
463
|
// no longer exists.
|
|
@@ -444,14 +467,14 @@ effect(() => {
|
|
|
444
467
|
})
|
|
445
468
|
|
|
446
469
|
const visibleRows = derived(() => {
|
|
447
|
-
if (!paginated())
|
|
470
|
+
if (!paginated() || manualPagination())
|
|
448
471
|
return sorted()
|
|
449
472
|
const start = (page() - 1) * pageSize()
|
|
450
473
|
return sorted().slice(start, start + pageSize())
|
|
451
474
|
})
|
|
452
475
|
|
|
453
|
-
const rangeStart = derived(() => (
|
|
454
|
-
const rangeEnd = derived(() => Math.min(page() * pageSize(),
|
|
476
|
+
const rangeStart = derived(() => (totalRows() === 0 ? 0 : (page() - 1) * pageSize() + 1))
|
|
477
|
+
const rangeEnd = derived(() => Math.min(page() * pageSize(), totalRows()))
|
|
455
478
|
|
|
456
479
|
function goToPage(next: number): void {
|
|
457
480
|
const target = Math.min(Math.max(1, next), pageCount())
|
|
@@ -963,11 +986,11 @@ function cellClass(column: DashboardTableColumn, index: number): string {
|
|
|
963
986
|
</div>
|
|
964
987
|
|
|
965
988
|
<div
|
|
966
|
-
:if="paginated() &&
|
|
989
|
+
:if="paginated() && totalRows() > 0"
|
|
967
990
|
class="flex flex-wrap gap-3 items-center justify-between px-4 py-3 bg-neutral-50/50 dark:bg-neutral-800/25 border-neutral-200 border-t dark:border-neutral-700"
|
|
968
991
|
>
|
|
969
992
|
<div class="flex gap-3 items-center text-neutral-500 text-sm dark:text-neutral-400">
|
|
970
|
-
<span>Showing {{ rangeStart() }}-{{ rangeEnd() }} of {{
|
|
993
|
+
<span>Showing {{ rangeStart() }}-{{ rangeEnd() }} of {{ totalRows() }}</span>
|
|
971
994
|
<label class="flex gap-1.5 items-center">
|
|
972
995
|
<span class="sr-only">Rows per page</span>
|
|
973
996
|
<select
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -224,6 +224,7 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
|
|
|
224
224
|
guard(route.get('/content/overview', 'Actions/Dashboard/Content/ContentDashboardAction'))
|
|
225
225
|
guard(route.get('/posts', 'Actions/Dashboard/Content/PostIndexAction'))
|
|
226
226
|
guard(route.post('/posts', 'Actions/Dashboard/Content/PostStoreAction'))
|
|
227
|
+
guard(route.get('/posts/{id}', 'Actions/Dashboard/Content/PostShowAction'))
|
|
227
228
|
guard(route.patch('/posts/{id}', 'Actions/Dashboard/Content/PostUpdateAction'))
|
|
228
229
|
guard(route.delete('/posts/{id}', 'Actions/Dashboard/Content/PostDestroyAction'))
|
|
229
230
|
|