@stacksjs/defaults 0.70.366 → 0.70.368

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 (79) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +49 -1
  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/Dashboard/Deployments/CreateDeployment.ts +64 -44
  7. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  8. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  9. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  10. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  11. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  12. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  13. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  15. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  16. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  17. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  18. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  19. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  20. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  21. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  22. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  23. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  24. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  25. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  26. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  27. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  33. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  34. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  35. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  36. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  37. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  38. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  39. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  40. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  41. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  42. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  43. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  44. package/functions/deployments.ts +18 -0
  45. package/functions/operations.ts +361 -0
  46. package/ide/vscode/package.json +1 -1
  47. package/package.json +1 -1
  48. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  49. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  50. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  51. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  52. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  53. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  54. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  55. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  56. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  57. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  58. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  59. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  60. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  61. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  62. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  63. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  64. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  65. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  66. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  67. package/resources/components/Dashboard/UI/Modal.stx +33 -8
  68. package/resources/functions/dashboard/sidebar.ts +1 -0
  69. package/routes/dashboard-api.ts +23 -0
  70. package/routes/dashboard.ts +1 -0
  71. package/vcs/github/workflows/ci.yml +3 -3
  72. package/vcs/github/workflows/release.yml +1 -1
  73. package/views/dashboard/operations/audit.stx +5 -0
  74. package/views/dashboard/operations/changes.stx +5 -0
  75. package/views/dashboard/operations/incidents.stx +5 -0
  76. package/views/dashboard/operations/migrations.stx +8 -0
  77. package/views/dashboard/operations/recovery.stx +8 -0
  78. package/views/dashboard/operations/scheduler.stx +8 -0
  79. package/views/dashboard/stores/ci.ts +2 -2
