@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.
Files changed (54) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
  4. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
  5. package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
  6. package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
  7. package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
  8. package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
  9. package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
  10. package/app/Actions/Dashboard/Content/content-list.ts +69 -0
  11. package/app/Actions/Dashboard/Content/page-list.ts +108 -0
  12. package/app/Actions/Dashboard/Content/post-list.ts +140 -0
  13. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
  14. package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
  15. package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
  16. package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
  17. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
  18. package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
  19. package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
  20. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
  21. package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
  23. package/app/Actions/Monitoring/error-provider.ts +85 -7
  24. package/functions/api.ts +14 -10
  25. package/functions/commerce/coupons.ts +4 -6
  26. package/functions/commerce/customers.ts +4 -6
  27. package/functions/commerce/gift-cards.ts +4 -2
  28. package/functions/commerce/orders.ts +4 -2
  29. package/functions/commerce/payments.ts +4 -2
  30. package/functions/commerce/products/categories.ts +4 -6
  31. package/functions/commerce/products/manufacturers.ts +4 -6
  32. package/functions/commerce/products/products.ts +4 -6
  33. package/functions/commerce/products/reviews.ts +4 -6
  34. package/functions/commerce/products/units.ts +4 -6
  35. package/functions/commerce/products/variants.ts +4 -6
  36. package/functions/commerce/taxes.ts +4 -2
  37. package/functions/commerce/waitlist/products.ts +4 -6
  38. package/functions/commerce/waitlist/restaurants.ts +4 -6
  39. package/functions/monitoring/errors.ts +77 -49
  40. package/functions/queries.ts +34 -2
  41. package/ide/vscode/package.json +1 -1
  42. package/package.json +2 -2
  43. package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
  44. package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
  45. package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
  46. package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
  47. package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
  48. package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
  49. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
  50. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
  51. package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
  52. package/resources/components/Dashboard/UI/Table.stx +30 -7
  53. package/routes/dashboard-api.ts +1 -0
  54. package/app/Actions/Buddy/JobsListAction.ts +0 -141
@@ -47,7 +47,9 @@
47
47
 
48
48
  <ErrorTable
49
49
  :errors="filteredErrors"
50
- :pageResetKey="viewKey"
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
- const typeOptions = derived(() => {
197
- const types = new Set(errorList().map(e => e.type))
198
- return [
199
- { value: '', label: 'All Types' },
200
- ...Array.from(types).map(type => ({ value: type, label: type })),
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
- const filteredErrors = derived(() => {
205
- const list = errorList()
206
- const q = searchQuery().toLowerCase()
207
- const status = statusFilter()
208
- const type = typeFilter()
209
- return list
210
- .filter((error) => {
211
- const matchesSearch = !q
212
- || error.type.toLowerCase().includes(q)
213
- || error.message.toLowerCase().includes(q)
214
- const matchesStatus = !status || error.status === status
215
- const matchesType = !type || error.type === type
216
- return matchesSearch && matchesStatus && matchesType
217
- })
218
- // Most recently seen first: the order a header sort in the table falls back to.
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
- /** What the table is showing: a change to the search or a filter returns it to the first page. */
223
- const viewKey = derived(() => JSON.stringify([searchQuery(), statusFilter(), typeFilter()]))
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
- await resolveErrorGroup(error.type, error.message)
267
- await fetchErrorStats()
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
- await ignoreErrorGroup(error.type, error.message)
275
- await fetchErrorStats()
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
- await unresolveErrorGroup(error.type, error.message)
283
- await fetchErrorStats()
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
- await fetchErrorStats()
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([fetchGroupedErrors(), fetchErrorStats()])
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
- /** The parent's search and filters: a change returns to the first page. */
19
- const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
20
- const emit = defineEmits(['view', 'resolve', 'ignore', 'unresolve', 'delete'])
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 and sorts by the timestamp behind it, so
43
- * it is slotted rather than a datetime column. The parent's order, most
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
- :page-size="10"
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 = 'hour' | 'day' | 'week'
5
+ type QueryRange = DashboardQueryFilters['range']
6
6
  type QueryMode = 'all' | 'slow' | 'history'
7
- type QueryType = DashboardQueryLog['type'] | 'all'
8
- type QueryStatus = DashboardQueryLog['status'] | 'all'
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 rangeHours: Record<QueryRange, number> = {
26
- hour: 1,
27
- day: 24,
28
- week: 168,
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(() => Array.from(new Set(data().queries.map(query => query.connection))).sort())
62
- const total = derived(() => filteredQueries().length)
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 setRange(value: QueryRange): void {
75
- range.set(value)
76
- currentPage.set(1)
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 resetPage(): void {
80
- currentPage.set(1)
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
- data.set(result)
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
- loading.set(false)
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>{{ selectCount() }}</template>
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>{{ writeCount() }}</template>
180
+ <template #value>{{ counts().writes }}</template>
172
181
  </StatsCard>
173
182
  <StatsCard title="Slow" icon="i-hugeicons-time-02" iconBg="warning">
174
- <template #value>{{ slowCount() }}</template>
183
+ <template #value>{{ counts().slow }}</template>
175
184
  </StatsCard>
176
185
  <StatsCard title="Failed" icon="i-hugeicons-alert-02" iconBg="danger">
177
- <template #value>{{ failedCount() }}</template>
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" @change="resetPage">
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" @change="resetPage">
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" @change="resetPage">
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() && filteredQueries().length === 0" class="p-10 text-center">
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() && filteredQueries().length > 0" class="overflow-x-auto">
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 visibleQueries()">
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() && filteredQueries().length > 0" class="px-5 py-4 border-neutral-200 border-t dark:border-neutral-700">
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="filteredQueries().length"
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
- const pageCount = derived(() => (paginated() ? Math.max(1, Math.ceil(sorted().length / pageSize())) : 1))
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(() => (sorted().length === 0 ? 0 : (page() - 1) * pageSize() + 1))
454
- const rangeEnd = derived(() => Math.min(page() * pageSize(), sorted().length))
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() && sorted().length > 0"
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 {{ sorted().length }}</span>
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
@@ -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