@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.
- 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/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- 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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { RecoveryDestinationInput } from '../../../../functions/operations'
|
|
3
|
+
|
|
4
|
+
const open = useReactiveProp('isOpen', false)
|
|
5
|
+
const busy = useReactiveProp('busy', false)
|
|
6
|
+
const emit = defineEmits()
|
|
7
|
+
|
|
8
|
+
const name = state('')
|
|
9
|
+
const provider = state<RecoveryDestinationInput['provider']>('aws_s3')
|
|
10
|
+
const bucket = state('')
|
|
11
|
+
const endpoint = state('')
|
|
12
|
+
const prefix = state('stacks')
|
|
13
|
+
const region = state('')
|
|
14
|
+
const accessKeyId = state('')
|
|
15
|
+
const secretAccessKey = state('')
|
|
16
|
+
const sessionToken = state('')
|
|
17
|
+
const encryption = state<RecoveryDestinationInput['encryption']>('provider')
|
|
18
|
+
const encryptionKey = state('')
|
|
19
|
+
const lockDays = state(0)
|
|
20
|
+
const allowPrivate = state(false)
|
|
21
|
+
const forcePathStyle = state(false)
|
|
22
|
+
|
|
23
|
+
function close(): void {
|
|
24
|
+
if (!busy())
|
|
25
|
+
emit('close')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function submit(): void {
|
|
29
|
+
emit('save', {
|
|
30
|
+
name: name().trim(),
|
|
31
|
+
provider: provider(),
|
|
32
|
+
bucket: bucket().trim(),
|
|
33
|
+
endpoint: endpoint().trim(),
|
|
34
|
+
prefix: prefix().trim(),
|
|
35
|
+
region: region().trim(),
|
|
36
|
+
allowPrivate: allowPrivate(),
|
|
37
|
+
forcePathStyle: forcePathStyle(),
|
|
38
|
+
encryption: encryption(),
|
|
39
|
+
encryptionKey: encryptionKey(),
|
|
40
|
+
lockDays: lockDays(),
|
|
41
|
+
credentials: {
|
|
42
|
+
accessKeyId: accessKeyId().trim(),
|
|
43
|
+
secretAccessKey: secretAccessKey(),
|
|
44
|
+
sessionToken: sessionToken(),
|
|
45
|
+
},
|
|
46
|
+
} satisfies RecoveryDestinationInput)
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<Modal :isOpen="open()" title="Connect recovery destination" description="Backups are only protected after the destination passes a real write, read, checksum, and cleanup test." size="lg" :closable="!busy()" @close="close">
|
|
51
|
+
<form id="recovery-destination-form" class="space-y-4" @submit.prevent="submit">
|
|
52
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
53
|
+
<div>
|
|
54
|
+
<label for="recovery-destination-name" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Name</label>
|
|
55
|
+
<input id="recovery-destination-name" x-model.trim="name" required placeholder="Production backups" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
56
|
+
</div>
|
|
57
|
+
<div>
|
|
58
|
+
<label for="recovery-destination-provider" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Provider</label>
|
|
59
|
+
<select id="recovery-destination-provider" x-model="provider" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
60
|
+
<option value="aws_s3">AWS S3</option>
|
|
61
|
+
<option value="s3_compatible">S3 compatible</option>
|
|
62
|
+
<option value="aws_backup">AWS Backup</option>
|
|
63
|
+
</select>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div :if="provider() !== 'aws_backup'" class="grid gap-4 sm:grid-cols-2">
|
|
68
|
+
<div>
|
|
69
|
+
<label for="recovery-destination-bucket" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Bucket</label>
|
|
70
|
+
<input id="recovery-destination-bucket" x-model.trim="bucket" required placeholder="stacks-production-backups" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<label for="recovery-destination-region" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Region</label>
|
|
74
|
+
<input id="recovery-destination-region" x-model.trim="region" placeholder="us-east-1" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label for="recovery-destination-prefix" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Object prefix</label>
|
|
78
|
+
<input id="recovery-destination-prefix" x-model.trim="prefix" placeholder="stacks" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
79
|
+
</div>
|
|
80
|
+
<div :if="provider() === 's3_compatible'">
|
|
81
|
+
<label for="recovery-destination-endpoint" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">HTTPS endpoint</label>
|
|
82
|
+
<input id="recovery-destination-endpoint" x-model.trim="endpoint" type="url" placeholder="https://objects.example.com" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<details :if="provider() !== 'aws_backup'" class="p-4 bg-gray-50 dark:bg-neutral-900/60 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
87
|
+
<summary class="font-medium text-gray-900 text-sm cursor-pointer dark:text-white">Static credentials, optional</summary>
|
|
88
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave blank to use the runtime's AWS credential chain. Submitted credentials are encrypted in the control plane.</p>
|
|
89
|
+
<div class="grid gap-4 mt-4 sm:grid-cols-2">
|
|
90
|
+
<input x-model.trim="accessKeyId" autocomplete="off" placeholder="Access key id" aria-label="Access key id" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
91
|
+
<input x-model="secretAccessKey" type="password" autocomplete="new-password" placeholder="Secret access key" aria-label="Secret access key" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
92
|
+
<input x-model="sessionToken" type="password" autocomplete="new-password" placeholder="Session token, optional" aria-label="Session token" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700 sm:col-span-2" />
|
|
93
|
+
</div>
|
|
94
|
+
</details>
|
|
95
|
+
|
|
96
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
97
|
+
<div>
|
|
98
|
+
<label for="recovery-destination-encryption" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Encryption</label>
|
|
99
|
+
<select id="recovery-destination-encryption" x-model="encryption" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
100
|
+
<option value="provider">Provider managed</option>
|
|
101
|
+
<option value="client_side">Client side</option>
|
|
102
|
+
<option value="both">Both</option>
|
|
103
|
+
</select>
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<label for="recovery-destination-lock" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Immutable retention days</label>
|
|
107
|
+
<input id="recovery-destination-lock" x-model.number="lockDays" type="number" min="0" max="36500" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div :if="encryption() !== 'provider'">
|
|
111
|
+
<label for="recovery-destination-key" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Client encryption key</label>
|
|
112
|
+
<input id="recovery-destination-key" x-model="encryptionKey" type="password" minlength="32" autocomplete="new-password" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
113
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">At least 32 characters. Keep an independent recovery copy outside this control plane.</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div :if="provider() === 's3_compatible'" class="flex flex-wrap gap-4">
|
|
116
|
+
<label class="inline-flex gap-2 items-center text-gray-700 text-sm dark:text-neutral-200"><input x-model="forcePathStyle" type="checkbox" /> Force path-style URLs</label>
|
|
117
|
+
<label class="inline-flex gap-2 items-center text-gray-700 text-sm dark:text-neutral-200"><input x-model="allowPrivate" type="checkbox" /> Allow private endpoint</label>
|
|
118
|
+
</div>
|
|
119
|
+
</form>
|
|
120
|
+
<template #footer>
|
|
121
|
+
<div class="flex gap-2 justify-end w-full">
|
|
122
|
+
<Button variant="secondary" :disabled="busy()" @click="close">Cancel</Button>
|
|
123
|
+
<Button type="submit" form="recovery-destination-form" :loading="busy()">Connect destination</Button>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
</Modal>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { RecoveryJob } from '../../../../functions/operations'
|
|
3
|
+
|
|
4
|
+
const jobItems = useReactiveProp('jobs', [])
|
|
5
|
+
function jobs(): RecoveryJob[] { return jobItems() as RecoveryJob[] }
|
|
6
|
+
function formatDate(value?: string): string {
|
|
7
|
+
if (!value) return 'Not recorded'
|
|
8
|
+
const date = new Date(value)
|
|
9
|
+
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
10
|
+
}
|
|
11
|
+
function variant(status: RecoveryJob['status']): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
|
|
12
|
+
if (status === 'succeeded') return 'success'
|
|
13
|
+
if (status === 'failed' || status === 'cleanup_required') return 'danger'
|
|
14
|
+
if (status === 'running') return 'primary'
|
|
15
|
+
if (status === 'cancelled') return 'warning'
|
|
16
|
+
return 'default'
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<Card title="Recovery jobs" subtitle="Durable backup, verification, restore, drill, and retention work." padding="none">
|
|
21
|
+
<div :if="jobs().length === 0" class="px-5 py-10 text-center text-gray-500 text-sm dark:text-neutral-400">No recovery jobs queued</div>
|
|
22
|
+
<ul :if="jobs().length > 0" class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
23
|
+
<li :for="job in jobs().slice(0, 12)" class="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
24
|
+
<div>
|
|
25
|
+
<div class="flex gap-2 items-center"><p class="font-medium text-gray-900 text-sm capitalize dark:text-white">{{ job.kind }}</p><Badge :variant="variant(job.status)">{{ job.status }}</Badge></div>
|
|
26
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ job.progress.phase || 'queued' }} · {{ formatDate(job.finishedAt || job.startedAt || job.createdAt) }}</p>
|
|
27
|
+
<p :if="job.error" class="mt-1 text-red-600 text-xs dark:text-red-400">{{ job.error }}</p>
|
|
28
|
+
</div>
|
|
29
|
+
<code class="font-mono text-gray-400 text-[11px] dark:text-neutral-500">{{ job.id }}</code>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</Card>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { RecoveryPoint, RecoveryPolicy } from '../../../../functions/operations'
|
|
3
|
+
|
|
4
|
+
const pointItems = useReactiveProp('points', [])
|
|
5
|
+
const policyItems = useReactiveProp('policies', [])
|
|
6
|
+
const busyId = useReactiveProp('busyId', '')
|
|
7
|
+
const emit = defineEmits()
|
|
8
|
+
|
|
9
|
+
function points(): RecoveryPoint[] { return pointItems() as RecoveryPoint[] }
|
|
10
|
+
function policies(): RecoveryPolicy[] { return policyItems() as RecoveryPolicy[] }
|
|
11
|
+
|
|
12
|
+
function policyName(id?: string): string {
|
|
13
|
+
return policies().find(policy => policy.id === id)?.name || 'Manual recovery point'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDate(value?: string): string {
|
|
17
|
+
if (!value) return 'Not recorded'
|
|
18
|
+
const date = new Date(value)
|
|
19
|
+
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatBytes(value: number): string {
|
|
23
|
+
if (!value) return 'Provider managed'
|
|
24
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
25
|
+
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
|
26
|
+
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function verificationVariant(state: RecoveryPoint['verificationState']): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
|
|
30
|
+
if (state === 'verified') return 'success'
|
|
31
|
+
if (state === 'corrupt' || state === 'failed') return 'danger'
|
|
32
|
+
if (state === 'verifying') return 'primary'
|
|
33
|
+
return 'warning'
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
|
|
38
|
+
<div :if="points().length === 0" class="px-5 py-14 text-center">
|
|
39
|
+
<span aria-hidden="true" class="block mb-3 mx-auto h-9 w-9 text-gray-400 i-hugeicons-database-restore"></span>
|
|
40
|
+
<p class="font-semibold text-gray-900 dark:text-white">No recovery points yet</p>
|
|
41
|
+
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create and run a recovery policy to produce the first independently verifiable point.</p>
|
|
42
|
+
</div>
|
|
43
|
+
<div :if="points().length > 0" class="overflow-x-auto">
|
|
44
|
+
<table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
|
|
45
|
+
<thead class="bg-gray-50 dark:bg-neutral-700/50">
|
|
46
|
+
<tr>
|
|
47
|
+
<th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Recovery point</th>
|
|
48
|
+
<th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Created</th>
|
|
49
|
+
<th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Verification</th>
|
|
50
|
+
<th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Protection</th>
|
|
51
|
+
<th class="px-4 py-3"><span class="sr-only">Actions</span></th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
55
|
+
<template :for="point in points()">
|
|
56
|
+
<tr>
|
|
57
|
+
<td class="px-4 py-3">
|
|
58
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ policyName(point.policyId) }}</p>
|
|
59
|
+
<p class="mt-0.5 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ point.id }} · {{ point.kind }} · {{ formatBytes(point.sizeBytes) }}</p>
|
|
60
|
+
</td>
|
|
61
|
+
<td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(point.pointInTime) }}</td>
|
|
62
|
+
<td class="px-4 py-3"><Badge :variant="verificationVariant(point.verificationState)" dot>{{ point.verificationState }}</Badge></td>
|
|
63
|
+
<td class="px-4 py-3">
|
|
64
|
+
<div class="flex flex-wrap gap-1.5">
|
|
65
|
+
<Badge :if="point.pinned" variant="primary">Pinned</Badge>
|
|
66
|
+
<Badge :if="point.held" variant="warning">Held</Badge>
|
|
67
|
+
<span :if="!point.pinned && !point.held" class="text-gray-500 text-xs dark:text-neutral-400">Retention policy</span>
|
|
68
|
+
</div>
|
|
69
|
+
</td>
|
|
70
|
+
<td class="px-4 py-3 text-right whitespace-nowrap">
|
|
71
|
+
<div class="inline-flex gap-2 items-center">
|
|
72
|
+
<Button size="sm" :disabled="busyId() !== '' || point.status !== 'available'" :loading="busyId() === point.id + ':verify'" @click="emit('verify', point.id)">Verify</Button>
|
|
73
|
+
<Button size="sm" variant="outline" :disabled="busyId() !== '' || point.status !== 'available' || point.verificationState !== 'verified'" @click="emit('restore', point)">Restore</Button>
|
|
74
|
+
<Button size="sm" variant="ghost" :disabled="busyId() !== ''" :loading="busyId() === point.id + ':protect'" @click="emit('protect', { id: point.id, pinned: !point.pinned })">{{ point.pinned ? 'Unpin' : 'Pin' }}</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</td>
|
|
77
|
+
</tr>
|
|
78
|
+
</template>
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { BackupResourceKind, RecoveryDataService, RecoveryDestination, RecoveryPolicyInput, RecoveryResource } from '../../../../functions/operations'
|
|
3
|
+
|
|
4
|
+
const open = useReactiveProp('isOpen', false)
|
|
5
|
+
const busy = useReactiveProp('busy', false)
|
|
6
|
+
const destinationItems = useReactiveProp('destinations', [])
|
|
7
|
+
const resourceItems = useReactiveProp('resources', [])
|
|
8
|
+
const serviceItems = useReactiveProp('dataServices', [])
|
|
9
|
+
const emit = defineEmits()
|
|
10
|
+
|
|
11
|
+
const name = state('')
|
|
12
|
+
const destinationId = state('')
|
|
13
|
+
const resourceKind = state<BackupResourceKind>('control_plane')
|
|
14
|
+
const resourceId = state('')
|
|
15
|
+
const dataServiceId = state('')
|
|
16
|
+
const includePatterns = state('')
|
|
17
|
+
const excludePatterns = state('storage/framework/runtime,storage/framework/stx')
|
|
18
|
+
const schedule = state('daily')
|
|
19
|
+
const timezone = state('UTC')
|
|
20
|
+
const keepLast = state(7)
|
|
21
|
+
const expireAfterDays = state(30)
|
|
22
|
+
const expectedRpoMinutes = state(1440)
|
|
23
|
+
const expectedRtoMinutes = state(120)
|
|
24
|
+
const compression = state<RecoveryPolicyInput['compression']>('gzip')
|
|
25
|
+
const enabled = state(true)
|
|
26
|
+
|
|
27
|
+
function destinations(): RecoveryDestination[] { return destinationItems() as RecoveryDestination[] }
|
|
28
|
+
function resources(): RecoveryResource[] { return resourceItems() as RecoveryResource[] }
|
|
29
|
+
function dataServices(): RecoveryDataService[] { return serviceItems() as RecoveryDataService[] }
|
|
30
|
+
|
|
31
|
+
function close(): void {
|
|
32
|
+
if (!busy()) emit('close')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function split(value: string): string[] {
|
|
36
|
+
return value.split(',').map(entry => entry.trim()).filter(Boolean)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function submit(): void {
|
|
40
|
+
emit('save', {
|
|
41
|
+
name: name().trim(),
|
|
42
|
+
destinationId: destinationId(),
|
|
43
|
+
resourceKind: resourceKind(),
|
|
44
|
+
resourceId: resourceId() || undefined,
|
|
45
|
+
dataServiceId: dataServiceId() || undefined,
|
|
46
|
+
includePatterns: split(includePatterns()),
|
|
47
|
+
excludePatterns: split(excludePatterns()),
|
|
48
|
+
schedule: schedule().trim(),
|
|
49
|
+
timezone: timezone().trim(),
|
|
50
|
+
keepLast: keepLast(),
|
|
51
|
+
expireAfterDays: expireAfterDays(),
|
|
52
|
+
compression: compression(),
|
|
53
|
+
expectedRpoMinutes: expectedRpoMinutes(),
|
|
54
|
+
expectedRtoMinutes: expectedRtoMinutes(),
|
|
55
|
+
enabled: enabled(),
|
|
56
|
+
} satisfies RecoveryPolicyInput)
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<Modal :isOpen="open()" title="Create recovery policy" description="Define the source once. The native recovery queue handles scheduled and on-demand runs." size="lg" :closable="!busy()" @close="close">
|
|
61
|
+
<form id="recovery-policy-form" class="space-y-4" @submit.prevent="submit">
|
|
62
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
63
|
+
<div>
|
|
64
|
+
<label for="recovery-policy-name" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Policy name</label>
|
|
65
|
+
<input id="recovery-policy-name" x-model.trim="name" required placeholder="Daily control plane" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<label for="recovery-policy-destination" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Destination</label>
|
|
69
|
+
<select id="recovery-policy-destination" x-model="destinationId" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
70
|
+
<option value="">Choose destination</option>
|
|
71
|
+
<option :for="destination in destinations()" :value="destination.id">{{ destination.name }} ({{ destination.status }})</option>
|
|
72
|
+
</select>
|
|
73
|
+
</div>
|
|
74
|
+
<div>
|
|
75
|
+
<label for="recovery-policy-kind" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Source type</label>
|
|
76
|
+
<select id="recovery-policy-kind" x-model="resourceKind" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
77
|
+
<option value="control_plane">Control plane</option>
|
|
78
|
+
<option value="files">Project files</option>
|
|
79
|
+
<option value="logical_database">Logical database</option>
|
|
80
|
+
<option value="managed_database">Managed database</option>
|
|
81
|
+
<option value="volume">Docker volume</option>
|
|
82
|
+
<option value="infrastructure">AWS infrastructure</option>
|
|
83
|
+
</select>
|
|
84
|
+
</div>
|
|
85
|
+
<div :if="resourceKind() === 'files' || resourceKind() === 'volume'">
|
|
86
|
+
<label for="recovery-policy-resource" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Resource</label>
|
|
87
|
+
<select id="recovery-policy-resource" x-model="resourceId" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
88
|
+
<option value="">Choose resource</option>
|
|
89
|
+
<option :for="resource in resources()" :value="resource.id">{{ resource.name }} ({{ resource.kind }})</option>
|
|
90
|
+
</select>
|
|
91
|
+
</div>
|
|
92
|
+
<div :if="resourceKind() === 'logical_database' || resourceKind() === 'managed_database'">
|
|
93
|
+
<label for="recovery-policy-service" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Data service</label>
|
|
94
|
+
<select id="recovery-policy-service" x-model="dataServiceId" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
95
|
+
<option value="">Choose data service</option>
|
|
96
|
+
<option :for="service in dataServices()" :value="service.id">{{ service.name }} ({{ service.engine }})</option>
|
|
97
|
+
</select>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div :if="resourceKind() === 'files' || resourceKind() === 'volume' || resourceKind() === 'infrastructure'">
|
|
102
|
+
<label for="recovery-policy-includes" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Include entries</label>
|
|
103
|
+
<input id="recovery-policy-includes" x-model.trim="includePatterns" required placeholder="storage/app,public/uploads" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
104
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Comma separated. Volumes use a volume name. Infrastructure uses resource:arn: and role:arn: entries.</p>
|
|
105
|
+
</div>
|
|
106
|
+
<div :if="resourceKind() === 'files'">
|
|
107
|
+
<label for="recovery-policy-excludes" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Exclude patterns</label>
|
|
108
|
+
<input id="recovery-policy-excludes" x-model.trim="excludePatterns" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
112
|
+
<div>
|
|
113
|
+
<label for="recovery-policy-schedule" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Schedule</label>
|
|
114
|
+
<input id="recovery-policy-schedule" x-model.trim="schedule" required placeholder="daily" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
115
|
+
</div>
|
|
116
|
+
<div>
|
|
117
|
+
<label for="recovery-policy-timezone" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Timezone</label>
|
|
118
|
+
<input id="recovery-policy-timezone" x-model.trim="timezone" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
119
|
+
</div>
|
|
120
|
+
<div>
|
|
121
|
+
<label for="recovery-policy-rpo" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">RPO minutes</label>
|
|
122
|
+
<input id="recovery-policy-rpo" x-model.number="expectedRpoMinutes" type="number" min="1" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
123
|
+
</div>
|
|
124
|
+
<div>
|
|
125
|
+
<label for="recovery-policy-rto" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">RTO minutes</label>
|
|
126
|
+
<input id="recovery-policy-rto" x-model.number="expectedRtoMinutes" type="number" min="1" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="grid gap-4 sm:grid-cols-3">
|
|
130
|
+
<div>
|
|
131
|
+
<label for="recovery-policy-keep" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Keep latest</label>
|
|
132
|
+
<input id="recovery-policy-keep" x-model.number="keepLast" type="number" min="1" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
133
|
+
</div>
|
|
134
|
+
<div>
|
|
135
|
+
<label for="recovery-policy-expire" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Expire after days</label>
|
|
136
|
+
<input id="recovery-policy-expire" x-model.number="expireAfterDays" type="number" min="1" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
137
|
+
</div>
|
|
138
|
+
<div>
|
|
139
|
+
<label for="recovery-policy-compression" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Compression</label>
|
|
140
|
+
<select id="recovery-policy-compression" x-model="compression" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
141
|
+
<option value="gzip">Gzip</option>
|
|
142
|
+
<option value="zstd">Zstandard</option>
|
|
143
|
+
<option value="none">None</option>
|
|
144
|
+
</select>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<label class="inline-flex gap-2 items-center text-gray-700 text-sm dark:text-neutral-200"><input x-model="enabled" type="checkbox" /> Enable scheduled runs</label>
|
|
148
|
+
</form>
|
|
149
|
+
<template #footer>
|
|
150
|
+
<div class="flex gap-2 justify-end w-full">
|
|
151
|
+
<Button variant="secondary" :disabled="busy()" @click="close">Cancel</Button>
|
|
152
|
+
<Button type="submit" form="recovery-policy-form" :loading="busy()" :disabled="destinations().length === 0">Create policy</Button>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
155
|
+
</Modal>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { RecoveryPoint, RestorePlanInput } from '../../../../functions/operations'
|
|
3
|
+
|
|
4
|
+
const open = useReactiveProp('isOpen', false)
|
|
5
|
+
const pointValue = useReactiveProp('point', null)
|
|
6
|
+
const pointItems = useReactiveProp('points', [])
|
|
7
|
+
const busy = useReactiveProp('busy', false)
|
|
8
|
+
const planValue = useReactiveProp('plan', null)
|
|
9
|
+
const emit = defineEmits()
|
|
10
|
+
|
|
11
|
+
const mode = state<'isolated' | 'in_place'>('isolated')
|
|
12
|
+
const targetName = state('')
|
|
13
|
+
const drill = state(false)
|
|
14
|
+
const downtimeAcknowledged = state(false)
|
|
15
|
+
const confirm = state('')
|
|
16
|
+
const safetyBackupId = state('')
|
|
17
|
+
|
|
18
|
+
function point(): RecoveryPoint | null { return pointValue() as RecoveryPoint | null }
|
|
19
|
+
function points(): RecoveryPoint[] { return pointItems() as RecoveryPoint[] }
|
|
20
|
+
function plan(): { warnings?: string[] } | null { return planValue() as { warnings?: string[] } | null }
|
|
21
|
+
|
|
22
|
+
function close(): void {
|
|
23
|
+
if (!busy()) emit('close')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function input(execute: boolean): RestorePlanInput {
|
|
27
|
+
return {
|
|
28
|
+
mode: mode(),
|
|
29
|
+
targetName: targetName().trim(),
|
|
30
|
+
drill: drill(),
|
|
31
|
+
execute,
|
|
32
|
+
confirm: confirm().trim(),
|
|
33
|
+
downtimeAcknowledged: downtimeAcknowledged(),
|
|
34
|
+
safetyBackupId: safetyBackupId() || undefined,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function preview(): void { emit('preview', input(false)) }
|
|
39
|
+
function execute(): void { emit('execute', input(true)) }
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<Modal :isOpen="open()" title="Restore recovery point" description="Preview is mandatory. Isolated restores are the default and can run as disposable recovery drills." size="lg" :closable="!busy()" @close="close">
|
|
43
|
+
<div class="space-y-4">
|
|
44
|
+
<div class="p-3 bg-gray-50 dark:bg-neutral-900/60 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
45
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ point()?.id || 'Recovery point' }}</p>
|
|
46
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ point()?.kind }} · {{ point()?.verificationState }}</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
49
|
+
<div>
|
|
50
|
+
<label for="restore-mode" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Mode</label>
|
|
51
|
+
<select id="restore-mode" x-model="mode" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700">
|
|
52
|
+
<option value="isolated">Isolated target</option>
|
|
53
|
+
<option value="in_place">In-place replacement</option>
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<label for="restore-target" class="block mb-1.5 font-medium text-gray-700 text-sm dark:text-neutral-200">Target name</label>
|
|
58
|
+
<input id="restore-target" x-model.trim="targetName" required placeholder="recovery-check-2026-08" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-lg dark:border-neutral-700" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<label :if="mode() === 'isolated'" class="inline-flex gap-2 items-center text-gray-700 text-sm dark:text-neutral-200"><input x-model="drill" type="checkbox" /> Run as a recovery drill and clean the isolated target after health validation</label>
|
|
62
|
+
<div :if="mode() === 'in_place'" class="space-y-4 p-4 text-amber-900 dark:text-amber-100 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">
|
|
63
|
+
<p class="font-medium text-sm">In-place restore replaces live data and requires a distinct verified safety point.</p>
|
|
64
|
+
<div>
|
|
65
|
+
<label for="restore-safety" class="block mb-1.5 font-medium text-sm">Safety recovery point</label>
|
|
66
|
+
<select id="restore-safety" x-model="safetyBackupId" required class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-amber-300 rounded-lg dark:border-amber-800">
|
|
67
|
+
<option value="">Choose verified safety point</option>
|
|
68
|
+
<option :for="candidate in points().filter(value => value.id !== point()?.id && value.verificationState === 'verified')" :value="candidate.id">{{ candidate.id }}</option>
|
|
69
|
+
</select>
|
|
70
|
+
</div>
|
|
71
|
+
<label class="inline-flex gap-2 items-center text-sm"><input x-model="downtimeAcknowledged" type="checkbox" /> I acknowledge downtime and live data replacement</label>
|
|
72
|
+
<div>
|
|
73
|
+
<label for="restore-confirm" class="block mb-1.5 font-medium text-sm">Type the target name to confirm</label>
|
|
74
|
+
<input id="restore-confirm" x-model.trim="confirm" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-amber-300 rounded-lg dark:border-amber-800" />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div :if="plan()" class="p-4 text-blue-800 text-sm dark:text-blue-200 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 rounded-lg dark:border-blue-900">
|
|
78
|
+
<p class="font-medium">Restore plan validated</p>
|
|
79
|
+
<ul :if="plan()?.warnings?.length" class="mt-2 pl-5 list-disc"><li :for="warning in plan()?.warnings">{{ warning }}</li></ul>
|
|
80
|
+
<p :if="!plan()?.warnings?.length" class="mt-1">No additional warnings were returned.</p>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<template #footer>
|
|
84
|
+
<div class="flex flex-wrap gap-2 justify-end w-full">
|
|
85
|
+
<Button variant="secondary" :disabled="busy()" @click="close">Cancel</Button>
|
|
86
|
+
<Button variant="outline" :loading="busy() && !plan()" :disabled="!targetName().trim()" @click="preview">Preview restore</Button>
|
|
87
|
+
<Button :if="plan()" :loading="busy()" @click="execute">Queue restore</Button>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
</Modal>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { OperatorOperation, SchedulerOperationsResponse, SchedulerTask } from '../../../../functions/operations'
|
|
3
|
+
import { fetchSchedulerOperations, runSchedulerTask, updateSchedulerTask } from '../../../../functions/operations'
|
|
4
|
+
import { pushToast } from '../../../../functions/toasts'
|
|
5
|
+
|
|
6
|
+
interface PendingAction {
|
|
7
|
+
name: string
|
|
8
|
+
action: 'run' | 'toggle'
|
|
9
|
+
enabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const tasks = state<SchedulerTask[]>([])
|
|
13
|
+
const operations = state<OperatorOperation[]>([])
|
|
14
|
+
const summary = state<SchedulerOperationsResponse['summary']>({ total: 0, active: 0, paused: 0, nextRun: null })
|
|
15
|
+
const isLoading = state(true)
|
|
16
|
+
const loadError = state('')
|
|
17
|
+
const busyTask = state('')
|
|
18
|
+
const pendingAction = state<PendingAction | null>(null)
|
|
19
|
+
|
|
20
|
+
function formatDate(value: string | null): string {
|
|
21
|
+
if (!value)
|
|
22
|
+
return 'Nothing scheduled'
|
|
23
|
+
const date = new Date(value)
|
|
24
|
+
return Number.isNaN(date.getTime())
|
|
25
|
+
? value
|
|
26
|
+
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadOperations(): Promise<void> {
|
|
30
|
+
isLoading.set(true)
|
|
31
|
+
loadError.set('')
|
|
32
|
+
try {
|
|
33
|
+
const result = await fetchSchedulerOperations()
|
|
34
|
+
tasks.set(result.tasks)
|
|
35
|
+
operations.set(result.operations)
|
|
36
|
+
summary.set(result.summary)
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
loadError.set('Scheduler operations could not be loaded.')
|
|
40
|
+
pushToast('error', 'Could not load scheduler operations', { detail: String(error) })
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
isLoading.set(false)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requestRun(name: string | CustomEvent<string>): void {
|
|
48
|
+
pendingAction.set({ name: typeof name === 'string' ? name : name.detail, action: 'run' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requestToggle(detail: { name: string, enabled: boolean } | CustomEvent<{ name: string, enabled: boolean }>): void {
|
|
52
|
+
const value = detail instanceof CustomEvent ? detail.detail : detail
|
|
53
|
+
pendingAction.set({ name: value.name, action: 'toggle', enabled: value.enabled })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function closeConfirmation(): void {
|
|
57
|
+
if (!busyTask())
|
|
58
|
+
pendingAction.set(null)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function confirmAction(): Promise<void> {
|
|
62
|
+
const pending = pendingAction()
|
|
63
|
+
if (!pending)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
busyTask.set(`${pending.name}:${pending.action}`)
|
|
67
|
+
try {
|
|
68
|
+
const result = pending.action === 'run'
|
|
69
|
+
? await runSchedulerTask(pending.name)
|
|
70
|
+
: await updateSchedulerTask(pending.name, pending.enabled === true)
|
|
71
|
+
pushToast('success', result.message)
|
|
72
|
+
pendingAction.set(null)
|
|
73
|
+
await loadOperations()
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
pushToast('error', 'Scheduler operation failed', { detail: String(error) })
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
busyTask.set('')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onMount(() => {
|
|
84
|
+
void loadOperations()
|
|
85
|
+
})
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<div class="space-y-6">
|
|
89
|
+
<PageHeader title="Operations" description="Run and control application schedules through the same registry used by Buddy.">
|
|
90
|
+
<template #actions>
|
|
91
|
+
<Button variant="secondary" :loading="isLoading()" @click="loadOperations">
|
|
92
|
+
<span :if="!isLoading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
|
|
93
|
+
Refresh
|
|
94
|
+
</Button>
|
|
95
|
+
</template>
|
|
96
|
+
</PageHeader>
|
|
97
|
+
|
|
98
|
+
<OperationsNavigation active="scheduler" />
|
|
99
|
+
|
|
100
|
+
<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">
|
|
101
|
+
<span>{{ loadError() }}</span>
|
|
102
|
+
<Button size="sm" variant="secondary" @click="loadOperations">Try again</Button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<section aria-label="Scheduler summary" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
106
|
+
<StatsCard title="Registered tasks" :value="String(summary().total)" icon="i-hugeicons-calendar-03" iconBg="primary" :loading="isLoading()" />
|
|
107
|
+
<StatsCard title="Active" :value="String(summary().active)" icon="i-hugeicons-play" iconBg="success" :loading="isLoading()" />
|
|
108
|
+
<StatsCard title="Paused" :value="String(summary().paused)" icon="i-hugeicons-pause" iconBg="warning" :loading="isLoading()" />
|
|
109
|
+
<StatsCard title="Next run" :value="formatDate(summary().nextRun)" icon="i-hugeicons-clock-01" iconBg="neutral" :loading="isLoading()" />
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section aria-labelledby="scheduled-tasks-heading">
|
|
113
|
+
<div class="mb-3">
|
|
114
|
+
<h2 id="scheduled-tasks-heading" class="font-semibold text-gray-900 dark:text-white">Scheduled tasks</h2>
|
|
115
|
+
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Pauses persist across scheduler processes. Manual runs use normal overlap and error guards.</p>
|
|
116
|
+
</div>
|
|
117
|
+
<SchedulerTaskTable
|
|
118
|
+
:tasks="tasks"
|
|
119
|
+
:loading="isLoading"
|
|
120
|
+
:busyTask="busyTask"
|
|
121
|
+
@run="requestRun"
|
|
122
|
+
@toggle="requestToggle"
|
|
123
|
+
/>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<OperationHistory
|
|
127
|
+
:operations="operations"
|
|
128
|
+
:loading="isLoading"
|
|
129
|
+
subtitle="Every scheduler mutation is recorded in the append-only control plane."
|
|
130
|
+
emptyTitle="No scheduler operations recorded"
|
|
131
|
+
emptyDescription="Run, pause, or resume a task to create the first audit entry."
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<ConfirmDialog
|
|
135
|
+
:isOpen="pendingAction() !== null"
|
|
136
|
+
:title="pendingAction()?.action === 'run' ? 'Run scheduled task?' : (pendingAction()?.enabled ? 'Resume scheduled task?' : 'Pause scheduled task?')"
|
|
137
|
+
:description="pendingAction()?.action === 'run' ? 'This executes the task immediately through its configured guards.' : 'This changes the persisted scheduler state without editing application source.'"
|
|
138
|
+
:confirmLabel="pendingAction()?.action === 'run' ? 'Run now' : (pendingAction()?.enabled ? 'Resume' : 'Pause')"
|
|
139
|
+
variant="warning"
|
|
140
|
+
:busy="busyTask() !== ''"
|
|
141
|
+
@close="closeConfirmation"
|
|
142
|
+
@confirm="confirmAction"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|