@@ -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>
@@ -0,0 +1,16 @@
1
+ <script client>
2
+ import type { AuditOperationsResponse } from '../../../../functions/operations'
3
+ import { fetchAuditOperations } from '../../../../functions/operations'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+ const data = state<AuditOperationsResponse | null>(null)
6
+ const loading = state(true)
7
+ async function load(): Promise<void> { loading.set(true); try { data.set(await fetchAuditOperations()) } catch (error) { pushToast('error', 'Could not load audit log', { detail: String(error) }) } finally { loading.set(false) } }
8
+ function date(value: string): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)) }
9
+ onMount(() => void load())
10
+ </script>
11
+ <div class="space-y-6">
12
+ <PageHeader title="Operations" description="Inspect the append-only operator event stream with actor and operation correlation."><template #actions><Button variant="secondary" :loading="loading()" @click="load"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button></template></PageHeader>
13
+ <OperationsNavigation active="audit" />
14
+ <section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><StatsCard title="Events" :value="String(data()?.summary.events || 0)" icon="i-hugeicons-activity-03" iconBg="primary" :loading="loading()" /><StatsCard title="Operators" :value="String(data()?.summary.operators || 0)" icon="i-hugeicons-user-group" iconBg="neutral" :loading="loading()" /><StatsCard title="Failures" :value="String(data()?.summary.failures || 0)" icon="i-hugeicons-alert-02" iconBg="warning" :loading="loading()" /><StatsCard title="Latest sequence" :value="String(data()?.summary.latestSequence || 0)" icon="i-hugeicons-list-view" iconBg="success" :loading="loading()" /></section>
15
+ <section class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800"><div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-800"><h2 class="font-semibold text-gray-900 dark:text-white">Operator events</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Events are sequence ordered and retained in the ts-cloud control plane.</p></div><div :if="!loading() && !data()?.events.length" class="px-6 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">No operator events have been recorded.</div><div class="max-h-[42rem] overflow-auto divide-y divide-gray-100 dark:divide-neutral-800"><div :for="event in data()?.events || []" class="grid gap-2 px-4 py-3 md:grid-cols-[5rem_1fr_10rem_12rem]"><span class="font-mono text-gray-400 text-xs">#{{ event.sequence }}</span><div><p class="font-medium text-gray-900 text-sm dark:text-white">{{ event.type }}</p><p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ event.operationId || event.id }}</p></div><span class="text-gray-600 text-sm dark:text-neutral-300">{{ event.actorName }}</span><time class="text-gray-500 text-xs dark:text-neutral-400">{{ date(event.createdAt) }}</time></div></div></section>
16
+ </div>
@@ -0,0 +1,66 @@
1
+ <script client>
2
+ import type { ChangeOperationsResponse, ReleaseApprovalItem } from '../../../../functions/operations'
3
+ import { decideRelease, fetchChangeOperations } from '../../../../functions/operations'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+
6
+ const data = state<ChangeOperationsResponse | null>(null)
7
+ const loading = state(true)
8
+ const selected = state<ReleaseApprovalItem | null>(null)
9
+ const comment = state('')
10
+ const busy = state(false)
11
+
12
+ async function load(): Promise<void> {
13
+ loading.set(true)
14
+ try { data.set(await fetchChangeOperations()) }
15
+ catch (error) { pushToast('error', 'Could not load change plan', { detail: String(error) }) }
16
+ finally { loading.set(false) }
17
+ }
18
+
19
+ async function decide(decision: 'approved' | 'rejected'): Promise<void> {
20
+ if (!selected()) return
21
+ busy.set(true)
22
+ try {
23
+ await decideRelease(selected()!.id, decision, comment())
24
+ pushToast('success', decision === 'approved' ? 'Release approved.' : 'Release rejected.')
25
+ selected.set(null)
26
+ await load()
27
+ }
28
+ catch (error) { pushToast('error', 'Approval decision failed', { detail: String(error) }) }
29
+ finally { busy.set(false) }
30
+ }
31
+
32
+ onMount(() => void load())
33
+ </script>
34
+
35
+ <div class="space-y-6">
36
+ <PageHeader title="Operations" description="Review application changes, safety signals, approvals, and active work in one place.">
37
+ <template #actions><Button variant="secondary" :loading="loading()" @click="load"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button></template>
38
+ </PageHeader>
39
+ <OperationsNavigation active="changes" />
40
+ <section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
41
+ <StatsCard title="Schema changes" :value="String(data()?.summary.migrationChanges || 0)" icon="i-hugeicons-database-01" iconBg="primary" :loading="loading()" />
42
+ <StatsCard title="Destructive" :value="String(data()?.summary.destructiveChanges || 0)" icon="i-hugeicons-alert-02" iconBg="warning" :loading="loading()" />
43
+ <StatsCard title="Approvals" :value="String(data()?.summary.approvals || 0)" icon="i-hugeicons-task-done-01" iconBg="neutral" :loading="loading()" />
44
+ <StatsCard title="Active operations" :value="String(data()?.summary.activeOperations || 0)" icon="i-hugeicons-loading-03" iconBg="success" :loading="loading()" />
45
+ </section>
46
+ <section class="grid gap-6 lg:grid-cols-2">
47
+ <div class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800">
48
+ <div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-800"><h2 class="font-semibold text-gray-900 dark:text-white">Change plan</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Current model and runtime changes for {{ data()?.environment?.name || 'this environment' }}.</p></div>
49
+ <div class="divide-y divide-gray-100 dark:divide-neutral-800">
50
+ <div class="flex items-center justify-between px-4 py-3"><span class="text-gray-700 text-sm dark:text-neutral-300">Model-derived schema operations</span><Badge :variant="data()?.summary.destructiveChanges ? 'warning' : 'info'">{{ data()?.summary.migrationChanges || 0 }}</Badge></div>
51
+ <div class="flex items-center justify-between px-4 py-3"><span class="text-gray-700 text-sm dark:text-neutral-300">Migration ledger drift</span><Badge :variant="data()?.migrations.summary.drift ? 'warning' : 'success'">{{ data()?.migrations.summary.drift ? 'Review' : 'Clean' }}</Badge></div>
52
+ <div class="flex items-center justify-between px-4 py-3"><span class="text-gray-700 text-sm dark:text-neutral-300">Running or queued control-plane work</span><Badge variant="default">{{ data()?.activeOperations.length || 0 }}</Badge></div>
53
+ </div>
54
+ </div>
55
+ <div class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800">
56
+ <div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-800"><h2 class="font-semibold text-gray-900 dark:text-white">Release approvals</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Only native releases explicitly marked as approval-required appear here.</p></div>
57
+ <div :if="!loading() && !data()?.pendingReleases.length" class="px-6 py-10 text-center text-gray-500 text-sm dark:text-neutral-400">No releases are awaiting approval.</div>
58
+ <button :for="release in data()?.pendingReleases || []" type="button" class="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-neutral-800" @click="selected.set(release)"><div><p class="font-medium text-gray-900 text-sm dark:text-white">{{ release.sourceSha || release.id }}</p><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ release.kind }} · {{ release.strategy }}</p></div><Badge variant="warning">Review</Badge></button>
59
+ </div>
60
+ </section>
61
+ <OperationHistory :operations="data()?.recentOperations || []" :loading="loading()" />
62
+ <Modal :isOpen="Boolean(selected())" title="Release approval" description="Record an immutable approval decision for this release." size="sm" @close="selected.set(null)">
63
+ <Input v-model:value="comment" label="Comment" placeholder="Reason for this decision" />
64
+ <template #footer><Button variant="secondary" :disabled="busy()" @click="selected.set(null)">Cancel</Button><Button variant="danger" :loading="busy()" @click="decide('rejected')">Reject</Button><Button :loading="busy()" @click="decide('approved')">Approve</Button></template>
65
+ </Modal>
66
+ </div>