@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
@@ -0,0 +1,39 @@
1
+ <script client>
2
+ import type { IncidentAlert, IncidentOperationsResponse } from '../../../../functions/operations'
3
+ import { fetchIncidentOperations, updateIncident } from '../../../../functions/operations'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+
6
+ const data = state<IncidentOperationsResponse | null>(null)
7
+ const loading = state(true)
8
+ const busy = state('')
9
+ const selected = state<IncidentAlert | null>(null)
10
+
11
+ async function load(): Promise<void> { loading.set(true); try { data.set(await fetchIncidentOperations()) } catch (error) { pushToast('error', 'Could not load incidents', { detail: String(error) }) } finally { loading.set(false) } }
12
+ async function mutate(action: 'acknowledge' | 'assign_self' | 'unassign' | 'silence'): Promise<void> {
13
+ if (!selected()) return
14
+ busy.set(action)
15
+ try {
16
+ const until = action === 'silence' ? new Date(Date.now() + 60 * 60 * 1000).toISOString() : undefined
17
+ await updateIncident(selected()!.id, { action, until })
18
+ pushToast('success', action === 'silence' ? 'Alert silenced for one hour.' : 'Incident updated.')
19
+ selected.set(null); await load()
20
+ }
21
+ catch (error) { pushToast('error', 'Incident update failed', { detail: String(error) }) }
22
+ finally { busy.set('') }
23
+ }
24
+ function severityVariant(value: string): string { return value === 'critical' ? 'danger' : value === 'warning' ? 'warning' : 'info' }
25
+ onMount(() => void load())
26
+ </script>
27
+
28
+ <div class="space-y-6">
29
+ <PageHeader title="Operations" description="Triage application-wide alerts from native health rules and delivery routes."><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>
30
+ <OperationsNavigation active="incidents" />
31
+ <section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><StatsCard title="Firing" :value="String(data()?.summary.firing || 0)" icon="i-hugeicons-alert-02" iconBg="warning" :loading="loading()" /><StatsCard title="Critical" :value="String(data()?.summary.critical || 0)" icon="i-hugeicons-fire" iconBg="danger" :loading="loading()" /><StatsCard title="Acknowledged" :value="String(data()?.summary.acknowledged || 0)" icon="i-hugeicons-checkmark-circle-02" iconBg="success" :loading="loading()" /><StatsCard title="Active rules" :value="String(data()?.summary.rules || 0)" icon="i-hugeicons-radar-02" iconBg="neutral" :loading="loading()" /></section>
32
+ <section class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800">
33
+ <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">Incidents</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Acknowledgement does not resolve an alert. Resolution follows recovered signal evidence.</p></div>
34
+ <div :if="!loading() && !data()?.alerts.length" class="px-6 py-12 text-center"><span class="mx-auto h-8 w-8 text-green-500 i-hugeicons-checkmark-circle-02"></span><p class="mt-3 font-medium text-gray-900 dark:text-white">No incidents</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">No native alert is pending, firing, resolved, or silenced in this environment.</p></div>
35
+ <button :for="alert in data()?.alerts || []" type="button" class="flex w-full items-start justify-between gap-4 px-4 py-4 text-left border-b border-gray-100 last:border-0 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-800" @click="selected.set(alert)"><div><div class="flex gap-2 items-center"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ alert.title }}</p><Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ alert.state }} · {{ alert.occurrenceCount }} occurrences · {{ alert.ownerName || 'Unassigned' }}</p></div><span class="h-4 w-4 text-gray-400 i-hugeicons-arrow-right-01"></span></button>
36
+ </section>
37
+ <OperationHistory :operations="data()?.operations || []" :loading="loading()" />
38
+ <Modal :isOpen="Boolean(selected())" title="Incident actions" :description="selected()?.title || ''" size="sm" @close="selected.set(null)"><div class="grid gap-2"><Button variant="secondary" :loading="busy() === 'acknowledge'" @click="mutate('acknowledge')">Acknowledge</Button><Button variant="secondary" :loading="busy() === 'assign_self'" @click="mutate('assign_self')">Assign to me</Button><Button variant="secondary" :loading="busy() === 'silence'" @click="mutate('silence')">Silence for one hour</Button></div></Modal>
39
+ </div>
@@ -0,0 +1,146 @@
1
+ <script client>
2
+ import type { MigrationOperationsResponse } from '../../../../functions/operations'
3
+ import { applyMigrationOperations, fetchMigrationOperations, reconcileMigrationLedger } from '../../../../functions/operations'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+
6
+ const data = state<MigrationOperationsResponse | null>(null)
7
+ const loading = state(true)
8
+ const busy = state('')
9
+ const errorMessage = state('')
10
+ const dialog = state<'' | 'apply' | 'reconcile'>('')
11
+ const confirmation = state('')
12
+
13
+ async function load(): Promise<void> {
14
+ loading.set(true)
15
+ errorMessage.set('')
16
+ try {
17
+ data.set(await fetchMigrationOperations())
18
+ }
19
+ catch (error) {
20
+ errorMessage.set('Migration state could not be loaded.')
21
+ pushToast('error', 'Could not load migrations', { detail: String(error) })
22
+ }
23
+ finally {
24
+ loading.set(false)
25
+ }
26
+ }
27
+
28
+ function openDialog(kind: 'apply' | 'reconcile'): void {
29
+ confirmation.set('')
30
+ dialog.set(kind)
31
+ }
32
+
33
+ function closeDialog(): void {
34
+ if (!busy()) dialog.set('')
35
+ }
36
+
37
+ async function confirm(): Promise<void> {
38
+ const current = data()
39
+ const kind = dialog()
40
+ if (!current || !kind) return
41
+ busy.set(kind)
42
+ try {
43
+ if (kind === 'apply') {
44
+ const result = await applyMigrationOperations(current.revision, confirmation())
45
+ pushToast('success', result.message)
46
+ }
47
+ else {
48
+ await reconcileMigrationLedger(current.revision, confirmation())
49
+ pushToast('success', 'Migration ledger reconciled.')
50
+ }
51
+ dialog.set('')
52
+ await load()
53
+ }
54
+ catch (error) {
55
+ pushToast('error', kind === 'apply' ? 'Migration failed' : 'Reconciliation failed', { detail: String(error) })
56
+ }
57
+ finally {
58
+ busy.set('')
59
+ }
60
+ }
61
+
62
+ function statusClass(status: string): string {
63
+ if (status === 'applied' || status === 'stranded') return 'text-green-700 bg-green-50 dark:text-green-300 dark:bg-green-950/30'
64
+ if (status === 'pending') return 'text-blue-700 bg-blue-50 dark:text-blue-300 dark:bg-blue-950/30'
65
+ return 'text-orange-700 bg-orange-50 dark:text-orange-300 dark:bg-orange-950/30'
66
+ }
67
+
68
+ onMount(() => void load())
69
+ </script>
70
+
71
+ <div class="space-y-6">
72
+ <PageHeader title="Operations" description="Review model-derived schema changes and migration ledger health before changing the database.">
73
+ <template #actions>
74
+ <Button variant="secondary" :loading="loading()" @click="load">
75
+ <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
76
+ Refresh
77
+ </Button>
78
+ </template>
79
+ </PageHeader>
80
+ <OperationsNavigation active="migrations" />
81
+
82
+ <div :if="errorMessage()" role="alert" class="px-4 py-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ errorMessage() }}</div>
83
+
84
+ <section aria-label="Migration summary" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
85
+ <StatsCard title="Applied" :value="String(data()?.applied || 0)" icon="i-hugeicons-checkmark-circle-02" iconBg="success" :loading="loading()" />
86
+ <StatsCard title="Pending changes" :value="String(data()?.summary.pending || 0)" icon="i-hugeicons-git-compare" iconBg="primary" :loading="loading()" />
87
+ <StatsCard title="Destructive" :value="String(data()?.summary.destructive || 0)" icon="i-hugeicons-alert-02" iconBg="warning" :loading="loading()" />
88
+ <StatsCard title="Ledger issues" :value="String(data()?.summary.ledgerIssues || 0)" icon="i-hugeicons-database-01" iconBg="neutral" :loading="loading()" />
89
+ </section>
90
+
91
+ <section class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800">
92
+ <div class="flex flex-col gap-3 px-4 py-4 border-b border-gray-200 dark:border-neutral-800 sm:flex-row sm:items-center sm:justify-between">
93
+ <div>
94
+ <h2 class="font-semibold text-gray-900 dark:text-white">Schema plan</h2>
95
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">{{ data()?.dialect || 'database' }} in {{ data()?.environment || 'current environment' }}. The plan revision is rechecked at execution.</p>
96
+ </div>
97
+ <Button :disabled="!data()?.summary.pending" @click="openDialog('apply')">Apply migrations</Button>
98
+ </div>
99
+ <div :if="!loading() && !data()?.operations.length" class="px-6 py-12 text-center">
100
+ <span aria-hidden="true" class="mx-auto h-8 w-8 text-green-500 i-hugeicons-checkmark-circle-02"></span>
101
+ <p class="mt-3 font-medium text-gray-900 dark:text-white">Schema is current</p>
102
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Models and the connected database have no pending structural changes.</p>
103
+ </div>
104
+ <div :if="data()?.operations.length" class="divide-y divide-gray-100 dark:divide-neutral-800">
105
+ <div :for="operation in data()?.operations || []" class="flex items-start justify-between gap-4 px-4 py-3">
106
+ <div>
107
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ operation.kind.replaceAll('_', ' ') }}</p>
108
+ <p class="mt-0.5 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ operation.table }}{{ operation.column ? '.' + operation.column : '' }}</p>
109
+ </div>
110
+ <Badge :variant="operation.destructive ? 'danger' : 'info'">{{ operation.destructive ? 'Destructive' : 'Safe' }}</Badge>
111
+ </div>
112
+ </div>
113
+ </section>
114
+
115
+ <section class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800">
116
+ <div class="flex items-center justify-between gap-3 px-4 py-4 border-b border-gray-200 dark:border-neutral-800">
117
+ <div>
118
+ <h2 class="font-semibold text-gray-900 dark:text-white">Migration ledger</h2>
119
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Compared with both migration files and live schema effects.</p>
120
+ </div>
121
+ <Button variant="secondary" :disabled="!data()?.summary.drift" @click="openDialog('reconcile')">Reconcile provable drift</Button>
122
+ </div>
123
+ <div class="max-h-96 overflow-auto divide-y divide-gray-100 dark:divide-neutral-800">
124
+ <div :for="entry in data()?.ledger.entries || []" class="flex items-center justify-between gap-4 px-4 py-3">
125
+ <span class="min-w-0 truncate font-mono text-gray-700 text-xs dark:text-neutral-300">{{ entry.file }}</span>
126
+ <span :class="'shrink-0 px-2 py-1 font-medium text-xs rounded-md ' + statusClass(entry.status)">{{ entry.status }}</span>
127
+ </div>
128
+ </div>
129
+ </section>
130
+
131
+ <OperationHistory :operations="data()?.operatorOperations || []" :loading="loading()" />
132
+
133
+ <Modal :isOpen="dialog() !== ''" :title="dialog() === 'apply' ? 'Apply migration plan' : 'Reconcile migration ledger'" size="md" @close="closeDialog">
134
+ <div class="space-y-4">
135
+ <div :if="dialog() === 'apply' && data()?.summary.destructive" role="alert" class="px-4 py-3 text-orange-800 text-sm dark:text-orange-200 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 rounded-lg dark:border-orange-900">
136
+ This plan contains {{ data()?.summary.destructive }} destructive operation{{ data()?.summary.destructive === 1 ? '' : 's' }}. Confirm that a verified recovery point exists before continuing.
137
+ </div>
138
+ <p class="text-gray-600 text-sm dark:text-neutral-300">Type <strong>{{ dialog() === 'apply' ? 'migrate ' + data()?.environment : 'reconcile ' + data()?.environment }}</strong> to confirm.</p>
139
+ <Input v-model:value="confirmation" autocomplete="off" placeholder="Typed confirmation" />
140
+ </div>
141
+ <template #footer>
142
+ <Button variant="secondary" :disabled="busy() !== ''" @click="closeDialog">Cancel</Button>
143
+ <Button :variant="dialog() === 'apply' && data()?.summary.destructive ? 'danger' : 'primary'" :loading="busy() !== ''" @click="confirm">{{ dialog() === 'apply' ? 'Apply migrations' : 'Reconcile ledger' }}</Button>
144
+ </template>
145
+ </Modal>
146
+ </div>
@@ -0,0 +1,61 @@
1
+ <script client>
2
+ import type { OperatorOperation } from '../../../../functions/operations'
3
+
4
+ const operations = useReactiveProp('operations', [])
5
+ const loading = useReactiveProp('loading', false)
6
+ const subtitle = useReactiveProp('subtitle', 'Every operator mutation is recorded in the append-only control plane.')
7
+ const emptyTitle = useReactiveProp('emptyTitle', 'No operator operations recorded')
8
+ const emptyDescription = useReactiveProp('emptyDescription', 'A completed or attempted operator action will create the first audit entry.')
9
+
10
+ function rows(): OperatorOperation[] {
11
+ return operations() as OperatorOperation[]
12
+ }
13
+
14
+ function formatDate(value?: string): string {
15
+ if (!value)
16
+ return 'Not recorded'
17
+ const date = new Date(value)
18
+ return Number.isNaN(date.getTime())
19
+ ? value
20
+ : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
21
+ }
22
+
23
+ function actionLabel(operation: OperatorOperation): string {
24
+ const action = operation.kind.split('.').at(-1) || operation.kind
25
+ const target = typeof operation.input.task === 'string'
26
+ ? operation.input.task
27
+ : typeof operation.input.alertId === 'string'
28
+ ? operation.input.alertId
29
+ : ''
30
+ return `${action.charAt(0).toUpperCase()}${action.slice(1)}${target ? ` ${target}` : ''}`
31
+ }
32
+
33
+ function statusVariant(state: OperatorOperation['state']): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
34
+ if (state === 'succeeded') return 'success'
35
+ if (state === 'failed' || state === 'timed_out') return 'danger'
36
+ if (state === 'running') return 'primary'
37
+ if (state === 'cancelled') return 'warning'
38
+ return 'default'
39
+ }
40
+ </script>
41
+
42
+ <Card title="Operator history" :subtitle="subtitle()" padding="none">
43
+ <div :if="loading()" class="px-5 py-10 text-center text-gray-500 text-sm dark:text-neutral-400">Loading operation history</div>
44
+ <div :if="!loading() && rows().length === 0" class="px-5 py-10 text-center">
45
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ emptyTitle() }}</p>
46
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">{{ emptyDescription() }}</p>
47
+ </div>
48
+ <ul :if="rows().length > 0" class="divide-gray-200 divide-y dark:divide-neutral-700">
49
+ <li :for="operation in rows()" class="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
50
+ <div class="min-w-0">
51
+ <div class="flex flex-wrap gap-2 items-center">
52
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ actionLabel(operation) }}</p>
53
+ <Badge :variant="statusVariant(operation.state)">{{ operation.state }}</Badge>
54
+ </div>
55
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ operation.actorName }} at {{ formatDate(operation.finishedAt || operation.createdAt) }}</p>
56
+ <p :if="operation.error" class="mt-1 text-red-600 text-xs dark:text-red-400">{{ operation.error }}</p>
57
+ </div>
58
+ <code class="font-mono text-gray-400 text-[11px] dark:text-neutral-500">{{ operation.id }}</code>
59
+ </li>
60
+ </ul>
61
+ </Card>
@@ -0,0 +1,32 @@
1
+ <script client>
2
+ const active = useReactiveProp('active', 'scheduler')
3
+
4
+ function linkClass(name: string): string {
5
+ return active() === name
6
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-neutral-700 dark:text-white'
7
+ : 'text-gray-600 hover:text-gray-900 dark:text-neutral-400 dark:hover:text-white'
8
+ }
9
+ </script>
10
+
11
+ <nav aria-label="Operations sections" class="overflow-x-auto">
12
+ <div class="inline-flex gap-1 p-1 min-w-max bg-gray-100 dark:bg-neutral-900 rounded-lg">
13
+ <StxLink to="/operations/changes" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('changes')">
14
+ Changes
15
+ </StxLink>
16
+ <StxLink to="/operations/scheduler" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('scheduler')">
17
+ Scheduler
18
+ </StxLink>
19
+ <StxLink to="/operations/recovery" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('recovery')">
20
+ Recovery
21
+ </StxLink>
22
+ <StxLink to="/operations/migrations" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('migrations')">
23
+ Migrations
24
+ </StxLink>
25
+ <StxLink to="/operations/incidents" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('incidents')">
26
+ Incidents
27
+ </StxLink>
28
+ <StxLink to="/operations/audit" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('audit')">
29
+ Audit
30
+ </StxLink>
31
+ </div>
32
+ </nav>
@@ -0,0 +1,229 @@
1
+ <script client>
2
+ import type {
3
+ RecoveryDestinationInput,
4
+ RecoveryOperationsResponse,
5
+ RecoveryPoint,
6
+ RecoveryPolicyInput,
7
+ RestorePlanInput,
8
+ } from '../../../../functions/operations'
9
+ import {
10
+ createRecoveryDestination,
11
+ createRecoveryPolicy,
12
+ fetchRecoveryOperations,
13
+ planRecoveryRestore,
14
+ protectRecoveryPoint,
15
+ runRecoveryPolicy,
16
+ runRecoveryRetention,
17
+ testRecoveryDestination,
18
+ verifyRecoveryPoint,
19
+ } from '../../../../functions/operations'
20
+ import { pushToast } from '../../../../functions/toasts'
21
+
22
+ const data = state<RecoveryOperationsResponse | null>(null)
23
+ const isLoading = state(true)
24
+ const loadError = state('')
25
+ const busyId = state('')
26
+ const showDestination = state(false)
27
+ const showPolicy = state(false)
28
+ const destinationBusy = state(false)
29
+ const policyBusy = state(false)
30
+ const restorePoint = state<RecoveryPoint | null>(null)
31
+ const restorePlan = state<{ mode: string, target: Record<string, unknown>, warnings: string[] } | null>(null)
32
+ const restoreBusy = state(false)
33
+
34
+ function summary(): RecoveryOperationsResponse['summary'] {
35
+ return data()?.summary || { policies: 0, protected: 0, missedRpo: 0, unverified: 0, queuedJobs: 0, latestPoint: null }
36
+ }
37
+
38
+ function formatDate(value: string | null): string {
39
+ if (!value) return 'No recovery point'
40
+ const date = new Date(value)
41
+ return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
42
+ }
43
+
44
+ async function loadRecovery(): Promise<void> {
45
+ isLoading.set(true)
46
+ loadError.set('')
47
+ try {
48
+ data.set(await fetchRecoveryOperations())
49
+ }
50
+ catch (error) {
51
+ loadError.set('Recovery operations could not be loaded.')
52
+ pushToast('error', 'Could not load recovery operations', { detail: String(error) })
53
+ }
54
+ finally {
55
+ isLoading.set(false)
56
+ }
57
+ }
58
+
59
+ async function saveDestination(input: RecoveryDestinationInput | CustomEvent<RecoveryDestinationInput>): Promise<void> {
60
+ destinationBusy.set(true)
61
+ try {
62
+ await createRecoveryDestination(input instanceof CustomEvent ? input.detail : input)
63
+ showDestination.set(false)
64
+ pushToast('success', 'Recovery destination connected')
65
+ await loadRecovery()
66
+ }
67
+ catch (error) {
68
+ pushToast('error', 'Could not connect destination', { detail: String(error) })
69
+ }
70
+ finally { destinationBusy.set(false) }
71
+ }
72
+
73
+ async function savePolicy(input: RecoveryPolicyInput | CustomEvent<RecoveryPolicyInput>): Promise<void> {
74
+ policyBusy.set(true)
75
+ try {
76
+ await createRecoveryPolicy(input instanceof CustomEvent ? input.detail : input)
77
+ showPolicy.set(false)
78
+ pushToast('success', 'Recovery policy created')
79
+ await loadRecovery()
80
+ }
81
+ catch (error) {
82
+ pushToast('error', 'Could not create policy', { detail: String(error) })
83
+ }
84
+ finally { policyBusy.set(false) }
85
+ }
86
+
87
+ async function testDestination(id: string): Promise<void> {
88
+ busyId.set(`${id}:test`)
89
+ try {
90
+ const result = await testRecoveryDestination(id)
91
+ pushToast('success', result.message)
92
+ await loadRecovery()
93
+ }
94
+ catch (error) { pushToast('error', 'Destination test failed', { detail: String(error) }) }
95
+ finally { busyId.set('') }
96
+ }
97
+
98
+ async function runPolicy(id: string): Promise<void> {
99
+ busyId.set(`${id}:run`)
100
+ try {
101
+ const result = await runRecoveryPolicy(id)
102
+ pushToast('success', result.message)
103
+ await loadRecovery()
104
+ }
105
+ catch (error) { pushToast('error', 'Backup could not be queued', { detail: String(error) }) }
106
+ finally { busyId.set('') }
107
+ }
108
+
109
+ async function verifyPoint(id: string | CustomEvent<string>): Promise<void> {
110
+ const pointId = typeof id === 'string' ? id : id.detail
111
+ busyId.set(`${pointId}:verify`)
112
+ try {
113
+ const result = await verifyRecoveryPoint(pointId)
114
+ pushToast('success', result.message)
115
+ await loadRecovery()
116
+ }
117
+ catch (error) { pushToast('error', 'Verification could not be queued', { detail: String(error) }) }
118
+ finally { busyId.set('') }
119
+ }
120
+
121
+ function openRestore(point: RecoveryPoint | CustomEvent<RecoveryPoint>): void {
122
+ restorePlan.set(null)
123
+ restorePoint.set(point instanceof CustomEvent ? point.detail : point)
124
+ }
125
+
126
+ async function restore(input: RestorePlanInput | CustomEvent<RestorePlanInput>): Promise<void> {
127
+ const value = input instanceof CustomEvent ? input.detail : input
128
+ const point = restorePoint()
129
+ if (!point) return
130
+ restoreBusy.set(true)
131
+ try {
132
+ const result = await planRecoveryRestore(point.id, value)
133
+ restorePlan.set(result.plan)
134
+ if (result.productionExecutionCreated) {
135
+ pushToast('success', value.drill ? 'Recovery drill queued' : 'Restore queued')
136
+ restorePoint.set(null)
137
+ restorePlan.set(null)
138
+ await loadRecovery()
139
+ }
140
+ }
141
+ catch (error) { pushToast('error', value.execute ? 'Restore could not be queued' : 'Restore plan is not safe yet', { detail: String(error) }) }
142
+ finally { restoreBusy.set(false) }
143
+ }
144
+
145
+ async function protect(detail: { id: string, pinned: boolean } | CustomEvent<{ id: string, pinned: boolean }>): Promise<void> {
146
+ const value = detail instanceof CustomEvent ? detail.detail : detail
147
+ busyId.set(`${value.id}:protect`)
148
+ try {
149
+ await protectRecoveryPoint(value.id, { pinned: value.pinned })
150
+ pushToast('success', value.pinned ? 'Recovery point pinned' : 'Recovery point unpinned')
151
+ await loadRecovery()
152
+ }
153
+ catch (error) { pushToast('error', 'Protection could not be updated', { detail: String(error) }) }
154
+ finally { busyId.set('') }
155
+ }
156
+
157
+ async function retention(): Promise<void> {
158
+ busyId.set('retention')
159
+ try {
160
+ const result = await runRecoveryRetention()
161
+ pushToast('success', result.message)
162
+ await loadRecovery()
163
+ }
164
+ catch (error) { pushToast('error', 'Retention could not be queued', { detail: String(error) }) }
165
+ finally { busyId.set('') }
166
+ }
167
+
168
+ onMount(() => { void loadRecovery() })
169
+ </script>
170
+
171
+ <div class="space-y-6">
172
+ <PageHeader title="Operations" description="Backup coverage, independent verification, restore planning, and recovery drills from the native control plane.">
173
+ <template #actions>
174
+ <Button variant="outline" :disabled="isLoading()" @click="showDestination.set(true)">Connect destination</Button>
175
+ <Button :disabled="isLoading() || (data()?.destinations.length || 0) === 0" @click="showPolicy.set(true)">Create policy</Button>
176
+ <Button variant="secondary" :loading="isLoading()" @click="loadRecovery"><span :if="!isLoading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
177
+ </template>
178
+ </PageHeader>
179
+ <OperationsNavigation active="recovery" />
180
+
181
+ <div :if="loadError()" role="alert" class="flex flex-col gap-3 px-4 py-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900 sm:flex-row sm:items-center sm:justify-between"><span>{{ loadError() }}</span><Button size="sm" variant="secondary" @click="loadRecovery">Try again</Button></div>
182
+
183
+ <div :if="!isLoading() && (data()?.destinations.length || 0) === 0" class="flex flex-col gap-4 p-5 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 sm:flex-row sm:items-center sm:justify-between">
184
+ <div><p class="font-semibold">No recovery destination is connected</p><p class="mt-1 text-sm">Nothing is protected yet. Connect object storage or AWS Backup, then run its health test before creating policies.</p></div>
185
+ <Button @click="showDestination.set(true)">Connect destination</Button>
186
+ </div>
187
+
188
+ <section aria-label="Recovery summary" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
189
+ <StatsCard title="Policies" :value="String(summary().policies)" icon="i-hugeicons-shield-02" iconBg="primary" :loading="isLoading()" />
190
+ <StatsCard title="Protected" :value="String(summary().protected)" icon="i-hugeicons-checkmark-badge-01" iconBg="success" :loading="isLoading()" />
191
+ <StatsCard title="Missed RPO" :value="String(summary().missedRpo)" icon="i-hugeicons-alert-02" iconBg="danger" :loading="isLoading()" />
192
+ <StatsCard title="Unverified" :value="String(summary().unverified)" icon="i-hugeicons-search-02" iconBg="warning" :loading="isLoading()" />
193
+ <StatsCard title="Latest point" :value="formatDate(summary().latestPoint)" icon="i-hugeicons-clock-01" iconBg="neutral" :loading="isLoading()" />
194
+ </section>
195
+
196
+ <section>
197
+ <div class="flex flex-col gap-3 mb-3 sm:flex-row sm:items-end sm:justify-between">
198
+ <div><h2 class="font-semibold text-gray-900 dark:text-white">Destinations and policies</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Coverage is healthy only when a destination is healthy and the latest point meets its RPO.</p></div>
199
+ <Button size="sm" variant="outline" :loading="busyId() === 'retention'" :disabled="busyId() !== ''" @click="retention">Run retention</Button>
200
+ </div>
201
+ <div class="grid gap-4 lg:grid-cols-2">
202
+ <Card title="Destinations" subtitle="Credential material is encrypted and never returned to this page." padding="none">
203
+ <div :if="(data()?.destinations.length || 0) === 0" class="px-5 py-10 text-center text-gray-500 text-sm dark:text-neutral-400">No destinations connected</div>
204
+ <ul :if="(data()?.destinations.length || 0) > 0" class="divide-gray-200 divide-y dark:divide-neutral-700">
205
+ <li :for="destination in data()?.destinations || []" class="flex gap-4 items-center justify-between px-5 py-4">
206
+ <div><div class="flex gap-2 items-center"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ destination.name }}</p><Badge :variant="destination.status === 'healthy' ? 'success' : destination.status === 'failing' ? 'danger' : 'warning'" dot>{{ destination.status }}</Badge></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ destination.provider }} · {{ destination.bucket || 'Provider vault' }} · {{ destination.region || 'Default region' }}</p></div>
207
+ <Button size="sm" variant="outline" :loading="busyId() === destination.id + ':test'" :disabled="busyId() !== '' || destination.provider === 'aws_backup'" @click="testDestination(destination.id)">Test</Button>
208
+ </li>
209
+ </ul>
210
+ </Card>
211
+ <Card title="Policies" :subtitle="data()?.environment ? 'Environment: ' + data()?.environment.name : 'Current environment'" padding="none">
212
+ <div :if="(data()?.policies.length || 0) === 0" class="px-5 py-10 text-center text-gray-500 text-sm dark:text-neutral-400">No recovery policies configured</div>
213
+ <ul :if="(data()?.policies.length || 0) > 0" class="divide-gray-200 divide-y dark:divide-neutral-700">
214
+ <li :for="policy in data()?.policies || []" class="flex gap-4 items-center justify-between px-5 py-4">
215
+ <div><div class="flex gap-2 items-center"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ policy.name }}</p><Badge :variant="policy.enabled ? 'success' : 'default'">{{ policy.enabled ? 'Enabled' : 'Disabled' }}</Badge></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ policy.resourceKind }} · {{ policy.schedule }} · RPO {{ policy.expectedRpoMinutes }}m · RTO {{ policy.expectedRtoMinutes }}m</p></div>
216
+ <Button size="sm" :loading="busyId() === policy.id + ':run'" :disabled="busyId() !== '' || !policy.enabled" @click="runPolicy(policy.id)">Back up now</Button>
217
+ </li>
218
+ </ul>
219
+ </Card>
220
+ </div>
221
+ </section>
222
+
223
+ <section aria-labelledby="recovery-points-heading"><div class="mb-3"><h2 id="recovery-points-heading" class="font-semibold text-gray-900 dark:text-white">Recovery points</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Verify independently, pin important points, or preview isolated and in-place restores.</p></div><RecoveryPointTable :points="data()?.recoveryPoints || []" :policies="data()?.policies || []" :busyId="busyId" @verify="verifyPoint" @restore="openRestore" @protect="protect" /></section>
224
+ <RecoveryJobList :jobs="data()?.jobs || []" />
225
+
226
+ <RecoveryDestinationDialog :isOpen="showDestination()" :busy="destinationBusy()" @close="showDestination.set(false)" @save="saveDestination" />
227
+ <RecoveryPolicyDialog :isOpen="showPolicy()" :busy="policyBusy()" :destinations="data()?.destinations || []" :resources="data()?.resources || []" :dataServices="data()?.dataServices || []" @close="showPolicy.set(false)" @save="savePolicy" />
228
+ <RecoveryRestoreDialog :isOpen="restorePoint() !== null" :point="restorePoint()" :points="data()?.recoveryPoints || []" :busy="restoreBusy()" :plan="restorePlan()" @close="restorePoint.set(null); restorePlan.set(null)" @preview="restore" @execute="restore" />
229
+ </div>