@stacksjs/defaults 0.70.365 → 0.70.367

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 (90) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +71 -14
  3. package/ai/skills/stacks-deploy/SKILL.md +18 -0
  4. package/ai/skills/stacks-desktop/SKILL.md +11 -2
  5. package/ai/skills/stacks-stx/SKILL.md +25 -0
  6. package/app/Actions/Auth/LoginAction.ts +11 -2
  7. package/app/Actions/Auth/RefreshTokenAction.ts +6 -2
  8. package/app/Actions/Auth/RegisterAction.ts +6 -2
  9. package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +6 -2
  10. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  11. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  12. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  13. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  15. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  16. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  17. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  18. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  19. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  20. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  21. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  22. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  23. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  24. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  25. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  26. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  27. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  33. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  34. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  35. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  36. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  37. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  38. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  39. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  40. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  41. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  42. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  43. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  44. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  45. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  46. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  47. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  48. package/functions/deployments.ts +18 -0
  49. package/functions/operations.ts +361 -0
  50. package/ide/vscode/package.json +1 -1
  51. package/package.json +1 -1
  52. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +0 -2
  53. package/resources/components/Dashboard/Auth/LoginDashboard.stx +1 -1
  54. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +1 -3
  55. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  56. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  57. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  58. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  59. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +9 -435
  60. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +416 -0
  61. package/resources/components/Dashboard/Modals/BaseModal.stx +13 -36
  62. package/resources/components/Dashboard/Modals/Popups/Alert.stx +18 -44
  63. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  64. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  65. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  66. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  67. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  68. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  69. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  70. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  71. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  72. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  73. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  74. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  75. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  76. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  77. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  78. package/resources/components/Dashboard/UI/Modal.stx +43 -14
  79. package/resources/components/Dashboard/UI/Table.stx +30 -25
  80. package/resources/functions/dashboard/sidebar.ts +1 -0
  81. package/routes/dashboard-api.ts +23 -0
  82. package/routes/dashboard.ts +1 -0
  83. package/views/dashboard/AUDIT.md +21 -16
  84. package/views/dashboard/operations/audit.stx +5 -0
  85. package/views/dashboard/operations/changes.stx +5 -0
  86. package/views/dashboard/operations/incidents.stx +5 -0
  87. package/views/dashboard/operations/migrations.stx +8 -0
  88. package/views/dashboard/operations/recovery.stx +8 -0
  89. package/views/dashboard/operations/scheduler.stx +8 -0
  90. package/views/dashboard/stores/ci.ts +2 -2
@@ -0,0 +1,81 @@
1
+ <script client>
2
+ import type { SchedulerTask } from '../../../../functions/operations'
3
+
4
+ const tasks = useReactiveProp('tasks', [])
5
+ const loading = useReactiveProp('loading', false)
6
+ const busyTask = useReactiveProp('busyTask', '')
7
+ const emit = defineEmits()
8
+
9
+ function rows(): SchedulerTask[] {
10
+ return tasks() as SchedulerTask[]
11
+ }
12
+
13
+ function formatDate(value: string | null): string {
14
+ if (!value)
15
+ return 'Not scheduled'
16
+ const date = new Date(value)
17
+ return Number.isNaN(date.getTime())
18
+ ? value
19
+ : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
20
+ }
21
+ </script>
22
+
23
+ <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
24
+ <div :if="loading()" class="px-5 py-14 text-center text-gray-500 text-sm dark:text-neutral-400">
25
+ Loading scheduled tasks
26
+ </div>
27
+ <div :if="!loading() && rows().length === 0" class="px-5 py-14 text-center">
28
+ <span aria-hidden="true" class="block mb-3 mx-auto h-9 w-9 text-gray-400 i-hugeicons-calendar-03"></span>
29
+ <p class="font-semibold text-gray-900 dark:text-white">No scheduled tasks registered</p>
30
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Add a rate to an app job or register a task in app/Scheduler.ts.</p>
31
+ </div>
32
+ <div :if="rows().length > 0" class="overflow-x-auto">
33
+ <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
34
+ <thead class="bg-gray-50 dark:bg-neutral-700/50">
35
+ <tr>
36
+ <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Task</th>
37
+ <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Schedule</th>
38
+ <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Next run</th>
39
+ <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Status</th>
40
+ <th class="px-4 py-3"><span class="sr-only">Actions</span></th>
41
+ </tr>
42
+ </thead>
43
+ <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
44
+ <template :for="task in rows()">
45
+ <tr>
46
+ <td class="px-4 py-3">
47
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ task.name }}</p>
48
+ <p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ task.timezone }}</p>
49
+ </td>
50
+ <td class="px-4 py-3 font-mono text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ task.pattern || 'Not configured' }}</td>
51
+ <td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(task.nextRun) }}</td>
52
+ <td class="px-4 py-3">
53
+ <Badge :variant="task.enabled ? 'success' : 'warning'" dot>{{ task.enabled ? 'Active' : 'Paused' }}</Badge>
54
+ </td>
55
+ <td class="px-4 py-3 text-right whitespace-nowrap">
56
+ <div class="inline-flex gap-2 items-center">
57
+ <Button
58
+ size="sm"
59
+ :disabled="busyTask() !== '' || !task.enabled"
60
+ :loading="busyTask() === task.name + ':run'"
61
+ @click="emit('run', task.name)"
62
+ >
63
+ Run now
64
+ </Button>
65
+ <Button
66
+ size="sm"
67
+ variant="outline"
68
+ :disabled="busyTask() !== ''"
69
+ :loading="busyTask() === task.name + ':toggle'"
70
+ @click="emit('toggle', { name: task.name, enabled: !task.enabled })"
71
+ >
72
+ {{ task.enabled ? 'Pause' : 'Resume' }}
73
+ </Button>
74
+ </div>
75
+ </td>
76
+ </tr>
77
+ </template>
78
+ </tbody>
79
+ </table>
80
+ </div>
81
+ </div>
@@ -42,6 +42,7 @@ const files = state<ConfigFile[]>([])
42
42
  const fields = state<ConfigField[]>([])
