@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
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.365",
5
+ "version": "0.70.367",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -17,8 +17,6 @@ async function handleForgotPassword(data) {
17
17
  }
18
18
  catch (err) {
19
19
  error.set(err instanceof Error ? err.message : 'Failed to send reset link')
20
- // eslint-disable-next-line no-console
21
- console.error(err)
22
20
  }
23
21
  finally {
24
22
  isLoading.set(false)
@@ -27,7 +27,7 @@ async function handleLogin(credentials) {
27
27
  navigate(safeRedirect())
28
28
  }
29
29
  catch (err) {
30
- error.set(err.message || 'An error occurred during login')
30
+ error.set(err instanceof Error ? err.message : 'An error occurred during login')
31
31
  }
32
32
  finally {
33
33
  isLoading.set(false)
@@ -27,9 +27,7 @@ async function handleRegister(credentials) {
27
27
  navigate(safeRedirect())
28
28
  }
29
29
  catch (err) {
30
- error.set(err.message || 'An error occurred during registration')
31
- // eslint-disable-next-line no-console
32
- console.error(err)
30
+ error.set(err instanceof Error ? err.message : 'An error occurred during registration')
33
31
  }
34
32
  finally {
35
33
  isLoading.set(false)
@@ -263,13 +263,13 @@
263
263
  <span class="text-xs text-zinc-400" :text="repo.commitAuthor"></span>
264
264
  </div>
265
265
 
266
- <div :if="repo.renovatePRs > 0 || repo.actionsPRs > 0" class="mt-2 pt-2 border-t border-zinc-200/60 dark:border-zinc-800">
266
+ <div :if="repo.buddyBotPRs > 0 || repo.actionsPRs > 0" class="mt-2 pt-2 border-t border-zinc-200/60 dark:border-zinc-800">
267
267
  <div class="mb-1 text-xs text-zinc-400">Pull Requests</div>
268
268
  <div class="flex flex-wrap gap-x-3 gap-y-0.5">
269
- <a :if="repo.renovatePRs > 0" :href="repo.renovatePRsUrl" target="_blank" @click.stop class="no-underline text-xs text-zinc-500 dark:text-zinc-400 hover:underline">
270
- renovatebot: <span :text="repo.renovatePRs"></span>
269
+ <a :if="repo.buddyBotPRs > 0" :href="repo.buddyBotPRsUrl" target="_blank" rel="noreferrer" @click.stop class="no-underline text-xs text-zinc-500 dark:text-zinc-400 hover:underline">
270
+ buddy-bot: <span :text="repo.buddyBotPRs"></span>
271
271
  </a>
272
- <a :if="repo.actionsPRs > 0" :href="repo.actionsPRsUrl" target="_blank" @click.stop class="no-underline text-xs text-zinc-500 dark:text-zinc-400 hover:underline">
272
+ <a :if="repo.actionsPRs > 0" :href="repo.actionsPRsUrl" target="_blank" rel="noreferrer" @click.stop class="no-underline text-xs text-zinc-500 dark:text-zinc-400 hover:underline">
273
273
  github-actions: <span :text="repo.actionsPRs"></span>
274
274
  </a>
275
275
  </div>
@@ -1,7 +1,8 @@
1
1
  <script client>
2
- import type { DeploymentRecord } from '../../../../functions/deployments'
2
+ import type { DeploymentRecord, DeploymentRollbackPlan } from '../../../../functions/deployments'
3
+ import type { DeploymentPreview } from '@stacksjs/types'
3
4
  import { dashboardApi } from '../../../../functions/dashboard-api'
4
- import { fetchDeployments, summarizeDeployments } from '../../../../functions/deployments'
5
+ import { fetchDeployments, previewDeploymentRollback, runDeploymentRollback, summarizeDeployments } from '../../../../functions/deployments'
5
6
  import { pushToast } from '../../../../functions/toasts'
6
7
 
7
8
  interface DeploymentStartResponse {
@@ -16,9 +17,18 @@ const status = state('all')
16
17
  const isLoading = state(true)
17
18
  const loadError = state('')
18
19
  const showDeployModal = state(false)
20
+ const deploymentDialogMode = state<'preview' | 'deploy'>('deploy')
19
21
  const isStarting = state(false)
22
+ const isPreviewing = state(false)
23
+ const previewPlan = state<DeploymentPreview | null>(null)
20
24
  const environment = state('production')
21
25
  const domain = state('')
26
+ const showRollbackModal = state(false)
27
+ const rollbackPlan = state<DeploymentRollbackPlan | null>(null)
28
+ const rollbackSite = state('')
29
+ const rollbackRelease = state('')
30
+ const rollbackConfirmation = state('')
31
+ const isRollingBack = state(false)
22
32
  const summary = derived(() => summarizeDeployments(deployments()))
23
33
  const environmentOptions = [
24
34
  { value: 'production', label: 'Production' },
@@ -72,14 +82,13 @@ async function loadDeployments(): Promise<void> {
72
82
  }
73
83
  }
74
84
 
75
- async function startDeployment(dryRun: boolean): Promise<void> {
85
+ async function startDeployment(): Promise<void> {
76
86
  isStarting.set(true)
77
87
  try {
78
88
  const result = await dashboardApi<DeploymentStartResponse>('/api/dashboard/deployments', {
79
89
  method: 'POST',
80
90
  body: {
81
- confirmed: !dryRun,
82
- dryRun,
91
+ confirmed: true,
83
92
  environment: environment(),
84
93
  domain: domain().trim(),
85
94
  },
@@ -90,11 +99,10 @@ async function startDeployment(dryRun: boolean): Promise<void> {
90
99
  pushToast('success', result.message, {
91
100
  detail: result.pid ? `Process ${result.pid}` : undefined,
92
101
  })
93
- if (!dryRun)
94
- await loadDeployments()
102
+ await loadDeployments()
95
103
  }
96
104
  catch (error) {
97
- pushToast('error', dryRun ? 'Could not preview deployment' : 'Could not start deployment', {
105
+ pushToast('error', 'Could not start deployment', {
98
106
  detail: error instanceof Error ? error.message : String(error),
99
107
  })
100
108
  }
@@ -103,6 +111,89 @@ async function startDeployment(dryRun: boolean): Promise<void> {
103
111
  }
104
112
  }
105
113
 
114
+ function openDeploymentDialog(mode: 'preview' | 'deploy'): void {
115
+ deploymentDialogMode.set(mode)
116
+ showDeployModal.set(true)
117
+ }
118
+
119
+ async function previewDeployment(): Promise<void> {
120
+ isPreviewing.set(true)
121
+ try {
122
+ const result = await dashboardApi<{ success: boolean, plan: DeploymentPreview }>('/api/dashboard/deployments/preview', {
123
+ method: 'POST',
124
+ body: {
125
+ environment: environment(),
126
+ domain: domain().trim(),
127
+ },
128
+ })
129
+ if (!result.success || !result.plan)
130
+ throw new Error('Deployment preview could not be generated.')
131
+ showDeployModal.set(false)
132
+ previewPlan.set(result.plan)
133
+ }
134
+ catch (error) {
135
+ pushToast('error', 'Could not preview deployment', {
136
+ detail: error instanceof Error ? error.message : String(error),
137
+ })
138
+ }
139
+ finally {
140
+ isPreviewing.set(false)
141
+ }
142
+ }
143
+
144
+ function continueFromPreview(): void {
145
+ previewPlan.set(null)
146
+ openDeploymentDialog('deploy')
147
+ }
148
+
149
+ function openRollback(): void {
150
+ rollbackPlan.set(null)
151
+ rollbackConfirmation.set('')
152
+ showRollbackModal.set(true)
153
+ }
154
+
155
+ async function previewRollback(): Promise<void> {
156
+ isRollingBack.set(true)
157
+ try {
158
+ const result = await previewDeploymentRollback({
159
+ environment: environment(),
160
+ site: rollbackSite().trim() || undefined,
161
+ release: rollbackRelease().trim() || undefined,
162
+ })
163
+ rollbackPlan.set(result.plan)
164
+ }
165
+ catch (error) {
166
+ pushToast('error', 'Could not preview rollback', { detail: error instanceof Error ? error.message : String(error) })
167
+ }
168
+ finally {
169
+ isRollingBack.set(false)
170
+ }
171
+ }
172
+
173
+ async function confirmRollback(): Promise<void> {
174
+ const plan = rollbackPlan()
175
+ if (!plan) return
176
+ isRollingBack.set(true)
177
+ try {
178
+ const result = await runDeploymentRollback({
179
+ environment: environment(),
180
+ site: rollbackSite().trim() || undefined,
181
+ release: rollbackRelease().trim() || undefined,
182
+ revision: plan.revision,
183
+ confirmation: rollbackConfirmation(),
184
+ })
185
+ showRollbackModal.set(false)
186
+ pushToast('success', result.message)
187
+ await loadDeployments()
188
+ }
189
+ catch (error) {
190
+ pushToast('error', 'Rollback failed', { detail: error instanceof Error ? error.message : String(error) })
191
+ }
192
+ finally {
193
+ isRollingBack.set(false)
194
+ }
195
+ }
196
+
106
197
  onMount(() => {
107
198
  void loadDeployments()
108
199
  })
@@ -115,10 +206,13 @@ onMount(() => {
115
206
  <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Deployment model records through the guarded dashboard API.</p>
116
207
  </div>
117
208
  <div class="flex gap-2 items-center">
118
- <Button :disabled="isStarting()" variant="outline" @click="startDeployment(true)">
209
+ <Button :disabled="isStarting() || isPreviewing()" variant="outline" @click="openDeploymentDialog('preview')">
119
210
  Preview
120
211
  </Button>
121
- <Button :disabled="isStarting()" @click="showDeployModal.set(true)">
212
+ <Button :disabled="isStarting() || isPreviewing() || isRollingBack()" variant="secondary" @click="openRollback">
213
+ Rollback
214
+ </Button>
215
+ <Button :disabled="isStarting() || isPreviewing()" @click="openDeploymentDialog('deploy')">
122
216
  Deploy
123
217
  </Button>
124
218
  <Button :loading="isLoading()" variant="secondary" @click="loadDeployments">
@@ -179,8 +273,8 @@ onMount(() => {
179
273
 
180
274
  <Modal
181
275
  :isOpen="showDeployModal()"
182
- title="Start deployment"
183
- description="This runs ./buddy deploy as a real background process and records its status."
276
+ :title="deploymentDialogMode() === 'preview' ? 'Preview deployment' : 'Start deployment'"
277
+ :description="deploymentDialogMode() === 'preview' ? 'Generate a non-mutating plan from the native Buddy deployment pipeline.' : 'This runs ./buddy deploy as a real background process and records its status.'"
184
278
  size="sm"
185
279
  @close="showDeployModal.set(false)"
186
280
  >
@@ -204,9 +298,48 @@ onMount(() => {
204
298
  </div>
205
299
  <template #footer>
206
300
  <div class="flex gap-2 items-center">
207
- <Button :disabled="isStarting()" variant="ghost" @click="showDeployModal.set(false)">Cancel</Button>
208
- <Button :loading="isStarting()" @click="startDeployment(false)">Start deployment</Button>
301
+ <Button :disabled="isStarting() || isPreviewing()" variant="ghost" @click="showDeployModal.set(false)">Cancel</Button>
302
+ <template :if="deploymentDialogMode() === 'preview'">
303
+ <Button :loading="isPreviewing()" @click="previewDeployment">Generate preview</Button>
304
+ </template>
305
+ <template :else>
306
+ <Button :loading="isStarting()" @click="startDeployment">Start deployment</Button>
307
+ </template>
308
+ </div>
309
+ </template>
310
+ </Modal>
311
+
312
+ <DeploymentPreviewDialog :plan="previewPlan" @close="previewPlan.set(null)" @deploy="continueFromPreview" />
313
+
314
+ <Modal :isOpen="showRollbackModal()" title="Rollback deployment" description="Preview and validate a preserved release before changing the active runtime." size="lg" @close="showRollbackModal.set(false)">
315
+ <div class="space-y-4">
316
+ <div class="grid gap-4 sm:grid-cols-2">
317
+ <Select v-model:value="environment" :options="environmentOptions" label="Environment" placeholder="" />
318
+ <Input v-model:value="rollbackSite" label="Site" placeholder="All application sites" hint="Optional site slug." />
209
319
  </div>
320
+ <Input v-model:value="rollbackRelease" label="Preserved release" placeholder="Previous release" hint="Optional. Leave blank to use the previous preserved release." />
321
+ <template :if="rollbackPlan()">
322
+ <div class="p-4 bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 rounded-lg dark:border-neutral-800">
323
+ <div class="flex gap-3 items-start">
324
+ <span aria-hidden="true" class="shrink-0 mt-0.5 h-5 w-5 text-blue-600 i-hugeicons-git-pull-request"></span>
325
+ <div>
326
+ <p class="font-semibold text-gray-900 text-sm dark:text-white">Validated rollback to {{ rollbackPlan()?.target }}</p>
327
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">{{ rollbackPlan()?.site || 'All sites' }} in {{ rollbackPlan()?.environment }}</p>
328
+ </div>
329
+ </div>
330
+ <pre class="mt-4 p-3 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-neutral-600 text-xs dark:text-neutral-300 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">{{ rollbackPlan()?.output }}</pre>
331
+ </div>
332
+ <div :for="warning in rollbackPlan()?.warnings || []" class="flex gap-2 items-start text-orange-800 text-sm dark:text-orange-200">
333
+ <span aria-hidden="true" class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
334
+ <span>{{ warning }}</span>
335
+ </div>
336
+ <Input v-model:value="rollbackConfirmation" label="Confirmation" :placeholder="'rollback ' + environment()" :hint="'Type rollback ' + environment() + ' to continue.'" autocomplete="off" />
337
+ </template>
338
+ </div>
339
+ <template #footer>
340
+ <Button variant="secondary" :disabled="isRollingBack()" @click="showRollbackModal.set(false)">Cancel</Button>
341
+ <Button :if="!rollbackPlan()" :loading="isRollingBack()" @click="previewRollback">Preview rollback</Button>
342
+ <Button :if="rollbackPlan()" variant="danger" :loading="isRollingBack()" @click="confirmRollback">Rollback deployment</Button>
210
343
  </template>
211
344
  </Modal>
212
345
  </div>
@@ -0,0 +1,118 @@
1
+ <script client>
2
+ import type { DeploymentPreview, DeploymentPreviewOperation } from '@stacksjs/types'
3
+
4
+ const plan = useReactiveProp<DeploymentPreview | null>('plan', null)
5
+ const emit = defineEmits()
6
+
7
+ const phaseIcons: Record<DeploymentPreviewOperation['phase'], string> = {
8
+ validate: 'i-hugeicons-task-01',
9
+ infrastructure: 'i-hugeicons-cloud-server',
10
+ build: 'i-hugeicons-tools',
11
+ package: 'i-hugeicons-package',
12
+ release: 'i-hugeicons-upload-04',
13
+ runtime: 'i-hugeicons-computer-terminal-01',
14
+ gateway: 'i-hugeicons-route-01',
15
+ dns: 'i-hugeicons-globe-02',
16
+ tls: 'i-hugeicons-shield-key',
17
+ container: 'i-hugeicons-cube',
18
+ }
19
+
20
+ function icon(operation: DeploymentPreviewOperation): string {
21
+ return phaseIcons[operation.phase] || 'i-hugeicons-check-list'
22
+ }
23
+ </script>
24
+
25
+ <Modal
26
+ :isOpen="Boolean(plan())"
27
+ title="Deployment preview"
28
+ description="Generated from the same environment-aware deployment model as Buddy. No changes were made."
29
+ size="xl"
30
+ contentClassName="p-0"
31
+ @close="emit('close')"
32
+ >
33
+ <div :if="plan()" class="max-h-[65vh] overflow-y-auto">
34
+ <div class="grid gap-px grid-cols-2 md:grid-cols-4 bg-neutral-200 dark:bg-neutral-700 border-neutral-200 border-b dark:border-neutral-700">
35
+ <div class="p-4 bg-white dark:bg-neutral-900">
36
+ <p class="text-neutral-500 text-xs uppercase dark:text-neutral-400">Environment</p>
37
+ <p class="mt-1 capitalize font-semibold text-neutral-900 text-sm dark:text-white">{{ plan()?.environment }}</p>
38
+ </div>
39
+ <div class="p-4 bg-white dark:bg-neutral-900">
40
+ <p class="text-neutral-500 text-xs uppercase dark:text-neutral-400">Provider</p>
41
+ <p class="mt-1 capitalize font-semibold text-neutral-900 text-sm dark:text-white">{{ plan()?.provider }}</p>
42
+ </div>
43
+ <div class="p-4 bg-white dark:bg-neutral-900">
44
+ <p class="text-neutral-500 text-xs uppercase dark:text-neutral-400">Region</p>
45
+ <p class="mt-1 font-semibold text-neutral-900 text-sm dark:text-white">{{ plan()?.region }}</p>
46
+ </div>
47
+ <div class="p-4 bg-white dark:bg-neutral-900">
48
+ <p class="text-neutral-500 text-xs uppercase dark:text-neutral-400">Target</p>
49
+ <p class="mt-1 font-semibold text-neutral-900 text-sm dark:text-white">{{ plan()?.target.site || 'All sites' }}</p>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="p-6">
54
+ <div class="flex gap-3 items-start p-4 text-green-800 dark:text-green-200 bg-green-50 dark:bg-green-950/30 border border-green-200 rounded-xl dark:border-green-900">
55
+ <span aria-hidden="true" class="shrink-0 mt-0.5 h-5 w-5 i-hugeicons-checkmark-circle-02"></span>
56
+ <div>
57
+ <p class="font-semibold text-sm">Preview only</p>
58
+ <p class="mt-1 text-sm">No builds, packages, hooks, cloud providers, records, DNS changes, or service restarts were performed.</p>
59
+ </div>
60
+ </div>
61
+
62
+ <div :if="(plan()?.warnings || []).length > 0" class="mt-4 space-y-2">
63
+ <div :for="warning in plan()?.warnings || []" class="flex gap-3 items-start p-4 text-amber-900 dark:text-amber-100 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-xl dark:border-amber-900">
64
+ <span aria-hidden="true" class="shrink-0 mt-0.5 h-5 w-5 i-hugeicons-alert-02"></span>
65
+ <p class="text-sm">{{ warning }}</p>
66
+ </div>
67
+ </div>
68
+
69
+ <section class="mt-6">
70
+ <div class="flex gap-3 items-end justify-between">
71
+ <div>
72
+ <h4 class="font-semibold text-neutral-900 dark:text-white">Planned operations</h4>
73
+ <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Executed in this order during a real deployment.</p>
74
+ </div>
75
+ <span class="font-mono text-neutral-500 text-xs dark:text-neutral-400">{{ plan()?.operations.length || 0 }} steps</span>
76
+ </div>
77
+ <ol class="mt-4 space-y-3">
78
+ <li :for="operation, index in plan()?.operations || []" class="flex gap-4 items-start p-4 bg-neutral-50 dark:bg-neutral-800/70 border border-neutral-200 rounded-xl dark:border-neutral-700">
79
+ <div class="flex shrink-0 items-center justify-center h-9 w-9 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-950/60 rounded-lg">
80
+ <span aria-hidden="true" class="h-5 w-5 {{ icon(operation) }}"></span>
81
+ </div>
82
+ <div class="min-w-0 flex-1">
83
+ <div class="flex gap-2 items-center">
84
+ <span class="font-mono text-neutral-400 text-xs">{{ index + 1 }}</span>
85
+ <h5 class="font-semibold text-neutral-900 text-sm dark:text-white">{{ operation.label }}</h5>
86
+ </div>
87
+ <p class="mt-1 text-neutral-600 text-sm dark:text-neutral-300">{{ operation.detail }}</p>
88
+ <p :if="operation.sites.length > 0" class="mt-2 font-mono text-neutral-500 text-xs dark:text-neutral-400">{{ operation.sites.join(', ') }}</p>
89
+ </div>
90
+ </li>
91
+ </ol>
92
+ </section>
93
+
94
+ <section class="mt-6">
95
+ <h4 class="font-semibold text-neutral-900 dark:text-white">Resolved sites</h4>
96
+ <div class="grid gap-3 md:grid-cols-2 mt-4">
97
+ <article :for="site in plan()?.sites || []" class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-700">
98
+ <div class="flex gap-3 items-center justify-between">
99
+ <h5 class="font-semibold text-neutral-900 text-sm dark:text-white">{{ site.name }}</h5>
100
+ <span class="px-2 py-1 font-mono text-neutral-600 text-[11px] dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded-md">{{ site.kind }}</span>
101
+ </div>
102
+ <p class="mt-2 text-neutral-500 text-xs dark:text-neutral-400">{{ site.domains.join(', ') || 'Private service' }}{{ site.path !== '/' ? site.path : '' }}</p>
103
+ </article>
104
+ </div>
105
+ </section>
106
+ </div>
107
+ </div>
108
+
109
+ <template #footer>
110
+ <div class="flex gap-2 items-center justify-between w-full">
111
+ <p class="hidden sm:block text-neutral-500 text-xs dark:text-neutral-400">Review this plan before continuing.</p>
112
+ <div class="flex gap-2 items-center sm:ml-auto">
113
+ <Button variant="secondary" @click="emit('close')">Close</Button>
114
+ <Button @click="emit('deploy')">Continue to deploy</Button>
115
+ </div>
116
+ </div>
117
+ </template>
118
+ </Modal>
@@ -112,10 +112,6 @@ function handleDialogKeydown(event: KeyboardEvent): void {
112
112
  event.preventDefault()
113
113
  selectCurrent()
114
114
  }
115
- else if (event.key === 'Escape') {
116
- event.preventDefault()
117
- close()
118
- }
119
115
  }
120
116
 
121
117
  function handleGlobalKeydown(event: KeyboardEvent): void {
@@ -123,10 +119,6 @@ function handleGlobalKeydown(event: KeyboardEvent): void {
123
119
  event.preventDefault()
124
120
  isOpen() ? close() : open()
125
121
  }
126
- else if (isOpen() && event.key === 'Escape') {
127
- event.preventDefault()
128
- close()
129
- }
130
122
  }
131
123
 
132
124
  useEventListener('keydown', handleGlobalKeydown)
@@ -136,94 +128,98 @@ onDestroy(() => {
136
128
  })
137
129
  </script>
138
130
 
139
- <template :if="isOpen()">
140
- <div class="fixed inset-0 overflow-y-auto z-50 dashboard-modal-layer" role="dialog" aria-modal="true" aria-label="Search dashboard" @keydown="handleDialogKeydown">
141
- <button type="button" class="fixed inset-0 w-full bg-black/50 backdrop-blur-sm cursor-default" aria-label="Close search" @click="close"></button>
142
-
143
- <div class="relative mt-[15vh] mx-auto max-w-lg">
144
- <div class="overflow-hidden bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-700 shadow-2xl">
145
- <div class="flex items-center px-4 border-b border-neutral-200 dark:border-neutral-700">
146
- <span class="shrink-0 h-5 w-5 text-neutral-400 i-hugeicons-search-02"></span>
147
- <input
148
- ref="globalSearchInput"
149
- x-model="query"
150
- type="search"
151
- placeholder="Search model records"
152
- aria-label="Search model records"
153
- class="px-3 py-4 w-full text-neutral-900 text-sm dark:text-white placeholder:text-neutral-400 bg-transparent border-none focus:outline-none focus:ring-0"
154
- @input="scheduleSearch($event.target.value)"
155
- />
156
- <span :if="isSearching()" class="shrink-0 h-5 w-5 text-blue-500 animate-spin i-hugeicons-loading-03"></span>
157
- <kbd :if="!isSearching()" class="shrink-0 px-1.5 py-0.5 font-medium text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">
158
- ESC
159
- </kbd>
160
- </div>
161
-
162
- <div class="overflow-y-auto max-h-80">
163
- <div :if="error()" role="alert" class="px-4 py-8 text-center">
164
- <span class="mb-2 mx-auto h-8 w-8 text-red-400 i-hugeicons-alert-02"></span>
165
- <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
166
- </div>
131
+ <Modal
132
+ :isOpen="isOpen()"
133
+ ariaLabel="Search dashboard"
134
+ size="md"
135
+ position="top"
136
+ panelClassName="overflow-hidden border border-neutral-200 dark:border-neutral-700"
137
+ contentClassName="p-0"
138
+ :showCloseButton="false"
139
+ @close="close()"
140
+ >
141
+ <div @keydown="handleDialogKeydown">
142
+ <div class="flex items-center px-4 border-b border-neutral-200 dark:border-neutral-700">
143
+ <span class="shrink-0 h-5 w-5 text-neutral-400 i-hugeicons-search-02"></span>
144
+ <input
145
+ ref="globalSearchInput"
146
+ x-model="query"
147
+ type="search"
148
+ autofocus
149
+ placeholder="Search model records"
150
+ aria-label="Search model records"
151
+ class="px-3 py-4 w-full text-neutral-900 text-sm dark:text-white placeholder:text-neutral-400 bg-transparent border-none focus:outline-none focus:ring-0"
152
+ @input="scheduleSearch($event.target.value)"
153
+ />
154
+ <span :if="isSearching()" class="shrink-0 h-5 w-5 text-blue-500 animate-spin i-hugeicons-loading-03"></span>
155
+ <kbd :if="!isSearching()" class="shrink-0 px-1.5 py-0.5 font-medium text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">
156
+ ESC
157
+ </kbd>
158
+ </div>
167
159
 
168
- <div :if="!error() && unavailable().length > 0" role="status" class="mt-3 mx-3 px-3 py-2 text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">
169
- {{ unavailable().length }} searchable {{ unavailable().length === 1 ? 'model is' : 'models are' }} unavailable. Run migrations or fix the reported model configuration.
170
- </div>
160
+ <div class="overflow-y-auto max-h-80">
161
+ <div :if="error()" role="alert" class="px-4 py-8 text-center">
162
+ <span class="mb-2 mx-auto h-8 w-8 text-red-400 i-hugeicons-alert-02"></span>
163
+ <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
164
+ </div>
171
165
 
172
- <template :if="!error() && Object.keys(results()).length > 0">
173
- <template :for="group in resultGroups()">
174
- <div class="pb-1 pt-3 px-3">
175
- <h3 class="px-1 font-semibold text-neutral-400 text-xs tracking-wider uppercase">
176
- {{ group.type }}
177
- </h3>
178
- </div>
179
- <template :for="item in group.items">
180
- <button
181
- type="button"
182
- class="flex gap-3 items-center px-4 py-2.5 w-full text-left transition-colors"
183
- :class="resultIndex(item) === selectedIndex() ? 'bg-blue-50 dark:bg-blue-950/40' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800'"
184
- @mouseenter="selectedIndex.set(resultIndex(item))"
185
- @click="openResult(item)"
186
- >
187
- <span :class="'shrink-0 h-5 w-5 text-neutral-400 ' + item.icon"></span>
188
- <span class="flex-1 min-w-0">
189
- <span class="block font-medium text-neutral-900 text-sm truncate dark:text-white">
190
- {{ item.title }}
191
- </span>
192
- <span :if="item.subtitle" class="block text-neutral-500 text-xs truncate dark:text-neutral-400">
193
- {{ item.subtitle }}
194
- </span>
195
- </span>
196
- <span class="shrink-0 h-4 w-4 text-neutral-300 dark:text-neutral-600 i-hugeicons-arrow-right-01"></span>
197
- </button>
198
- </template>
199
- </template>
200
- </template>
166
+ <div :if="!error() && unavailable().length > 0" role="status" class="mt-3 mx-3 px-3 py-2 text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">
167
+ {{ unavailable().length }} searchable {{ unavailable().length === 1 ? 'model is' : 'models are' }} unavailable. Run migrations or fix the reported model configuration.
168
+ </div>
201
169
 
202
- <div :if="!error() && query().trim() && !isSearching() && Object.keys(results()).length === 0" class="px-4 py-8 text-center">
203
- <span class="mb-2 mx-auto h-8 w-8 text-neutral-300 dark:text-neutral-600 i-hugeicons-search-02"></span>
204
- <p class="text-neutral-500 text-sm dark:text-neutral-400">No results found for "{{ query() }}"</p>
170
+ <template :if="!error() && Object.keys(results()).length > 0">
171
+ <template :for="group in resultGroups()">
172
+ <div class="pb-1 pt-3 px-3">
173
+ <h3 class="px-1 font-semibold text-neutral-400 text-xs tracking-wider uppercase">
174
+ {{ group.type }}
175
+ </h3>
205
176
  </div>
177
+ <template :for="item in group.items">
178
+ <button
179
+ type="button"
180
+ class="flex gap-3 items-center px-4 py-2.5 w-full text-left transition-colors"
181
+ :class="resultIndex(item) === selectedIndex() ? 'bg-blue-50 dark:bg-blue-950/40' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800'"
182
+ @mouseenter="selectedIndex.set(resultIndex(item))"
183
+ @click="openResult(item)"
184
+ >
185
+ <span :class="'shrink-0 h-5 w-5 text-neutral-400 ' + item.icon"></span>
186
+ <span class="flex-1 min-w-0">
187
+ <span class="block font-medium text-neutral-900 text-sm truncate dark:text-white">
188
+ {{ item.title }}
189
+ </span>
190
+ <span :if="item.subtitle" class="block text-neutral-500 text-xs truncate dark:text-neutral-400">
191
+ {{ item.subtitle }}
192
+ </span>
193
+ </span>
194
+ <span class="shrink-0 h-4 w-4 text-neutral-300 dark:text-neutral-600 i-hugeicons-arrow-right-01"></span>
195
+ </button>
196
+ </template>
197
+ </template>
198
+ </template>
206
199
 
207
- <div :if="!error() && !query().trim() && !isSearching()" class="px-4 py-8 text-center">
208
- <p class="text-neutral-400 text-sm">Search fields declared by each model's useSearch trait.</p>
209
- </div>
210
- </div>
211
-
212
- <div class="flex gap-4 items-center px-4 py-2.5 text-neutral-400 text-xs border-neutral-200 border-t dark:border-neutral-700">
213
- <span class="flex gap-1 items-center">
214
- <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">↑↓</kbd>
215
- Navigate
216
- </span>
217
- <span class="flex gap-1 items-center">
218
- <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">↵</kbd>
219
- Open
220
- </span>
221
- <span class="flex gap-1 items-center">
222
- <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">esc</kbd>
223
- Close
224
- </span>
225
- </div>
200
+ <div :if="!error() && query().trim() && !isSearching() && Object.keys(results()).length === 0" class="px-4 py-8 text-center">
201
+ <span class="mb-2 mx-auto h-8 w-8 text-neutral-300 dark:text-neutral-600 i-hugeicons-search-02"></span>
202
+ <p class="text-neutral-500 text-sm dark:text-neutral-400">No results found for "{{ query() }}"</p>
226
203
  </div>
204
+
205
+ <div :if="!error() && !query().trim() && !isSearching()" class="px-4 py-8 text-center">
206
+ <p class="text-neutral-400 text-sm">Search fields declared by each model's useSearch trait.</p>
207
+ </div>
208
+ </div>
209
+
210
+ <div class="flex gap-4 items-center px-4 py-2.5 text-neutral-400 text-xs border-neutral-200 border-t dark:border-neutral-700">
211
+ <span class="flex gap-1 items-center">
212
+ <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">↑↓</kbd>
213
+ Navigate
214
+ </span>
215
+ <span class="flex gap-1 items-center">
216
+ <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">↵</kbd>
217
+ Open
218
+ </span>
219
+ <span class="flex gap-1 items-center">
220
+ <kbd class="px-1 py-0.5 text-[10px] bg-neutral-100 dark:bg-neutral-700 border border-neutral-200 rounded dark:border-neutral-600">esc</kbd>
221
+ Close
222
+ </span>
227
223
  </div>
228
224
  </div>
229
- </template>
225
+ </Modal>