@stacksjs/defaults 0.70.366 → 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.
- package/ai/skills/stacks-browse/SKILL.md +9 -1
- package/ai/skills/stacks-dashboard/SKILL.md +49 -1
- package/ai/skills/stacks-deploy/SKILL.md +18 -0
- package/ai/skills/stacks-desktop/SKILL.md +11 -2
- package/ai/skills/stacks-stx/SKILL.md +25 -0
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
- package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
- package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
- package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
- package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
- package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
- package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
- package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
- package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
- package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
- package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
- package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
- package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
- package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
- package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
- package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
- package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
- package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
- package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
- package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
- package/functions/deployments.ts +18 -0
- package/functions/operations.ts +361 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
- package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
- package/resources/components/Dashboard/GlobalSearch.stx +87 -91
- package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
- package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
- package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
- package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
- package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
- package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
- package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
- package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
- package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
- package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
- package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
- package/resources/components/Dashboard/UI/Modal.stx +33 -8
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +23 -0
- package/routes/dashboard.ts +1 -0
- package/views/dashboard/operations/audit.stx +5 -0
- package/views/dashboard/operations/changes.stx +5 -0
- package/views/dashboard/operations/incidents.stx +5 -0
- package/views/dashboard/operations/migrations.stx +8 -0
- package/views/dashboard/operations/recovery.stx +8 -0
- package/views/dashboard/operations/scheduler.stx +8 -0
- 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(
|
|
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:
|
|
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
|
-
|
|
94
|
-
await loadDeployments()
|
|
102
|
+
await loadDeployments()
|
|
95
103
|
}
|
|
96
104
|
catch (error) {
|
|
97
|
-
pushToast('error',
|
|
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="
|
|
209
|
+
<Button :disabled="isStarting() || isPreviewing()" variant="outline" @click="openDeploymentDialog('preview')">
|
|
119
210
|
Preview
|
|
120
211
|
</Button>
|
|
121
|
-
<Button :disabled="isStarting()" @click="
|
|
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
|
-
<
|
|
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
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
</
|
|
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>
|