43
43
  const drafts = reactive<Record<string, string | number | boolean>>({})
44
44
  const selected = state('')
45
+ const pendingSelection = state('')
45
46
  const query = state('')
46
47
  const loading = state(true)
47
48
  const saving = state(false)
@@ -58,6 +59,7 @@ const filteredFiles = derived(() => {
58
59
 
59
60
  const selectedFile = derived(() => files().find(file => file.name === selected()))
60
61
  const editableCount = derived(() => fields().filter(field => field.editable).length)
62
+ const isDirty = derived(() => fields().some(field => field.editable && drafts[field.key] !== field.value))
61
63
 
62
64
  function displayValue(field: ConfigField): string | number {
63
65
  if (field.editable)
@@ -101,7 +103,6 @@ async function selectFile(name: string): Promise<void> {
101
103
  if (!body.ok)
102
104
  throw new Error(body.error || `Unable to read config/${name}.ts`)
103
105
 
104
- fields.set(body.fields)
105
106
  for (const key of Object.keys(drafts))
106
107
  delete drafts[key]
107
108
  Object.assign(
@@ -112,6 +113,7 @@ async function selectFile(name: string): Promise<void> {
112
113
  .map((field: ConfigField) => [field.key, field.value]),
113
114
  ),
114
115
  )
116
+ fields.set(body.fields)
115
117
  }
116
118
  catch (cause) {
117
119
  error.set(cause instanceof Error ? cause.message : `Unable to read config/${name}.ts`)
@@ -121,6 +123,34 @@ async function selectFile(name: string): Promise<void> {
121
123
  }
122
124
  }
123
125
 
126
+ function requestSelectFile(name: string): void {
127
+ if (name === selected())
128
+ return
129
+ if (isDirty()) {
130
+ pendingSelection.set(name)
131
+ return
132
+ }
133
+ void selectFile(name)
134
+ }
135
+
136
+ function cancelPendingSelection(): void {
137
+ pendingSelection.set('')
138
+ }
139
+
140
+ function discardAndSelect(): void {
141
+ const name = pendingSelection()
142
+ pendingSelection.set('')
143
+ if (name)
144
+ void selectFile(name)
145
+ }
146
+
147
+ function handleBeforeUnload(event: BeforeUnloadEvent): void {
148
+ if (!isDirty())
149
+ return
150
+ event.preventDefault()
151
+ event.returnValue = ''
152
+ }
153
+
124
154
  async function save(): Promise<void> {
125
155
  const file = selected()
126
156
  if (!file || saving())
@@ -154,6 +184,7 @@ async function save(): Promise<void> {
154
184
  }
155
185
  }
156
186
 
187
+ useEventListener('beforeunload', handleBeforeUnload)
157
188
  void loadFiles()
158
189
  </script>
159
190
 
@@ -185,7 +216,7 @@ void loadFiles()
185
216
  <button
186
217
  type="button"
187
218
  :class="selected() === file.name ? 'flex items-center gap-3 px-3 py-2.5 w-full text-left text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-500/10 rounded-lg' : 'flex items-center gap-3 px-3 py-2.5 w-full text-left text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-white/5 rounded-lg'"
188
- @click="selectFile(file.name)"
219
+ @click="requestSelectFile(file.name)"
189
220
  >
190
221
  <span class="flex-none h-4 w-4 i-hugeicons-settings-02"></span>
191
222
  <span class="flex-1 font-medium text-sm truncate">{{ file.title }}</span>
@@ -220,8 +251,9 @@ void loadFiles()
220
251
  <p class="mt-1 font-mono text-neutral-500 text-xs dark:text-neutral-400">config/{{ selectedFile().name }}.ts</p>
221
252
  </div>
222
253
  <div class="flex gap-2 items-center">
254
+ <Badge :if="isDirty()" variant="warning" size="sm">Unsaved changes</Badge>
223
255
  <Badge variant="default" size="sm">{{ editableCount() }} editable</Badge>
224
- <Button variant="primary" size="sm" :disabled="saving() || editableCount() === 0" @click="save()">
256
+ <Button variant="primary" size="sm" :disabled="saving() || editableCount() === 0 || !isDirty()" @click="save()">
225
257
  {{ saving() ? 'Saving...' : 'Save changes' }}
226
258
  </Button>
227
259
  </div>
@@ -284,5 +316,15 @@ void loadFiles()
284
316
  @endif
285
317
  </Card>
286
318
  </div>
319
+
320
+ <ConfirmDialog
321
+ :isOpen="Boolean(pendingSelection())"
322
+ title="Discard unsaved changes?"
323
+ description="Switching configuration files will discard the edits in this file."
324
+ confirmLabel="Discard changes"
325
+ variant="warning"
326
+ @close="cancelPendingSelection"
327
+ @confirm="discardAndSelect"
328
+ />
287
329
  </div>
288
330
  </template>
@@ -3,12 +3,29 @@ interface ModalProps {
3
3
  isOpen?: boolean
4
4
  title?: string
5
5
  description?: string
6
+ ariaLabel?: string
6
7
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
8
+ position?: 'center' | 'top'
9
+ panelClassName?: string
10
+ contentClassName?: string
11
+ showCloseButton?: boolean
7
12
  closable?: boolean
8
13
  role?: 'dialog' | 'alertdialog'
9
14
  }
10
15
 
11
- const { isOpen = false, title = '', description = '', size = 'md', closable = true, role = 'dialog' } = defineProps<ModalProps>()
16
+ const {
17
+ isOpen = false,
18
+ title = '',
19
+ description = '',
20
+ ariaLabel = '',
21
+ size = 'md',
22
+ position = 'center',
23
+ panelClassName = '',
24
+ contentClassName = 'p-6',
25
+ showCloseButton = true,
26
+ closable = true,
27
+ role = 'dialog',
28
+ } = defineProps<ModalProps>()
12
29
 
13
30
  const sizeClasses = {
14
31
  sm: 'max-w-sm',
@@ -17,32 +34,41 @@ const sizeClasses = {
17
34
  xl: 'max-w-4xl',
18
35
  full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]'
19
36
  }
37
+
38
+ const positionClasses = {
39
+ center: 'items-center justify-center p-4',
40
+ top: 'items-start justify-center px-4 pb-4 pt-[15vh]',
41
+ }
20
42
  </script>
21
43
  <script client>
22
44
  const open = useReactiveProp('isOpen', false)
23
45
  const liveClosable = useReactiveProp('closable', true)
24
46
  const liveTitle = useReactiveProp('title', '')
25
47
  const liveDescription = useReactiveProp('description', '')
48
+ const liveAriaLabel = useReactiveProp('ariaLabel', '')
26
49
  const liveRole = useReactiveProp('role', 'dialog')
50
+ const liveShowCloseButton = useReactiveProp('showCloseButton', true)
27
51
  const dialog = useRef<HTMLDialogElement>('dashboardModalDialog')
28
52
  const activeElement = useActiveElement()
29
53
  const scrollLocked = useScrollLock()
30
54
  const emit = defineEmits()
31
55
  let restoreFocus: HTMLElement | null = null
32
56
 
33
- function closeModal(): void {
57
+ type ModalCloseReason = 'backdrop' | 'button' | 'escape'
58
+
59
+ function closeModal(reason: ModalCloseReason): void {
34
60
  if (!liveClosable()) return
35
- emit('close')
61
+ emit('close', reason)
36
62
  }
37
63
 
38
64
  function handleCancel(event: Event): void {
39
65
  event.preventDefault()
40
- closeModal()
66
+ closeModal('escape')
41
67
  }
42
68
 
43
69
  function handleBackdropClick(event: MouseEvent): void {
44
70
  if (event.target === event.currentTarget)
45
- closeModal()
71
+ closeModal('backdrop')
46
72
  }
47
73
 
48
74
  effect(() => {
@@ -61,8 +87,11 @@ effect(() => {
61
87
  const autofocusTarget = element.querySelector<HTMLElement>('[autofocus]:not([disabled])')
62
88
  if (autofocusTarget)
63
89
  autofocusTarget.focus()
64
- else
65
- element.focus()
90
+ else {
91
+ const currentFocus = peek(() => activeElement.value)
92
+ if (!(currentFocus instanceof HTMLElement && element.contains(currentFocus)))
93
+ element.focus()
94
+ }
66
95
  })
67
96
  return
68
97
  }
@@ -80,17 +109,17 @@ effect(() => {
80
109
  ref="dashboardModalDialog"
81
110
  :role="liveRole()"
82
111
  aria-modal="true"
83
- :aria-label="liveTitle() || 'Dialog'"
112
+ :aria-label="liveAriaLabel() || liveTitle() || 'Dialog'"
84
113
  class="fixed inset-0 overflow-y-auto z-[55] m-0 p-0 h-dvh max-h-none max-w-none w-screen bg-transparent dashboard-modal-dialog dashboard-modal-layer"
85
114
  @cancel="handleCancel"
86
115
  >
87
116
  <!-- Modal container -->
88
- <div class="flex items-center justify-center p-4 min-h-full" @click="handleBackdropClick">
117
+ <div class="flex min-h-full {{ positionClasses[position] }}" @click="handleBackdropClick">
89
118
  <div
90
- class="relative w-full rounded-xl bg-white dark:bg-neutral-900 shadow-2xl {{ sizeClasses[size] }}"
119
+ class="relative w-full rounded-xl bg-white dark:bg-neutral-900 shadow-2xl {{ sizeClasses[size] }} {{ panelClassName }}"
91
120
  >
92
121
  <!-- Header -->
93
- <div :if="liveTitle() || liveClosable()" class="flex gap-4 items-start justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
122
+ <div :if="liveTitle() || (liveClosable() && liveShowCloseButton())" class="flex gap-4 items-start justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
94
123
  <div class="flex gap-3 items-start min-w-0">
95
124
  <slot name="header-leading" />
96
125
  <div class="min-w-0">
@@ -106,12 +135,12 @@ effect(() => {
106
135
  <div class="flex flex-shrink-0 gap-2 items-center">
107
136
  <slot name="header-trailing" />
108
137
  <Button
109
- :if="liveClosable()"
138
+ :if="liveClosable() && liveShowCloseButton()"
110
139
  variant="ghost"
111
140
  size="sm"
112
141
  iconOnly
113
142
  ariaLabel="Close modal"
114
- @click="closeModal"
143
+ @click="closeModal('button')"
115
144
  >
116
145
  <div class="h-5 w-5 i-hugeicons-cancel-01"></div>
117
146
  </Button>
@@ -120,7 +149,7 @@ effect(() => {
120
149
 
121
150
  <!-- Body -->
122
151
  @if($slots.default)
123
- <div class="p-6">
152
+ <div class="{{ contentClassName }}">
124
153
  <slot />
125
154
  </div>
126
155
  @endif
@@ -326,12 +326,7 @@ function clearFilters(): void {
326
326
 
327
327
  /* ----------------------------------------------------------------- export */
328
328
 
329
- /**
330
- * CSV of what is on screen: current search, filters and sort, all columns
331
- * that are visible, every page rather than just the current one. Exporting
332
- * the raw prop would hand back something the viewer never asked for.
333
- */
334
- function exportCsv(): void {
329
+ function csvBlob(): Blob {
335
330
  const cols = visibleColumns()
336
331
  const escape = (value: any): string => {
337
332
  const text = value === null || value === undefined ? '' : String(value)
@@ -343,15 +338,21 @@ function exportCsv(): void {
343
338
  ...sorted().map(row => cols.map(column => escape(rawValue(row, column.key))).join(',')),
344
339
  ]
345
340
 
346
- const blob = new Blob([`${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
347
- const url = URL.createObjectURL(blob)
348
- const link = document.createElement('a')
349
- link.href = url
350
- link.download = `${exportFilename()}.csv`
351
- document.body.appendChild(link)
352
- link.click()
353
- document.body.removeChild(link)
354
- URL.revokeObjectURL(url)
341
+ return new Blob([`${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
342
+ }
343
+
344
+ /**
345
+ * CSV of what is on screen: current search, filters and sort, all columns
346
+ * that are visible, every page rather than just the current one. Exporting
347
+ * the raw prop would hand back something the viewer never asked for.
348
+ * useObjectUrl owns URL revocation while the native download link owns the
349
+ * browser interaction.
350
+ */
351
+ const exportBlob = derived(() => exportable() ? csvBlob() : null)
352
+ const exportUrl = useObjectUrl(exportBlob)
353
+ const exportDownload = derived(() => `${exportFilename()}.csv`)
354
+
355
+ function recordExport(): void {
355
356
  emit('export', sorted().length)
356
357
  }
357
358
 
@@ -420,14 +421,14 @@ function cellClass(column: DashboardTableColumn, index: number): string {
420
421
  />
421
422
  </div>
422
423
 
423
- <button
424
+ <Button
424
425
  :if="hasActiveFilters()"
425
- type="button"
426
+ variant="ghost"
427
+ size="xs"
426
428
  @click="clearFilters()"
427
- class="px-2.5 py-1.5 text-neutral-600 text-xs dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
428
429
  >
429
430
  Clear filters
430
- </button>
431
+ </Button>
431
432
 
432
433
  <slot name="toolbar" />
433
434
  </div>
@@ -463,15 +464,19 @@ function cellClass(column: DashboardTableColumn, index: number): string {
463
464
  </div>
464
465
  </div>
465
466
 
466
- <button
467
+ <Button
467
468
  :if="exportable()"
468
- type="button"
469
- @click="exportCsv()"
470
- class="inline-flex gap-1.5 items-center px-2.5 py-1.5 text-neutral-600 text-xs dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600"
469
+ tag="a"
470
+ variant="outline"
471
+ size="sm"
472
+ :href="exportUrl.value || ''"
473
+ :download="exportDownload()"
474
+ :disabled="!exportUrl.value"
475
+ @click="recordExport()"
471
476
  >
472
477
  <span aria-hidden="true" class="h-3.5 w-3.5 i-hugeicons-download-04"></span>
473
478
  Export CSV
474
- </button>
479
+ </Button>
475
480
  </div>
476
481
  </div>
477
482
 
@@ -588,7 +593,7 @@ function cellClass(column: DashboardTableColumn, index: number): string {
588
593
  <span aria-hidden="true" class="mb-3 p-3 h-12 w-12 text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full i-hugeicons-search-remove"></span>
589
594
  <p class="font-medium text-neutral-700 text-sm dark:text-neutral-200">No matches</p>
590
595
  <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">No rows match the current search or filters.</p>
591
- <button type="button" @click="clearFilters()" class="mt-3 text-blue-600 text-sm dark:text-blue-400 hover:underline">Clear filters</button>
596
+ <Button variant="ghost" size="sm" class="mt-3" @click="clearFilters()">Clear filters</Button>
592
597
  </div>
593
598
 
594
599
  <div :if="visibleRows().length === 0 && !loading() && !hasActiveFilters()" class="flex flex-col items-center justify-center px-4 py-12">
@@ -281,6 +281,7 @@ export function buildNavSections(
281
281
  // (`/notifications/dashboard`); the bare `/notifications` redirects.
282
282
  sections.push(['app', 'app', [
283
283
  { to: '/deployments', icon: 'rocket', text: 'Deployments' },
284
+ { to: '/operations/scheduler', icon: 'calendar-03', text: 'Operations' },
284
285
  { to: '/requests', icon: 'api', text: 'Requests' },
285
286
  { to: '/realtime', icon: 'link', text: 'Realtime' },
286
287
  { to: '/actions', icon: 'bolt', text: 'Actions' },
@@ -161,6 +161,9 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
161
161
  guard(route.delete('/commerce/print-logs/{id}', 'Actions/Commerce/ReceiptDestroyAction'))
162
162
  guard(route.get('/deployments', 'Actions/Dashboard/Deployments/GetDeployments'))
163
163
  guard(route.post('/deployments', 'Actions/Dashboard/Deployments/CreateDeployment'))
164
+ guard(route.post('/deployments/preview', 'Actions/Dashboard/Deployments/PreviewDeployment'))
165
+ guard(route.post('/deployments/rollback/preview', 'Actions/Dashboard/Deployments/PreviewDeploymentRollback'))
166
+ guard(route.post('/deployments/rollback', 'Actions/Dashboard/Deployments/CreateDeploymentRollback'))
164
167
  guard(route.get('/deployments/count', 'Actions/Dashboard/Deployments/GetDeploymentCount'))
165
168
  guard(route.get('/deployments/recent', 'Actions/Dashboard/Deployments/GetRecentDeployments'))
166
169
  guard(route.get('/deployments/avg-time', 'Actions/Dashboard/Deployments/GetAverageDeploymentTime'))
@@ -168,6 +171,26 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
168
171
  guard(route.put('/deployments/script', 'Actions/Dashboard/Deployments/UpdateDeployScript'))
169
172
  guard(route.get('/deployments/terminal', 'Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput'))
170
173
  guard(route.get('/deployments/{id}', 'Actions/Dashboard/Deployments/GetDeployment'))
174
+ guard(route.get('/operations/scheduler', 'Actions/Dashboard/Operations/SchedulerIndexAction'))
175
+ guard(route.post('/operations/scheduler/{name}/run', 'Actions/Dashboard/Operations/SchedulerRunAction'))
176
+ guard(route.patch('/operations/scheduler/{name}', 'Actions/Dashboard/Operations/SchedulerToggleAction'))
177
+ guard(route.get('/operations/recovery', 'Actions/Dashboard/Operations/RecoveryIndexAction'))
178
+ guard(route.post('/operations/recovery/destinations', 'Actions/Dashboard/Operations/RecoveryDestinationStoreAction'))
179
+ guard(route.post('/operations/recovery/destinations/{id}/test', 'Actions/Dashboard/Operations/RecoveryDestinationTestAction'))
180
+ guard(route.post('/operations/recovery/policies', 'Actions/Dashboard/Operations/RecoveryPolicyStoreAction'))
181
+ guard(route.post('/operations/recovery/policies/{id}/run', 'Actions/Dashboard/Operations/RecoveryRunAction'))
182
+ guard(route.post('/operations/recovery/points/{id}/verify', 'Actions/Dashboard/Operations/RecoveryVerifyAction'))
183
+ guard(route.post('/operations/recovery/points/{id}/restore', 'Actions/Dashboard/Operations/RecoveryRestoreAction'))
184
+ guard(route.patch('/operations/recovery/points/{id}/protection', 'Actions/Dashboard/Operations/RecoveryProtectAction'))
185
+ guard(route.post('/operations/recovery/retention', 'Actions/Dashboard/Operations/RecoveryRetentionAction'))
186
+ guard(route.get('/operations/migrations', 'Actions/Dashboard/Operations/MigrationIndexAction'))
187
+ guard(route.post('/operations/migrations/apply', 'Actions/Dashboard/Operations/MigrationApplyAction'))
188
+ guard(route.post('/operations/migrations/reconcile', 'Actions/Dashboard/Operations/MigrationReconcileAction'))
189
+ guard(route.get('/operations/changes', 'Actions/Dashboard/Operations/ChangeIndexAction'))
190
+ guard(route.post('/operations/changes/releases/{id}/decision', 'Actions/Dashboard/Operations/ChangeApprovalAction'))
191
+ guard(route.get('/operations/incidents', 'Actions/Dashboard/Operations/IncidentIndexAction'))
192
+ guard(route.patch('/operations/incidents/{id}', 'Actions/Dashboard/Operations/IncidentUpdateAction'))
193
+ guard(route.get('/operations/audit', 'Actions/Dashboard/Operations/AuditIndexAction'))
171
194
  guard(route.get('/data/activity', 'Actions/Dashboard/Data/ActivityIndexAction'))
172
195
  guard(route.get('/data/users', 'Actions/Dashboard/Data/UserIndexAction'))
173
196
  guard(route.get('/data/teams', 'Actions/Dashboard/Data/TeamIndexAction'))
@@ -647,6 +647,7 @@ route.group({ prefix: '/deployments', middleware: 'auth' }, () => {
647
647
  route.get('/recent', 'Actions/Dashboard/Deployments/GetRecentDeployments')
648
648
  route.get('/avg-time', 'Actions/Dashboard/Deployments/GetAverageDeploymentTime')
649
649
  route.post('/', 'Actions/Dashboard/Deployments/CreateDeployment')
650
+ route.post('/preview', 'Actions/Dashboard/Deployments/PreviewDeployment')
650
651
  route.get('/script', 'Actions/Dashboard/Deployments/GetDeployScript')
651
652
  route.put('/script', 'Actions/Dashboard/Deployments/UpdateDeployScript')
652
653
  route.get('/terminal', 'Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput')
@@ -1,6 +1,6 @@
1
1
  # Dashboard audit
2
2
 
3
- Last refreshed: 2026-07-31
3
+ Last refreshed: 2026-08-11
4
4
 
5
5
  This is the current verification record for `./buddy dev --dashboard`. It
6
6
  replaces the May 2026 migration inventory, whose per-file counts no longer
@@ -11,23 +11,25 @@ described the componentized dashboard.
11
11
  | Surface | Count |
12
12
  |---|---:|
13
13
  | Dashboard STX view files | 120 |
14
- | Dashboard STX components | 283 |
15
- | Dashboard Actions | 359 |
14
+ | Dashboard STX components | 303 |
15
+ | Dashboard Actions | 368 |
16
16
  | Registered `/api/dashboard/*` routes | 283 |
17
- | Framework model files | 69 |
18
- | Framework models declaring `useApi` | 60 |
19
- | Direct `fetch()` calls in dashboard views, components, functions, and stores | 0 |
17
+ | Framework model files | 75 |
18
+ | Framework models declaring `useApi` | 64 |
19
+ | Direct `fetch()` calls in dashboard views, components, and stores | 0 |
20
20
 
21
- The nine models without `useApi` are internal records or relationship-owned
22
- records: order idempotency keys, order items, errors, jobs, failed jobs, and
23
- payment-provider records. They are not missing generic CRUD surfaces.
21
+ The eleven models without `useApi` are internal, relationship-owned, or
22
+ provider-owned records: driver pings, order idempotency keys, order items,
23
+ errors, jobs, failed jobs, payment methods, payment products, payment
24
+ transactions, social accounts, and subscriptions. They are not missing generic
25
+ CRUD surfaces.
24
26
 
25
27
  ## Verified contracts
26
28
 
27
29
  ### Rendering and navigation
28
30
 
29
- - `./buddy dev --dashboard` starts on port 3002 and renders with STX 0.2.144
30
- and Crosswind 0.2.14.
31
+ - `./buddy dev --dashboard` starts on port 3002 and renders with STX 0.2.176
32
+ and Crosswind 0.2.16.
31
33
  - 108 static route views render with HTTP 200.
32
34
  - Full-page and STX fragment requests render for every static route.
33
35
  - STX prewarms the dependency-aware rendered shell cache with four workers.
@@ -37,12 +39,14 @@ payment-provider records. They are not missing generic CRUD surfaces.
37
39
  a dashboard composable invalidates the associated client bundle.
38
40
  - The componentized catch-all renders the native not-found page with HTTP 404
39
41
  for both full-page and STX fragment requests.
40
- - 130 distinct rendered local links and assets resolve without a 404 or 5xx.
42
+ - A dependency-free browser crawl followed the complete rendered link graph
43
+ across 261 distinct page URLs with no HTTP, console, request, or horizontal
44
+ overflow failure.
41
45
  - Rendered pages contain no unresolved PascalCase component tags.
42
46
  - Rendered pages contain no duplicate emitted IDs or broken
43
47
  `aria-labelledby` references.
44
48
  - The live render and navigation audit covers all 108 static destinations,
45
- all 69 discovered model destinations, and the remaining eight parameterized
49
+ all 75 discovered model destinations, and the remaining eight parameterized
46
50
  route families without a full-page or STX fragment failure.
47
51
  - Headless desktop and narrow-viewport visual checks cover the componentized
48
52
  Terms of Service and Privacy Policy routes linked from registration.
@@ -92,9 +96,9 @@ payment-provider records. They are not missing generic CRUD surfaces.
92
96
 
93
97
  ### Data integrity
94
98
 
95
- - The 128 live GET dashboard API routes return JSON or their documented text
99
+ - The 129 live GET dashboard API routes return JSON or their documented text
96
100
  payload with no hidden HTTP-200 error body.
97
- - A fresh live crawl returned 117 successful responses and 11 intentional
101
+ - A fresh live audit returned 118 successful responses and 11 intentional
98
102
  validation, authentication, or missing-resource responses, with no 5xx,
99
103
  method mismatch, transport failure, or HTML fallback.
100
104
  - Generic model routes reject malformed slugs with HTTP 400 before deriving
@@ -118,6 +122,7 @@ payment-provider records. They are not missing generic CRUD surfaces.
118
122
  bun storage/framework/defaults/ai/skills/stacks-dashboard/scripts/audit.ts
119
123
  # Or target a non-default origin:
120
124
  bun storage/framework/defaults/ai/skills/stacks-dashboard/scripts/audit.ts --base-url http://127.0.0.1:3002
125
+ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl http://localhost:3002/ --max 500 --settle 350 --summary
121
126
  bun test tests/unit/dashboard-*.test.ts
122
127
  bun test storage/framework/core/actions/tests/dev-csrf.test.ts
123
128
  bun run typecheck
@@ -128,7 +133,7 @@ bunx --bun pickier .
128
133
  The focused contracts cover buttons, native STX bindings, route and Action
129
134
  method alignment, model reads and writes, commerce mutations, deployment
130
135
  guards, navigation source, sidebar behavior, toasts, and skill documentation.
131
- The 2026-07-31 focused run completed 156 tests with 2,916 assertions and no
136
+ The 2026-08-11 focused run completed 226 tests with 5,424 assertions and no
132
137
  failures, followed by both TypeScript checks and a clean repository lint.
133
138
 
134
139
  ## Remaining verification boundaries
@@ -0,0 +1,5 @@
1
+ @extends('layouts/default')
2
+ @section('content')
3
+ @title('Dashboard - Operations - Audit')
4
+ <PageLayout><AuditDashboard /></PageLayout>
5
+ @endsection
@@ -0,0 +1,5 @@
1
+ @extends('layouts/default')
2
+ @section('content')
3
+ @title('Dashboard - Operations - Changes')
4
+ <PageLayout><ChangeDashboard /></PageLayout>
5
+ @endsection
@@ -0,0 +1,5 @@
1
+ @extends('layouts/default')
2
+ @section('content')
3
+ @title('Dashboard - Operations - Incidents')
4
+ <PageLayout><IncidentDashboard /></PageLayout>
5
+ @endsection
@@ -0,0 +1,8 @@
1
+ @extends('layouts/default')
2
+
3
+ @section('content')
4
+ @title('Dashboard - Operations - Migrations')
5
+ <PageLayout>
6
+ <MigrationDashboard />
7
+ </PageLayout>
8
+ @endsection
@@ -0,0 +1,8 @@
1
+ @extends('layouts/default')
2
+
3
+ @section('content')
4
+ @title('Dashboard - Operations - Recovery')
5
+ <PageLayout>
6
+ <RecoveryDashboard />
7
+ </PageLayout>
8
+ @endsection
@@ -0,0 +1,8 @@
1
+ @extends('layouts/default')
2
+
3
+ @section('content')
4
+ @title('Dashboard - Operations - Scheduler')
5
+ <PageLayout>
6
+ <SchedulerDashboard />
7
+ </PageLayout>
8
+ @endsection
@@ -24,8 +24,8 @@ interface RepoStatus {
24
24
  updatedAt: string | null
25
25
  runUrl: string | null
26
26
  failedJobs: FailedJob[]
27
- renovatePRs: number
28
- renovatePRsUrl: string | null
27
+ buddyBotPRs: number
28
+ buddyBotPRsUrl: string | null
29
29
  actionsPRs: number
30
30
  actionsPRsUrl: string | null
31
31
  }