@skyhook-io/radar-app 1.17.0 → 1.17.1
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/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { CNPGRestoreDialog } from './CNPGRestoreDialog'
|
|
5
|
+
const state = vi.hoisted(() => ({ coverage: 'denied', destination: false, backups: [] as any[] }))
|
|
6
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGWorkspace: () => ({ isLoading: false, data: { coverage: { backups: { state: state.coverage } }, objects: { clusters: [{ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'pg', namespace: 'db' }, spec: state.destination ? { backup: { barmanObjectStore: { destinationPath: 's3://archive' } } } : {} }], backups: state.backups } } }), useCNPGRuntime: () => ({}) }))
|
|
7
|
+
vi.mock('../../../api/client', () => ({ useResources: () => ({ data: [] }) }))
|
|
8
|
+
vi.mock('../../../api/cnpg-recovery', () => ({ useCNPGRestoreCapability: () => ({ data: { allowed: true } }) }))
|
|
9
|
+
vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
|
|
10
|
+
vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: vi.fn() }) }))
|
|
11
|
+
vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
|
|
12
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), ActionConfirmDialog: ({ disabledReason, warnings, children }: { disabledReason?: string; warnings?: string[]; children: ReactNode }) => <div>{disabledReason}{warnings?.join(' · ')}{children}</div> }))
|
|
13
|
+
it('says sources could not be read instead of claiming no completed Backup', () => {
|
|
14
|
+
const render = () => renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'cluster', name: 'pg' }} onClose={() => {}} />)
|
|
15
|
+
state.coverage = 'denied'
|
|
16
|
+
expect(render()).toContain('Backup sources could not be read in db')
|
|
17
|
+
expect(render()).not.toContain('no completed Backup')
|
|
18
|
+
state.coverage = 'full'
|
|
19
|
+
expect(render()).toContain('Nothing to restore from yet: no backup destination and no completed Backup')
|
|
20
|
+
})
|
|
21
|
+
it('names unread Backups even when an archive destination can be used', () => {
|
|
22
|
+
state.coverage = 'denied'; state.destination = true
|
|
23
|
+
const html = renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'cluster', name: 'pg' }} onClose={() => {}} />)
|
|
24
|
+
expect(html).toContain('Backup sources could not be read in db')
|
|
25
|
+
expect(html).toContain('Barman object store (in-tree)')
|
|
26
|
+
expect(html).not.toContain('no completed Backup')
|
|
27
|
+
state.destination = false
|
|
28
|
+
})
|
|
29
|
+
it('offers a volume snapshot only its own end and says why, while an in-tree Backup keeps WAL targets', () => {
|
|
30
|
+
const backup = (name: string, method: string) => ({ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name, namespace: 'db' }, spec: { cluster: { name: 'pg' }, method }, status: { phase: 'completed', method, stoppedAt: '2026-09-30T12:00:00Z' } })
|
|
31
|
+
state.coverage = 'full'
|
|
32
|
+
state.backups = [backup('snap', 'volumeSnapshot'), backup('base', 'barmanObjectStore')]
|
|
33
|
+
const render = (name: string) => renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'backup', name }} onClose={() => {}} />)
|
|
34
|
+
const snapshot = render('snap')
|
|
35
|
+
expect(snapshot).toContain('The end of this snapshot (2026-09-30 12:00:00 UTC)')
|
|
36
|
+
expect(snapshot).toMatch(/checked=""\/>The end of this snapshot/)
|
|
37
|
+
expect(snapshot).toContain('needs a WAL archive source, which this restore does not configure')
|
|
38
|
+
expect(snapshot).not.toContain('The latest archived WAL')
|
|
39
|
+
expect(snapshot).not.toContain('A point in time')
|
|
40
|
+
expect(snapshot).not.toContain('Last archived WAL')
|
|
41
|
+
const inTree = render('base')
|
|
42
|
+
expect(inTree).toContain('The latest archived WAL')
|
|
43
|
+
expect(inTree).toContain('A point in time')
|
|
44
|
+
expect(inTree).not.toContain('The end of this')
|
|
45
|
+
state.backups = []
|
|
46
|
+
})
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import yaml from 'yaml'
|
|
3
|
+
import { ActionConfirmDialog, ConfirmDialog, FoldSection, isApiGroup } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useCNPGRuntime, useCNPGWorkspace } from '../../../api/cnpg'
|
|
5
|
+
import { useCNPGRestoreCapability } from '../../../api/cnpg-recovery'
|
|
6
|
+
import { useResources } from '../../../api/client'
|
|
7
|
+
import { CNPGTargetFields } from '../creation/CNPGTargetFields'
|
|
8
|
+
import { cnpgTargetDraft, cnpgTargetFromManifest, cnpgTargetIssue, onlyCNPGTargetChanged, sameCNPGRecoveryIdentity, updateCNPGTargetYaml, withCNPGTarget, type CNPGTarget } from '../creation/targetModel'
|
|
9
|
+
import { useConnection } from '../../../context/ConnectionContext'
|
|
10
|
+
import { CreateResourceDialog } from '../../shared/CreateResourceDialog'
|
|
11
|
+
import { useToast } from '../../ui/Toast'
|
|
12
|
+
import { cnpgClusterFullPath } from '../paths'
|
|
13
|
+
import { trackCNPGOperation } from '../operations/store'
|
|
14
|
+
import { CNPG_RESTORE_OPERATION } from './restoreOperation'
|
|
15
|
+
import { CNPGRecoveryPoint } from './CNPGRecoveryPoint'
|
|
16
|
+
import {
|
|
17
|
+
buildRestoreManifest,
|
|
18
|
+
describeSource,
|
|
19
|
+
formatUTC,
|
|
20
|
+
pitrWarnings,
|
|
21
|
+
preflightFacts,
|
|
22
|
+
recoveryEvidenceFor,
|
|
23
|
+
restoreManifestHeader,
|
|
24
|
+
restoreSourceForBackup,
|
|
25
|
+
assessRestoreSources,
|
|
26
|
+
restoreSourcesForStore,
|
|
27
|
+
sourceClusterFor,
|
|
28
|
+
restorePermission,
|
|
29
|
+
restoreTargetKinds,
|
|
30
|
+
sourcePinsBackup,
|
|
31
|
+
targetIsoFrom,
|
|
32
|
+
type RestoreSource,
|
|
33
|
+
type RestoreTarget,
|
|
34
|
+
} from './restoreModel'
|
|
35
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
36
|
+
|
|
37
|
+
/** Where the restore was started from; decides the default source. */
|
|
38
|
+
export type CNPGRestoreEntry = { kind: 'cluster'; name: string } | { kind: 'backup'; name: string } | { kind: 'objectStore'; name: string }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Restore builds a new Cluster manifest, shows the evidence for the recovery
|
|
42
|
+
* target and what the manifest copies from the source, and hands it to
|
|
43
|
+
* Radar's create flow (server dry-run, strict create). After the create the
|
|
44
|
+
* user is offered the new Cluster's page, which follows the restore.
|
|
45
|
+
*/
|
|
46
|
+
export function CNPGRestoreDialog({ namespace, entry, onClose }: { namespace: string; entry: CNPGRestoreEntry; onClose: () => void }) {
|
|
47
|
+
const navigate = useCNPGNavigate()
|
|
48
|
+
const { connection } = useConnection()
|
|
49
|
+
const [context] = useState(connection.context)
|
|
50
|
+
const { showSuccess } = useToast()
|
|
51
|
+
const workspace = useCNPGWorkspace([namespace])
|
|
52
|
+
const objects = workspace.data?.objects
|
|
53
|
+
const clusters = useMemo(() => (objects?.clusters ?? []).filter((c: any) => isApiGroup(c.apiVersion, 'postgresql.cnpg.io')), [objects])
|
|
54
|
+
const backups = useMemo(() => (objects?.backups ?? []).filter((b: any) => isApiGroup(b.apiVersion, 'postgresql.cnpg.io')), [objects])
|
|
55
|
+
const stores = useMemo(() => objects?.objectStores ?? [], [objects])
|
|
56
|
+
|
|
57
|
+
const sources = useMemo<RestoreSource[]>(() => {
|
|
58
|
+
if (!objects) return []
|
|
59
|
+
if (entry.kind === 'cluster') {
|
|
60
|
+
const c = clusters.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
|
|
61
|
+
return assessRestoreSources(workspace.data, namespace, c).sources
|
|
62
|
+
}
|
|
63
|
+
if (entry.kind === 'backup') {
|
|
64
|
+
const b = backups.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
|
|
65
|
+
const owner = clusters.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === b?.spec?.cluster?.name) ?? null
|
|
66
|
+
const s = b ? restoreSourceForBackup(b, owner) : null
|
|
67
|
+
return s ? [s] : []
|
|
68
|
+
}
|
|
69
|
+
const store = stores.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
|
|
70
|
+
return store ? restoreSourcesForStore(store) : []
|
|
71
|
+
}, [objects, clusters, backups, stores, entry, namespace, workspace.data])
|
|
72
|
+
|
|
73
|
+
const [sourceIdx, setSourceIdx] = useState(0)
|
|
74
|
+
const [confirmedArchive, setConfirmedArchive] = useState<string | null>(null)
|
|
75
|
+
const source = sources[sourceIdx]
|
|
76
|
+
const sourceCluster = useMemo(() => {
|
|
77
|
+
if (!source) return null
|
|
78
|
+
if (entry.kind === 'cluster') return clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === entry.name) ?? null
|
|
79
|
+
if (source.kind === 'backup' || (source.kind === 'objectStore' && source.backupName)) {
|
|
80
|
+
const b = backups.find((x: any) => x.metadata?.name === (source.kind === 'backup' ? source.backup : source.backupName))
|
|
81
|
+
return clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === b?.spec?.cluster?.name) ?? null
|
|
82
|
+
}
|
|
83
|
+
return sourceClusterFor(source, clusters, namespace)
|
|
84
|
+
}, [source, entry, clusters, backups, namespace])
|
|
85
|
+
const archiveKey = source?.kind === 'objectStore' && source.backupName ? [sourceCluster?.metadata?.uid, source.objectStore, source.serverName, source.backupID, source.majorVersion].join('/') : null
|
|
86
|
+
const archiveConfirmed = archiveKey !== null && confirmedArchive === archiveKey
|
|
87
|
+
const sourceName = sourceCluster?.metadata?.name as string | undefined
|
|
88
|
+
const runtime = useCNPGRuntime(namespace, sourceName ?? '', !!sourceName)
|
|
89
|
+
|
|
90
|
+
const defaultName = `${sourceName ?? (source?.kind === 'objectStore' ? source.serverName : entry.name)}-restore`
|
|
91
|
+
const [newName, setNewName] = useState<string | null>(null)
|
|
92
|
+
const name = newName ?? defaultName
|
|
93
|
+
const pinned = sourcePinsBackup(source)
|
|
94
|
+
const snapshot = source?.kind === 'backup' && !!source.snapshot
|
|
95
|
+
const [targetKind, setTargetKind] = useState<RestoreTarget['kind'] | null>(null)
|
|
96
|
+
const targetKinds = restoreTargetKinds(source)
|
|
97
|
+
const effectiveKind = targetKind && targetKinds.includes(targetKind) ? targetKind : targetKinds[0]
|
|
98
|
+
const [timeValue, setTimeValue] = useState('')
|
|
99
|
+
const [zone, setZone] = useState<'utc' | 'local'>('utc')
|
|
100
|
+
const [manifest, setManifest] = useState<string | null>(null)
|
|
101
|
+
const [manifestBasis, setManifestBasis] = useState<string | null>(null)
|
|
102
|
+
const [editingManifest, setEditingManifest] = useState(false)
|
|
103
|
+
const [replacementManifest, setReplacementManifest] = useState<string | null>(null)
|
|
104
|
+
const [targetOverrides, setTargetOverrides] = useState<Partial<CNPGTarget>>({})
|
|
105
|
+
const [customIdentity, setCustomIdentity] = useState(false)
|
|
106
|
+
const [setupStep, setSetupStep] = useState<'source' | 'target'>('source')
|
|
107
|
+
const storageClasses = useResources<any>('storageclasses', undefined, 'storage.k8s.io')
|
|
108
|
+
const restoreCap = useCNPGRestoreCapability(namespace)
|
|
109
|
+
|
|
110
|
+
const evidence = useMemo(
|
|
111
|
+
() => recoveryEvidenceFor(source, { sourceCluster, stores, backups, runtime: runtime.data, namespace }),
|
|
112
|
+
[source, sourceCluster, stores, backups, runtime.data, namespace],
|
|
113
|
+
)
|
|
114
|
+
const targetIso = effectiveKind === 'time' ? targetIsoFrom(timeValue, zone) : null
|
|
115
|
+
const warnings = effectiveKind === 'time' && targetIso ? pitrWarnings(targetIso, evidence) : []
|
|
116
|
+
const facts = preflightFacts(sourceCluster).filter((fact) => !['Instances', 'Image', 'Data storage'].includes(fact.label))
|
|
117
|
+
const serverName = source && source.kind !== 'backup' ? source.serverName : undefined
|
|
118
|
+
const recoveryTarget: RestoreTarget = effectiveKind === 'time' && targetIso ? { kind: 'time', iso: targetIso } : effectiveKind === 'backupEnd' ? { kind: 'backupEnd' } : { kind: 'latest' }
|
|
119
|
+
const baseManifest = source ? buildRestoreManifest({ sourceCluster, source, namespace, newName: name, target: recoveryTarget }) : { metadata: { name, namespace }, spec: { instances: 1, storage: {} } }
|
|
120
|
+
const targetValues: CNPGTarget = { ...cnpgTargetFromManifest(baseManifest), ...targetOverrides, name, namespace }
|
|
121
|
+
const catalog = baseManifest.spec.imageCatalogRef
|
|
122
|
+
const imageDescription = catalog ? `${catalog.kind ?? 'ImageCatalog'} ${catalog.name}, PostgreSQL ${catalog.major}` : baseManifest.spec.imageName
|
|
123
|
+
const requireImage = !imageDescription
|
|
124
|
+
const prepareManifest = (values: CNPGTarget) => restoreManifestHeader(describeSource(source!), serverName, sourceName ?? null) + yaml.stringify(withCNPGTarget(baseManifest, values), { version: '1.1' })
|
|
125
|
+
const retainingDraft = source && manifest !== null && prepareManifest(targetValues) === manifestBasis
|
|
126
|
+
|
|
127
|
+
if (context !== connection.context) {
|
|
128
|
+
return <ActionConfirmDialog
|
|
129
|
+
open
|
|
130
|
+
onClose={onClose}
|
|
131
|
+
onConfirm={() => {}}
|
|
132
|
+
title="Restore to a new cluster"
|
|
133
|
+
subject={{ kind: 'Cluster', namespace, name }}
|
|
134
|
+
context={context || undefined}
|
|
135
|
+
effect="This restore was prepared in a different Kubernetes context."
|
|
136
|
+
confirmLabel="Review manifest"
|
|
137
|
+
disabledReason="Close this dialog and start again in the current context."
|
|
138
|
+
/>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (replacementManifest !== null) {
|
|
142
|
+
return <ConfirmDialog
|
|
143
|
+
open
|
|
144
|
+
onClose={() => setReplacementManifest(null)}
|
|
145
|
+
onConfirm={() => {
|
|
146
|
+
setManifest(replacementManifest)
|
|
147
|
+
setManifestBasis(replacementManifest)
|
|
148
|
+
setCustomIdentity(false)
|
|
149
|
+
setReplacementManifest(null)
|
|
150
|
+
setEditingManifest(true)
|
|
151
|
+
}}
|
|
152
|
+
variant="warning"
|
|
153
|
+
showWarning={false}
|
|
154
|
+
title="Replace edited restore manifest?"
|
|
155
|
+
message="The setup changed. Rebuilding the manifest uses these setup choices and discards your YAML edits."
|
|
156
|
+
confirmLabel="Replace manifest"
|
|
157
|
+
cancelLabel="Keep editing setup"
|
|
158
|
+
/>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (editingManifest && manifest !== null) {
|
|
162
|
+
return (
|
|
163
|
+
<CreateResourceDialog
|
|
164
|
+
open
|
|
165
|
+
onClose={onClose}
|
|
166
|
+
onBack={(draft) => {
|
|
167
|
+
setManifest(draft)
|
|
168
|
+
const parsed = cnpgTargetDraft(draft)
|
|
169
|
+
if (parsed && parsed.metadata.namespace === namespace) {
|
|
170
|
+
const values = cnpgTargetFromManifest(parsed)
|
|
171
|
+
setNewName(values.name)
|
|
172
|
+
setTargetOverrides(values)
|
|
173
|
+
setManifestBasis(prepareManifest(values))
|
|
174
|
+
setCustomIdentity(!sameCNPGRecoveryIdentity(parsed, baseManifest))
|
|
175
|
+
} else setCustomIdentity(true)
|
|
176
|
+
setEditingManifest(false)
|
|
177
|
+
}}
|
|
178
|
+
backLabel="Back to restore setup"
|
|
179
|
+
initialYaml={manifest}
|
|
180
|
+
initialMode="create"
|
|
181
|
+
lockMode
|
|
182
|
+
title={`Restore into a new cluster ${name}`}
|
|
183
|
+
onCreated={(created, submittedYaml) => {
|
|
184
|
+
if (created.kind !== 'Cluster' || !isApiGroup(created.apiVersion, 'postgresql.cnpg.io')) return
|
|
185
|
+
const submitted = cnpgTargetDraft(submittedYaml)
|
|
186
|
+
const restoring = submitted?.metadata.name === created.name && submitted.metadata.namespace === created.namespace && !!submitted.spec.bootstrap?.recovery
|
|
187
|
+
if (restoring) trackCNPGOperation({
|
|
188
|
+
kind: CNPG_RESTORE_OPERATION,
|
|
189
|
+
label: `Restore into ${created.name}`,
|
|
190
|
+
context: connection.context,
|
|
191
|
+
namespace: created.namespace || namespace,
|
|
192
|
+
cluster: created.name,
|
|
193
|
+
clusterUID: created.uid,
|
|
194
|
+
baseline: { source: source && sameCNPGRecoveryIdentity(submitted, baseManifest) ? describeSource(source) : undefined },
|
|
195
|
+
})
|
|
196
|
+
const path = cnpgClusterFullPath(created.namespace || namespace, created.name, connection.context || undefined)
|
|
197
|
+
showSuccess(`Cluster ${created.name} created`, restoring ? 'The operator is restoring it from backup.' : 'Review its current state on the Cluster page.', { label: restoring ? 'Follow the restore' : 'Open Cluster', onClick: () => navigate(path) })
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const permission = restorePermission(namespace, restoreCap.data, restoreCap.error)
|
|
204
|
+
const availability = entry.kind === 'cluster' ? assessRestoreSources(workspace.data, namespace, clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === entry.name)) : undefined
|
|
205
|
+
const noSource = !workspace.isLoading && sources.length === 0
|
|
206
|
+
const disabledReason =
|
|
207
|
+
permission.blocked ??
|
|
208
|
+
(!workspace.isLoading && sources.length === 0
|
|
209
|
+
? entry.kind === 'backup'
|
|
210
|
+
? 'This Backup cannot be restored: it has not completed, or its object store and backup ID are not recorded.'
|
|
211
|
+
: entry.kind === 'objectStore'
|
|
212
|
+
? 'This ObjectStore reports no server with backups yet.'
|
|
213
|
+
: availability?.unreadReason ?? availability?.disabledReason
|
|
214
|
+
: undefined)
|
|
215
|
+
const incompleteReason =
|
|
216
|
+
permission.pending ??
|
|
217
|
+
(workspace.isLoading
|
|
218
|
+
? 'Loading backups and object stores…'
|
|
219
|
+
: setupStep === 'target' && !retainingDraft && cnpgTargetIssue(targetValues, requireImage)
|
|
220
|
+
? cnpgTargetIssue(targetValues, requireImage)
|
|
221
|
+
: setupStep === 'target' && clusters.some((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === name)
|
|
222
|
+
? `A Cluster named ${name} already exists in ${namespace}.`
|
|
223
|
+
: source?.kind === 'objectStore' && source.backupName && !archiveConfirmed
|
|
224
|
+
? 'Confirm that the selected archive still contains this named backup.'
|
|
225
|
+
: effectiveKind === 'time' && !targetIso
|
|
226
|
+
? 'Enter the point in time to recover to.'
|
|
227
|
+
: undefined)
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<ActionConfirmDialog
|
|
231
|
+
open
|
|
232
|
+
size="wide"
|
|
233
|
+
onClose={onClose}
|
|
234
|
+
onBack={setupStep === 'target' ? () => setSetupStep('source') : undefined}
|
|
235
|
+
backLabel="Back to recovery point"
|
|
236
|
+
onConfirm={() => {
|
|
237
|
+
if (!source) return
|
|
238
|
+
if (setupStep === 'source') { setSetupStep('target'); return }
|
|
239
|
+
// The apiserver reads YAML 1.1, where unquoted on/off/yes are booleans (postgresql parameters are strings).
|
|
240
|
+
const nextManifest = prepareManifest(targetValues)
|
|
241
|
+
const previous = manifestBasis === null ? null : cnpgTargetDraft(manifestBasis)
|
|
242
|
+
const next = cnpgTargetDraft(nextManifest)
|
|
243
|
+
if (manifest !== null && nextManifest !== manifestBasis && previous && next && onlyCNPGTargetChanged(previous, next, targetValues)) {
|
|
244
|
+
const updated = updateCNPGTargetYaml(manifest, targetValues)
|
|
245
|
+
if (updated !== null) {
|
|
246
|
+
setManifest(updated)
|
|
247
|
+
setManifestBasis(nextManifest)
|
|
248
|
+
setEditingManifest(true)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (manifest !== null && manifest !== manifestBasis && nextManifest !== manifestBasis) {
|
|
253
|
+
setReplacementManifest(nextManifest)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (nextManifest !== manifestBasis) { setManifest(nextManifest); setCustomIdentity(false) }
|
|
257
|
+
setManifestBasis(nextManifest)
|
|
258
|
+
setEditingManifest(true)
|
|
259
|
+
}}
|
|
260
|
+
title="Restore to a new cluster"
|
|
261
|
+
subject={{ kind: entry.kind === 'cluster' ? 'Cluster' : entry.kind === 'backup' ? 'Backup' : 'ObjectStore', namespace, name: entry.name }}
|
|
262
|
+
context={connection.context || undefined}
|
|
263
|
+
effect="Restore backups into a separate Cluster. The source Cluster is left unchanged."
|
|
264
|
+
confirmLabel={setupStep === 'source' ? 'Continue to new Cluster' : customIdentity && retainingDraft ? 'Review current YAML' : 'Review manifest'}
|
|
265
|
+
warnings={noSource ? [] : [
|
|
266
|
+
...(customIdentity ? ['Advanced YAML changed the recovery source, image or resource shape. The setup shows the original source evidence; review the current YAML for the intended configuration.'] : []),
|
|
267
|
+
...(availability?.unreadReason ? [availability.unreadReason] : []),
|
|
268
|
+
...(catalog && (source?.kind === 'backup' || pinned) ? [`Verify that ${catalog.kind ?? 'ImageCatalog'} ${catalog.name} contains a PostgreSQL ${catalog.major} image. Catalog availability has not been checked.`] : []),
|
|
269
|
+
...(source?.majorVersion ? [`This backup was taken on PostgreSQL ${source.majorVersion}. Physical recovery requires that major version.`] : source?.kind === 'backup' || pinned ? ['This Backup does not record its PostgreSQL major. Choose a matching image; the current source image may have changed since the backup.'] : []),
|
|
270
|
+
...(setupStep === 'target' ? ['The new cluster has no WAL archiving or backups until you configure them.', ...(serverName ? [`If you add archiving later, do not reuse server name "${serverName}": the new cluster would write into the archive it restores from.`] : [])] : []),
|
|
271
|
+
...warnings,
|
|
272
|
+
...(permission.unchecked ? [permission.unchecked] : []),
|
|
273
|
+
]}
|
|
274
|
+
disabledReason={disabledReason}
|
|
275
|
+
incompleteReason={incompleteReason}
|
|
276
|
+
notes={manifest !== null && manifest !== manifestBasis ? ['Your YAML edits are retained. Name, instance and storage changes preserve them; changing the recovery source or time asks before replacing them.'] : []}
|
|
277
|
+
>
|
|
278
|
+
{noSource ? (
|
|
279
|
+
<p className="text-sm text-theme-text-secondary">
|
|
280
|
+
{availability?.unreadReason ?? 'A restore needs a completed Backup, or WAL archived to a backup destination. The Backups tab shows what this cluster has and where to set it up.'}
|
|
281
|
+
</p>
|
|
282
|
+
) : (
|
|
283
|
+
<>
|
|
284
|
+
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-theme-text-tertiary" aria-label="Restore steps">
|
|
285
|
+
<span className={setupStep === 'source' ? 'font-semibold text-accent-text' : undefined}>1. Recovery point</span><span aria-hidden>→</span>
|
|
286
|
+
<span className={setupStep === 'target' ? 'font-semibold text-accent-text' : undefined}>2. New Cluster</span><span aria-hidden>→</span><span>3. Review & create</span>
|
|
287
|
+
</div>
|
|
288
|
+
{manifest !== null && <button type="button" className="btn-secondary mb-4 px-3 py-1.5 text-xs" onClick={() => setEditingManifest(true)}>Continue editing current YAML</button>}
|
|
289
|
+
{source?.kind === 'objectStore' && source.backupName && <label className="mb-4 flex items-start gap-2 text-sm text-theme-text-secondary">
|
|
290
|
+
<input type="checkbox" checked={archiveConfirmed} onChange={(event) => setConfirmedArchive(event.target.checked ? archiveKey : null)} className="mt-1" />
|
|
291
|
+
<span>I confirm ObjectStore {source.objectStore}, archive server {source.serverName}, still contains backup {source.backupID}. The plugin does not record its archive destination in the Backup; this is the Cluster’s current destination.</span>
|
|
292
|
+
</label>}
|
|
293
|
+
{setupStep === 'source' ? <CNPGRecoveryPoint
|
|
294
|
+
sources={sources}
|
|
295
|
+
sourceIndex={sourceIdx}
|
|
296
|
+
onSourceChange={(index) => { setSourceIdx(index); setTargetKind(null); setTargetOverrides({}); setConfirmedArchive(null) }}
|
|
297
|
+
targetKind={effectiveKind}
|
|
298
|
+
onTargetChange={setTargetKind}
|
|
299
|
+
timeValue={timeValue}
|
|
300
|
+
onTimeChange={setTimeValue}
|
|
301
|
+
zone={zone}
|
|
302
|
+
onZoneChange={setZone}
|
|
303
|
+
targetIso={targetIso}
|
|
304
|
+
evidence={evidence}
|
|
305
|
+
sourceClusterName={sourceName}
|
|
306
|
+
/> : <>
|
|
307
|
+
<div className="mb-4 rounded-lg border border-theme-border bg-theme-base p-3 text-sm">
|
|
308
|
+
<div className="font-medium text-theme-text-primary">{describeSource(source!)}</div>
|
|
309
|
+
<div className="mt-1 text-theme-text-secondary">{effectiveKind === 'time' && targetIso ? `Recover to ${formatUTC(targetIso)}` : effectiveKind === 'backupEnd' ? `Recover to the end of this ${snapshot ? 'snapshot' : 'backup'}` : 'Recover to the latest archived WAL'}</div>
|
|
310
|
+
</div>
|
|
311
|
+
<CNPGTargetFields value={targetValues} onChange={(values) => { setNewName(values.name); setTargetOverrides(values) }} idPrefix="cnpg-restore" namespaceFixed storageClasses={storageClasses.data?.map((item) => item.metadata.name)} imageDescription={imageDescription} imageRequired={requireImage} defaultClassDisabled={(manifest !== null ? cnpgTargetDraft(manifest) : baseManifest)?.spec.storage?.pvcTemplate?.storageClassName === ''} />
|
|
312
|
+
{storageClasses.isError && <p className="text-xs text-theme-text-tertiary">Storage class suggestions could not be read. You can enter a name directly.</p>}
|
|
313
|
+
<div className="mt-4">
|
|
314
|
+
<FoldSection title="Other source settings" summary={sourceName ? `${sourceName} · ${facts.length} settings seed the manifest` : 'Source settings are unavailable'} attention={!sourceName}>
|
|
315
|
+
<p className="mb-2 text-xs text-theme-text-tertiary">{sourceName ? 'These settings seed the manifest. Review Advanced YAML for the final configuration and any edits.' : 'Review Advanced YAML for PostgreSQL settings, resource requests and placement.'}</p>
|
|
316
|
+
<dl className="grid grid-cols-[9rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-sm">
|
|
317
|
+
{facts.map((f) => (
|
|
318
|
+
<div key={f.path} className="contents">
|
|
319
|
+
<dt className="text-xs text-theme-text-secondary">{f.label}</dt>
|
|
320
|
+
<dd className={f.copied ? 'min-w-0 break-words text-theme-text-primary' : 'min-w-0 break-words text-theme-text-tertiary'}>
|
|
321
|
+
{f.value} <span className="font-mono text-[11px] text-theme-text-tertiary">{f.path}</span>
|
|
322
|
+
</dd>
|
|
323
|
+
</div>
|
|
324
|
+
))}
|
|
325
|
+
</dl>
|
|
326
|
+
</FoldSection>
|
|
327
|
+
</div>
|
|
328
|
+
</>}
|
|
329
|
+
</>
|
|
330
|
+
)}
|
|
331
|
+
</ActionConfirmDialog>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Badge, StatusDot, formatAge, toneTextClass, type HealthLevel, formatGrant } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGRecovery, type CNPGContainerState, type CNPGRecoveryResponse, type CNPGRecoverySpec } from '../../../api/cnpg-recovery'
|
|
4
|
+
import { buildWorkloadPath } from '../../../utils/navigation'
|
|
5
|
+
import { observeRestore, restoreNextSteps, type RestoreBackupDeclared, type RestoreNextStep, type RestoreNextStepId, type RestoreObservation } from './restoreModel'
|
|
6
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The restore observer: the recovery snapshot of one Cluster and where its
|
|
10
|
+
* restore stands. Self-contained so a generic operation tracker can register
|
|
11
|
+
* it as the "restore" operation's observer.
|
|
12
|
+
*/
|
|
13
|
+
export function useRestoreObservation(namespace: string, name: string, enabled = true): {
|
|
14
|
+
observation: RestoreObservation | null
|
|
15
|
+
snapshot: CNPGRecoveryResponse | undefined
|
|
16
|
+
error: unknown
|
|
17
|
+
} {
|
|
18
|
+
const probe = useCNPGRecovery(namespace, name, { enabled })
|
|
19
|
+
const observation = useMemo(() => (probe.data?.recovery ? observeRestore(probe.data) : null), [probe.data])
|
|
20
|
+
return { observation, snapshot: probe.data, error: probe.error }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function describeRecoverySource(r: CNPGRecoverySpec): string {
|
|
24
|
+
const target = r.target
|
|
25
|
+
? Object.entries(r.target)
|
|
26
|
+
.map(([k, v]) => `${k} ${String(v)}`)
|
|
27
|
+
.join(', ')
|
|
28
|
+
: 'latest archived WAL'
|
|
29
|
+
switch (r.sourceKind) {
|
|
30
|
+
case 'objectStore':
|
|
31
|
+
return `ObjectStore ${r.objectStore ?? '?'} · server ${r.serverName ?? '?'} · to ${target}`
|
|
32
|
+
case 'barmanObjectStore':
|
|
33
|
+
return `Barman object store · server ${r.serverName ?? '?'} · to ${target}`
|
|
34
|
+
case 'backup':
|
|
35
|
+
// Without a target, an in-tree Backup replays its archive's WAL but a
|
|
36
|
+
// volume snapshot stops at its own end; the method is not read here.
|
|
37
|
+
return `Backup ${r.backup ?? '?'}${r.target ? ` · to ${target}` : ''}`
|
|
38
|
+
case 'volumeSnapshots':
|
|
39
|
+
return 'Volume snapshots'
|
|
40
|
+
default:
|
|
41
|
+
return 'an external source'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATE_SEVERITY: Record<RestoreObservation['state'], 'success' | 'error' | 'neutral' | 'warning'> = {
|
|
46
|
+
completed: 'success',
|
|
47
|
+
failed: 'error',
|
|
48
|
+
progressing: 'neutral',
|
|
49
|
+
unobservable: 'neutral',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ContainerChip({ c, init }: { c: CNPGContainerState; init?: boolean }) {
|
|
53
|
+
const tone: HealthLevel =
|
|
54
|
+
c.state === 'running' ? 'healthy' : c.state === 'terminated' ? ((c.exitCode ?? 0) === 0 ? 'neutral' : 'unhealthy') : c.reason && c.reason !== 'PodInitializing' && c.reason !== 'ContainerCreating' ? 'alert' : 'unknown'
|
|
55
|
+
const label = c.state === 'terminated' ? (c.exitCode === 0 ? 'done' : `exit ${c.exitCode}`) : c.reason ?? c.state
|
|
56
|
+
return (
|
|
57
|
+
<span className="inline-flex items-center gap-1 rounded border border-theme-border px-1.5 py-0.5 text-[11px] text-theme-text-secondary">
|
|
58
|
+
<StatusDot tone={tone} />
|
|
59
|
+
{init && <span className="text-theme-text-tertiary">init</span>}
|
|
60
|
+
<span className="font-mono">{c.name}</span>
|
|
61
|
+
<span>{label}</span>
|
|
62
|
+
{c.restarts > 0 && <span className="text-theme-text-tertiary">· {c.restarts} restarts</span>}
|
|
63
|
+
</span>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* "Restore in progress" on a Cluster bootstrapped from backups: phase, the
|
|
69
|
+
* recovery Job's Pods with their init containers, links to their logs, and
|
|
70
|
+
* the Warning events about them. Once complete it collapses to one line.
|
|
71
|
+
*/
|
|
72
|
+
export interface CNPGRestoreNextStepsHost {
|
|
73
|
+
/** What the Cluster spec declares for backups; undefined when not read. */
|
|
74
|
+
backup?: RestoreBackupDeclared
|
|
75
|
+
onOpen: (step: RestoreNextStepId) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STEP_TONE: Record<RestoreNextStep['state'], HealthLevel> = { done: 'healthy', partial: 'degraded', todo: 'degraded', unknown: 'unknown' }
|
|
79
|
+
const STEP_TEXT: Record<RestoreNextStep['state'], string> = { done: 'done', partial: 'partly done', todo: 'to do', unknown: '' }
|
|
80
|
+
|
|
81
|
+
function NextSteps({ validationRecorded, host }: { validationRecorded: boolean; host: CNPGRestoreNextStepsHost }) {
|
|
82
|
+
const steps = restoreNextSteps({ validationRecorded, backup: host.backup })
|
|
83
|
+
return (
|
|
84
|
+
<div className="mt-2 border-t border-theme-border pt-2">
|
|
85
|
+
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Next steps</div>
|
|
86
|
+
<ul className="space-y-1">
|
|
87
|
+
{steps.map((s) => (
|
|
88
|
+
<li key={s.id} className="flex flex-wrap items-center gap-x-2 text-xs">
|
|
89
|
+
<StatusDot tone={STEP_TONE[s.state]} />
|
|
90
|
+
<button type="button" onClick={() => host.onOpen(s.id)} className="text-accent-text hover:underline">
|
|
91
|
+
{s.label}
|
|
92
|
+
</button>
|
|
93
|
+
{s.state !== 'unknown' && <span className={toneTextClass(STEP_TONE[s.state])}>{STEP_TEXT[s.state]}</span>}
|
|
94
|
+
{s.note && <span className="text-theme-text-tertiary">· {s.note}</span>}
|
|
95
|
+
</li>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function CNPGRestoreProgress({ namespace, name, nextSteps }: { namespace: string; name: string; nextSteps?: CNPGRestoreNextStepsHost }) {
|
|
103
|
+
const navigate = useCNPGNavigate()
|
|
104
|
+
const { observation, snapshot } = useRestoreObservation(namespace, name)
|
|
105
|
+
if (!observation || !snapshot?.recovery) return null
|
|
106
|
+
const openLogs = (pod: string) => navigate(buildWorkloadPath({ kind: 'pods', namespace, name: pod, tab: 'logs' }))
|
|
107
|
+
const source = describeRecoverySource(snapshot.recovery)
|
|
108
|
+
|
|
109
|
+
if (observation.state === 'completed') {
|
|
110
|
+
return (
|
|
111
|
+
<div className="mb-3 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm">
|
|
112
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
113
|
+
<Badge severity="success" size="sm">Restored</Badge>
|
|
114
|
+
<span className="text-theme-text-secondary">from {source}</span>
|
|
115
|
+
<span className="text-theme-text-tertiary">· {observation.detail}</span>
|
|
116
|
+
</div>
|
|
117
|
+
{nextSteps && <NextSteps validationRecorded={!!snapshot.validation} host={nextSteps} />}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const warnings = snapshot.events.filter((e) => e.type === 'Warning').slice(0, 5)
|
|
123
|
+
return (
|
|
124
|
+
<section aria-label="Restore progress" className="mb-3 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
125
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-theme-border px-4 py-2.5">
|
|
126
|
+
<Badge severity={STATE_SEVERITY[observation.state]} size="sm">
|
|
127
|
+
{observation.state === 'failed' ? 'Restore failed' : observation.state === 'unobservable' ? 'Restore' : 'Restore in progress'}
|
|
128
|
+
</Badge>
|
|
129
|
+
<span className="text-sm font-medium text-theme-text-primary">{observation.title}</span>
|
|
130
|
+
<span className="text-xs text-theme-text-tertiary">from {source}</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="space-y-3 px-4 py-3 text-sm">
|
|
133
|
+
{observation.detail && <div className="text-theme-text-secondary">{observation.detail}</div>}
|
|
134
|
+
{observation.recoveryPods.length > 0 && (
|
|
135
|
+
<div className="space-y-2">
|
|
136
|
+
{observation.recoveryPods.map((p) => (
|
|
137
|
+
<div key={p.uid} className="flex flex-wrap items-center gap-2">
|
|
138
|
+
<span className="font-mono text-[12.5px] text-theme-text-primary">{p.name}</span>
|
|
139
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
140
|
+
{p.phase}
|
|
141
|
+
{p.job ? ` · Job ${p.job}` : ''}
|
|
142
|
+
{!p.ownerVerified ? ' · Job ownership not verified' : ''}
|
|
143
|
+
{p.startedAt ? ` · started ${formatAge(p.startedAt)} ago` : ''}
|
|
144
|
+
</span>
|
|
145
|
+
{p.initContainers.map((c) => <ContainerChip key={`i-${c.name}`} c={c} init />)}
|
|
146
|
+
{p.containers.map((c) => <ContainerChip key={c.name} c={c} />)}
|
|
147
|
+
<button type="button" onClick={() => openLogs(p.name)} className="text-xs text-accent-text hover:underline">
|
|
148
|
+
Logs
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{observation.logsPod && !observation.recoveryPods.some((p) => p.name === observation.logsPod?.name) && (
|
|
155
|
+
<button type="button" onClick={() => openLogs(observation.logsPod!.name)} className="text-xs text-accent-text hover:underline">
|
|
156
|
+
Logs of {observation.logsPod.name}
|
|
157
|
+
</button>
|
|
158
|
+
)}
|
|
159
|
+
{snapshot.coverage.pods?.state !== 'ok' && (
|
|
160
|
+
<div className="text-xs text-theme-text-tertiary">Recovery Pods not visible: {snapshot.coverage.pods?.grant ? `needs ${formatGrant(snapshot.coverage.pods.grant)}` : snapshot.coverage.pods?.reason}</div>
|
|
161
|
+
)}
|
|
162
|
+
{warnings.length > 0 && (
|
|
163
|
+
<div>
|
|
164
|
+
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Warning events</div>
|
|
165
|
+
<ul className="space-y-1">
|
|
166
|
+
{warnings.map((e, i) => (
|
|
167
|
+
<li key={`${e.kind}/${e.name}/${e.reason}/${i}`} className="text-xs text-theme-text-secondary">
|
|
168
|
+
<span className="font-medium text-theme-text-primary">{e.reason}</span> on {e.kind} {e.name}
|
|
169
|
+
{e.lastSeen ? ` · ${formatAge(e.lastSeen)} ago` : ''}
|
|
170
|
+
{e.count > 1 ? ` · ×${e.count}` : ''}: {e.message}
|
|
171
|
+
</li>
|
|
172
|
+
))}
|
|
173
|
+
</ul>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
<div className="text-[11px] text-theme-text-tertiary">
|
|
177
|
+
Complete once a full recovery Job completes or a restored instance is ready. Sampled {formatAge(snapshot.capturedAt)} ago.
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
)
|
|
182
|
+
}
|