@skyhook-io/radar-app 1.16.3 → 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 +138 -93
- package/src/RadarApp.tsx +27 -17
- 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.goneResource.test.ts +49 -0
- 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 +381 -47
- 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/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- 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/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -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 +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- 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 +23 -150
- 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 +230 -124
- 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/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- 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/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- 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/RevisionHistory.tsx +3 -2
- 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/ClusterHealthCard.tsx +7 -0
- 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/home/mcpToolCatalog.ts +1 -1
- 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/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -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/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -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 +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- 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/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- 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,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { scheduleExpression, simpleSchedule } from './scheduleModel'
|
|
3
|
+
|
|
4
|
+
describe('simple schedules', () => {
|
|
5
|
+
it('round-trips only the exact common forms without interpreting custom cron', () => {
|
|
6
|
+
for (const value of ['0 30 2 * * *', '0 0 23 * * 0', '0 15 6 31 * *'])
|
|
7
|
+
expect(scheduleExpression(simpleSchedule(value)!)).toBe(value)
|
|
8
|
+
for (const value of [
|
|
9
|
+
'@daily',
|
|
10
|
+
'@every 1h',
|
|
11
|
+
'0 0 0 */2 * 1',
|
|
12
|
+
'30 0 2 * * *',
|
|
13
|
+
'0 0 2 1 * 1',
|
|
14
|
+
'0 60 2 * * *',
|
|
15
|
+
'0 0 24 * * *',
|
|
16
|
+
])
|
|
17
|
+
expect(simpleSchedule(value)).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SimpleSchedule = { frequency: 'daily' | 'weekly' | 'monthly'; time: string; day: string }
|
|
2
|
+
|
|
3
|
+
export function simpleSchedule(value: string): SimpleSchedule | null {
|
|
4
|
+
const match = value.match(/^0 (\d|[1-5]\d) (\d|1\d|2[0-3]) (\*|[1-9]|[12]\d|3[01]) \* (\*|[0-6])$/)
|
|
5
|
+
if (!match || (match[3] !== '*' && match[4] !== '*')) return null
|
|
6
|
+
return {
|
|
7
|
+
frequency: match[3] !== '*' ? 'monthly' : match[4] !== '*' ? 'weekly' : 'daily',
|
|
8
|
+
time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`,
|
|
9
|
+
day: match[3] !== '*' ? match[3] : match[4] !== '*' ? match[4] : '1',
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scheduleExpression(value: SimpleSchedule): string {
|
|
14
|
+
const [hour, minute] = value.time.split(':').map(Number)
|
|
15
|
+
return `0 ${minute} ${hour} ${value.frequency === 'monthly' ? value.day : '*'} * ${value.frequency === 'weekly' ? value.day : '*'}`
|
|
16
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { toneTextClass, Tooltip, type HealthLevel } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import {
|
|
4
|
+
describeSource,
|
|
5
|
+
formatLocal,
|
|
6
|
+
formatUTC,
|
|
7
|
+
restoreTargetKinds,
|
|
8
|
+
sourcePinsBackup,
|
|
9
|
+
type EvidencePoint,
|
|
10
|
+
type RecoveryEvidence,
|
|
11
|
+
type RestoreSource,
|
|
12
|
+
type RestoreTarget,
|
|
13
|
+
} from './restoreModel'
|
|
14
|
+
|
|
15
|
+
const FIELD = 'rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm text-theme-text-primary'
|
|
16
|
+
|
|
17
|
+
function When({ point, empty }: { point?: EvidencePoint & { wal?: string }; empty: string }) {
|
|
18
|
+
if (!point) return <span className="text-theme-text-tertiary">{empty}</span>
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<div className="font-mono text-[12.5px] text-theme-text-primary">{formatUTC(point.at)}</div>
|
|
22
|
+
<div className="text-[11px] text-theme-text-tertiary">
|
|
23
|
+
{formatLocal(point.at)}
|
|
24
|
+
{point.wal ? ` · ${point.wal}` : ''}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="text-[11px] text-theme-text-tertiary">{point.source}</div>
|
|
27
|
+
</>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function EvidenceRow({ label, children }: { label: string; children: ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-x-3 py-1.5">
|
|
34
|
+
<div className="text-xs text-theme-text-secondary">{label}</div>
|
|
35
|
+
<div className="min-w-0">{children}</div>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CNPGRecoveryPoint({
|
|
41
|
+
sources,
|
|
42
|
+
sourceIndex,
|
|
43
|
+
onSourceChange,
|
|
44
|
+
targetKind,
|
|
45
|
+
onTargetChange,
|
|
46
|
+
timeValue,
|
|
47
|
+
onTimeChange,
|
|
48
|
+
zone,
|
|
49
|
+
onZoneChange,
|
|
50
|
+
targetIso,
|
|
51
|
+
evidence,
|
|
52
|
+
sourceClusterName,
|
|
53
|
+
}: {
|
|
54
|
+
sources: RestoreSource[]
|
|
55
|
+
sourceIndex: number
|
|
56
|
+
onSourceChange: (index: number) => void
|
|
57
|
+
targetKind: RestoreTarget['kind']
|
|
58
|
+
onTargetChange: (kind: RestoreTarget['kind']) => void
|
|
59
|
+
timeValue: string
|
|
60
|
+
onTimeChange: (value: string) => void
|
|
61
|
+
zone: 'utc' | 'local'
|
|
62
|
+
onZoneChange: (value: 'utc' | 'local') => void
|
|
63
|
+
targetIso: string | null
|
|
64
|
+
evidence: RecoveryEvidence
|
|
65
|
+
sourceClusterName?: string
|
|
66
|
+
}) {
|
|
67
|
+
const source = sources[sourceIndex]
|
|
68
|
+
const pinned = sourcePinsBackup(source)
|
|
69
|
+
const snapshot = source?.kind === 'backup' && !!source.snapshot
|
|
70
|
+
const kinds = restoreTargetKinds(source)
|
|
71
|
+
const end = source?.kind === 'objectStore' || source?.kind === 'backup' ? source.backupEnd : undefined
|
|
72
|
+
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<div className="mb-4 grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-x-3">
|
|
76
|
+
<label className="text-xs text-theme-text-secondary" htmlFor="cnpg-restore-source">
|
|
77
|
+
Restore from
|
|
78
|
+
</label>
|
|
79
|
+
<select
|
|
80
|
+
id="cnpg-restore-source"
|
|
81
|
+
value={sourceIndex}
|
|
82
|
+
onChange={(e) => onSourceChange(Number(e.target.value))}
|
|
83
|
+
className={FIELD}
|
|
84
|
+
>
|
|
85
|
+
{sources.map((s, i) => (
|
|
86
|
+
<option key={i} value={i}>
|
|
87
|
+
{describeSource(s)}
|
|
88
|
+
</option>
|
|
89
|
+
))}
|
|
90
|
+
</select>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="grid gap-5 sm:grid-cols-2">
|
|
93
|
+
<div>
|
|
94
|
+
<fieldset className="space-y-1.5">
|
|
95
|
+
<legend className="mb-1 text-xs text-theme-text-secondary">Recover to</legend>
|
|
96
|
+
{kinds.includes('backupEnd') && (
|
|
97
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-primary">
|
|
98
|
+
<input
|
|
99
|
+
type="radio"
|
|
100
|
+
name="cnpg-restore-target"
|
|
101
|
+
checked={targetKind === 'backupEnd'}
|
|
102
|
+
onChange={() => onTargetChange('backupEnd')}
|
|
103
|
+
/>
|
|
104
|
+
{snapshot ? 'The end of this snapshot' : 'The end of this backup'}
|
|
105
|
+
{end ? ` (${formatUTC(end)})` : ''}
|
|
106
|
+
</label>
|
|
107
|
+
)}
|
|
108
|
+
{snapshot && (
|
|
109
|
+
<p className="ml-6 text-xs text-theme-text-tertiary">
|
|
110
|
+
No later point is offered: replaying WAL past a snapshot needs a WAL archive source, which this restore does not configure.
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
{kinds.includes('latest') && (
|
|
114
|
+
<>
|
|
115
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-primary">
|
|
116
|
+
<input
|
|
117
|
+
type="radio"
|
|
118
|
+
name="cnpg-restore-target"
|
|
119
|
+
checked={targetKind === 'latest'}
|
|
120
|
+
onChange={() => onTargetChange('latest')}
|
|
121
|
+
/>
|
|
122
|
+
The latest archived WAL
|
|
123
|
+
</label>
|
|
124
|
+
<p className="ml-6 text-xs text-theme-text-tertiary">
|
|
125
|
+
Replay the archived transaction logs to the newest available point.
|
|
126
|
+
</p>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
{kinds.includes('time') && (
|
|
130
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-primary">
|
|
131
|
+
<input
|
|
132
|
+
type="radio"
|
|
133
|
+
name="cnpg-restore-target"
|
|
134
|
+
checked={targetKind === 'time'}
|
|
135
|
+
onChange={() => onTargetChange('time')}
|
|
136
|
+
/>
|
|
137
|
+
A point in time
|
|
138
|
+
</label>
|
|
139
|
+
)}
|
|
140
|
+
{targetKind === 'time' && (
|
|
141
|
+
<div className="ml-6 space-y-1">
|
|
142
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
143
|
+
<input
|
|
144
|
+
id="cnpg-restore-time"
|
|
145
|
+
aria-label="Target time"
|
|
146
|
+
type="datetime-local"
|
|
147
|
+
step={1}
|
|
148
|
+
value={timeValue}
|
|
149
|
+
onChange={(e) => onTimeChange(e.target.value)}
|
|
150
|
+
className={FIELD}
|
|
151
|
+
/>
|
|
152
|
+
<select
|
|
153
|
+
aria-label="Time zone of the target"
|
|
154
|
+
value={zone}
|
|
155
|
+
onChange={(e) => onZoneChange(e.target.value as 'utc' | 'local')}
|
|
156
|
+
className={FIELD}
|
|
157
|
+
>
|
|
158
|
+
<option value="utc">UTC</option>
|
|
159
|
+
<option value="local">{localZone}</option>
|
|
160
|
+
</select>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="text-[11px] text-theme-text-tertiary">
|
|
163
|
+
{targetIso ? (
|
|
164
|
+
<>
|
|
165
|
+
Target: <span className="font-mono">{targetIso}</span> · {formatLocal(targetIso)}
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
'The target is written to the manifest in UTC.'
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{pinned && targetKind !== 'backupEnd' && (
|
|
174
|
+
<div className="ml-6 text-[11px] text-theme-text-tertiary">
|
|
175
|
+
Starts from this backup and replays archived WAL after it.
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</fieldset>
|
|
179
|
+
<p className="mt-4 text-xs text-theme-text-tertiary">
|
|
180
|
+
A restore creates a separate Cluster. Choose its name, instance count and storage next.
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="rounded-lg border border-theme-border bg-theme-base px-3 py-2">
|
|
184
|
+
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
185
|
+
What the source holds
|
|
186
|
+
</div>
|
|
187
|
+
<EvidenceRow label="First recoverability point">
|
|
188
|
+
<When point={evidence.firstPoint} empty={snapshot ? 'This snapshot' : source?.kind === 'backup' ? 'This backup' : 'Not reported'} />
|
|
189
|
+
</EvidenceRow>
|
|
190
|
+
<EvidenceRow label="Last successful backup">
|
|
191
|
+
<When point={evidence.lastBackup} empty="None observed" />
|
|
192
|
+
</EvidenceRow>
|
|
193
|
+
<EvidenceRow label="WAL archiving">
|
|
194
|
+
<Tooltip content={evidence.archiving.detail} disabled={!evidence.archiving.detail}>
|
|
195
|
+
<span className={toneTextClass(evidence.archiving.tone as HealthLevel)}>{evidence.archiving.text}</span>
|
|
196
|
+
</Tooltip>
|
|
197
|
+
<div className="text-[11px] text-theme-text-tertiary">{evidence.archiving.source}</div>
|
|
198
|
+
</EvidenceRow>
|
|
199
|
+
{!snapshot && (
|
|
200
|
+
<EvidenceRow label="Last archived WAL">
|
|
201
|
+
<When point={evidence.lastArchived} empty={sourceClusterName ? 'Not reported' : 'Unknown'} />
|
|
202
|
+
</EvidenceRow>
|
|
203
|
+
)}
|
|
204
|
+
{evidence.lastArchiveFailure && (
|
|
205
|
+
<EvidenceRow label="Last archive failure">
|
|
206
|
+
<When point={evidence.lastArchiveFailure} empty="" />
|
|
207
|
+
</EvidenceRow>
|
|
208
|
+
)}
|
|
209
|
+
{evidence.gaps.length > 0 && (
|
|
210
|
+
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-[11px] text-theme-text-tertiary">
|
|
211
|
+
{evidence.gaps.map((g) => (
|
|
212
|
+
<li key={g}>{g}</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPGRestoreButton } from './CNPGRestoreButton'
|
|
6
|
+
|
|
7
|
+
vi.mock('./CNPGRestoreDialog', () => ({ CNPGRestoreDialog: ({ entry }: any) => <div role="dialog">{entry.name}</div> }))
|
|
8
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
9
|
+
let root: ReturnType<typeof createRoot>
|
|
10
|
+
let host: HTMLDivElement
|
|
11
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove() })
|
|
12
|
+
|
|
13
|
+
it('keeps the blocked entry focusable for its explanation and prevents starting restore', () => {
|
|
14
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
15
|
+
act(() => root.render(<CNPGRestoreButton namespace="db" entry={{ kind: 'cluster', name: 'pg' }} disabledReason="Needs create clusters" />))
|
|
16
|
+
const button = host.querySelector('button')!
|
|
17
|
+
expect(button.disabled).toBe(false)
|
|
18
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
19
|
+
act(() => { button.focus(); button.click() })
|
|
20
|
+
expect(document.activeElement).toBe(button)
|
|
21
|
+
expect(host.querySelector('[role=dialog]')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('opens the selected source when restore is allowed', () => {
|
|
25
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
26
|
+
act(() => root.render(<CNPGRestoreButton namespace="db" entry={{ kind: 'backup', name: 'base-backup' }} />))
|
|
27
|
+
act(() => host.querySelector('button')!.click())
|
|
28
|
+
expect(host.querySelector('[role=dialog]')?.textContent).toBe('base-backup')
|
|
29
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { History } from 'lucide-react'
|
|
3
|
+
import { Tooltip } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { CNPGRestoreDialog, type CNPGRestoreEntry } from './CNPGRestoreDialog'
|
|
5
|
+
|
|
6
|
+
/** Restore into a new Cluster, from a Backup, an ObjectStore or a Cluster's backups. */
|
|
7
|
+
export function CNPGRestoreButton({ namespace, entry, disabledReason, compact }: { namespace: string; entry: CNPGRestoreEntry; disabledReason?: string; compact?: boolean }) {
|
|
8
|
+
const [open, setOpen] = useState(false)
|
|
9
|
+
const label = entry.kind === 'backup' ? 'Restore from this backup' : entry.kind === 'cluster' ? 'Restore to a new cluster' : 'Restore a cluster from this store'
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<Tooltip content={disabledReason ?? `${label} into a new Cluster`} position="bottom">
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
aria-disabled={!!disabledReason}
|
|
16
|
+
onClick={() => { if (!disabledReason) setOpen(true) }}
|
|
17
|
+
className={`btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium ${disabledReason ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
18
|
+
>
|
|
19
|
+
<History className="h-3.5 w-3.5" />
|
|
20
|
+
{!compact && label}
|
|
21
|
+
</button>
|
|
22
|
+
</Tooltip>
|
|
23
|
+
{open && <CNPGRestoreDialog namespace={namespace} entry={entry} onClose={() => setOpen(false)} />}
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { CNPGRestoreChecks } from './CNPGRestoreChecks'
|
|
4
|
+
|
|
5
|
+
const state = vi.hoisted(() => ({ estimatedRows: 0, noEstimate: 1 }))
|
|
6
|
+
vi.mock('../../../api/cnpg-inspect', () => ({
|
|
7
|
+
useCNPGRestoreChecks: () => ({
|
|
8
|
+
data: { state: 'ok', database: 'app', timeline: 2, contents: { tables: 1, ...state, largest: [] } },
|
|
9
|
+
refetch: vi.fn(),
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
it('distinguishes unavailable, partial and known-zero row estimates', () => {
|
|
14
|
+
const render = () => renderToStaticMarkup(<CNPGRestoreChecks namespace="db" name="restored" />)
|
|
15
|
+
expect(render()).toContain('Row estimate unavailable')
|
|
16
|
+
expect(render()).not.toContain('about 0 rows')
|
|
17
|
+
state.estimatedRows = 12000
|
|
18
|
+
expect(render()).toContain('about 12 k rows (partial estimate)')
|
|
19
|
+
state.estimatedRows = 0
|
|
20
|
+
state.noEstimate = 0
|
|
21
|
+
expect(render()).toContain('about 0 rows')
|
|
22
|
+
expect(render()).not.toContain('Row estimate unavailable')
|
|
23
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { cnpgFormatBytes, FactGrid, FactRow, formatAge, formatGrant, toneTextClass } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import { useCNPGRestoreChecks, type CNPGRestoreChecksResponse } from '../../../api/cnpg-inspect'
|
|
3
|
+
import { cnpgDeclaredTargetText, cnpgRecoveryStopText } from '../inspectModel'
|
|
4
|
+
|
|
5
|
+
const LIST_SHOWN = 6
|
|
6
|
+
|
|
7
|
+
function countList(names: string[], total: number): string {
|
|
8
|
+
const shown = names.slice(0, LIST_SHOWN).join(', ')
|
|
9
|
+
return total > LIST_SHOWN ? `${shown} and ${total - LIST_SHOWN} more` : shown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function approxRows(n: number): string {
|
|
13
|
+
if (n >= 1_000_000) return `about ${(n / 1_000_000).toFixed(1)} M rows`
|
|
14
|
+
if (n >= 1_000) return `about ${Math.round(n / 1_000)} k rows`
|
|
15
|
+
return `about ${n} rows`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What Radar reads inside a restored cluster's primary: where recovery
|
|
20
|
+
* stopped beside the declared target, and what the databases hold. These
|
|
21
|
+
* describe what is there; whether it is what the restore needed is still the
|
|
22
|
+
* person's call, recorded separately as the validation note.
|
|
23
|
+
*/
|
|
24
|
+
export function CNPGRestoreChecks({ namespace, name }: { namespace: string; name: string }) {
|
|
25
|
+
const q = useCNPGRestoreChecks(namespace, name)
|
|
26
|
+
const d = q.data
|
|
27
|
+
return (
|
|
28
|
+
<div className="border-b border-theme-border px-4 py-3 text-sm">
|
|
29
|
+
<div className="flex flex-wrap items-baseline gap-x-2">
|
|
30
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">What Radar reads in it</h3>
|
|
31
|
+
{d?.pod && d.state === 'ok' && (
|
|
32
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
33
|
+
{d.pod} · {d.capturedAt ? `${formatAge(d.capturedAt)} ago` : 'just now'} · fixed read-only SQL over your pods/exec
|
|
34
|
+
</span>
|
|
35
|
+
)}
|
|
36
|
+
<button type="button" onClick={() => q.refetch()} disabled={q.isFetching} className="ml-auto text-xs text-accent-text hover:underline disabled:opacity-50">
|
|
37
|
+
{q.isFetching ? 'Reading…' : 'Read again'}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="mt-2">
|
|
41
|
+
{!d ? (
|
|
42
|
+
<p className="text-theme-text-tertiary">
|
|
43
|
+
{q.isLoading ? 'Reading the restored primary…' : `Could not read it: ${q.error instanceof Error ? q.error.message : 'unknown error'}`}
|
|
44
|
+
</p>
|
|
45
|
+
) : d.state === 'denied' ? (
|
|
46
|
+
<p className="text-theme-text-secondary">
|
|
47
|
+
Reading inside the restored databases needs {formatGrant(d.permission.grant) ?? `create pods/exec in namespace ${namespace}`}.
|
|
48
|
+
</p>
|
|
49
|
+
) : d.state !== 'ok' ? (
|
|
50
|
+
<p className={toneTextClass('unknown')}>
|
|
51
|
+
Could not read the primary{d.pod ? ` ${d.pod}` : ''}: {d.error ?? d.state}.
|
|
52
|
+
</p>
|
|
53
|
+
) : (
|
|
54
|
+
<RestoreFacts d={d} />
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function RestoreFacts({ d }: { d: CNPGRestoreChecksResponse }) {
|
|
62
|
+
const history = [...(d.history ?? [])].reverse()
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<FactGrid>
|
|
66
|
+
<FactRow label="State">
|
|
67
|
+
{d.inRecovery ? (
|
|
68
|
+
<span className={toneTextClass('degraded')}>Still in recovery: it does not accept writes yet</span>
|
|
69
|
+
) : (
|
|
70
|
+
<span className="text-theme-text-primary">Out of recovery, on timeline {d.timeline}</span>
|
|
71
|
+
)}
|
|
72
|
+
</FactRow>
|
|
73
|
+
<FactRow label="Where recovery stopped">
|
|
74
|
+
{d.historyMissing ? (
|
|
75
|
+
<span className="text-theme-text-tertiary">No timeline history: timeline {d.timeline} never switched</span>
|
|
76
|
+
) : history.length === 0 ? (
|
|
77
|
+
<span className="text-theme-text-tertiary">The history file lists no switches</span>
|
|
78
|
+
) : (
|
|
79
|
+
<ul className="space-y-0.5">
|
|
80
|
+
{history.slice(0, 4).map((sw) => (
|
|
81
|
+
<li key={`${sw.from}-${sw.switchLsn}`}>
|
|
82
|
+
<span className="font-mono text-xs text-theme-text-tertiary">
|
|
83
|
+
timeline {sw.from} → {sw.to} at {sw.switchLsn}
|
|
84
|
+
</span>{' '}
|
|
85
|
+
<span className="text-theme-text-primary">{cnpgRecoveryStopText(sw)}</span>
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
)}
|
|
90
|
+
<div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">
|
|
91
|
+
Declared target: {cnpgDeclaredTargetText(d.target)}. From the current timeline’s history file, newest first; later switches are failovers or switchovers since
|
|
92
|
+
the restore.
|
|
93
|
+
</div>
|
|
94
|
+
</FactRow>
|
|
95
|
+
<FactRow label="Databases">
|
|
96
|
+
{d.databases && d.databases.length > 0 ? (
|
|
97
|
+
<span>
|
|
98
|
+
{countList(
|
|
99
|
+
d.databases.map((x) => `${x.name} ${cnpgFormatBytes(x.bytes)}`),
|
|
100
|
+
d.databaseCount ?? d.databases.length,
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
) : (
|
|
104
|
+
<span className="text-theme-text-tertiary">None besides the templates</span>
|
|
105
|
+
)}
|
|
106
|
+
</FactRow>
|
|
107
|
+
<FactRow label="Roles">
|
|
108
|
+
{d.roles && d.roles.length > 0 ? (
|
|
109
|
+
<span>
|
|
110
|
+
{countList(
|
|
111
|
+
d.roles.map((r) => r.name),
|
|
112
|
+
d.roleCount ?? d.roles.length,
|
|
113
|
+
)}
|
|
114
|
+
</span>
|
|
115
|
+
) : (
|
|
116
|
+
<span className="text-theme-text-tertiary">None</span>
|
|
117
|
+
)}
|
|
118
|
+
</FactRow>
|
|
119
|
+
<FactRow label={<span className="font-mono">{d.database}</span>}>
|
|
120
|
+
{d.contents ? (
|
|
121
|
+
<>
|
|
122
|
+
<span className="text-theme-text-primary">
|
|
123
|
+
{d.contents.tables} {d.contents.tables === 1 ? 'table' : 'tables'} ·{' '}
|
|
124
|
+
{d.contents.noEstimate > 0 && d.contents.estimatedRows === 0
|
|
125
|
+
? 'Row estimate unavailable'
|
|
126
|
+
: `${approxRows(d.contents.estimatedRows)}${d.contents.noEstimate > 0 ? ' (partial estimate)' : ''}`}
|
|
127
|
+
</span>
|
|
128
|
+
{d.contents.largest.length > 0 && (
|
|
129
|
+
<div className="mt-0.5 text-xs text-theme-text-secondary">
|
|
130
|
+
Largest:{' '}
|
|
131
|
+
{d.contents.largest
|
|
132
|
+
.slice(0, 3)
|
|
133
|
+
.map((t) => `${t.name} ${cnpgFormatBytes(t.bytes)}${t.estimatedRows !== undefined ? ` (${approxRows(t.estimatedRows).replace('about ', '~')})` : ''}`)
|
|
134
|
+
.join(' · ')}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
<div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">
|
|
138
|
+
Row counts are the planner’s estimates, which can be stale
|
|
139
|
+
{d.contents.noEstimate > 0 ? `; ${d.contents.noEstimate} without an estimate, so not counted` : ''}.
|
|
140
|
+
</div>
|
|
141
|
+
</>
|
|
142
|
+
) : (
|
|
143
|
+
<span className="text-theme-text-tertiary">Not read: {d.contentsSource?.error ?? 'unknown reason'}</span>
|
|
144
|
+
)}
|
|
145
|
+
</FactRow>
|
|
146
|
+
</FactGrid>
|
|
147
|
+
<p className="mt-2 text-[11.5px] text-theme-text-tertiary">
|
|
148
|
+
This describes what is there, not whether it is what you expected. The bootstrap database and owner exist whether or not the backup held them: CloudNativePG
|
|
149
|
+
creates them when missing.
|
|
150
|
+
</p>
|
|
151
|
+
</>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, type ReactNode } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import yaml from 'yaml'
|
|
6
|
+
import { CNPGRestoreDialog } from './CNPGRestoreDialog'
|
|
7
|
+
|
|
8
|
+
const state = vi.hoisted(() => ({ context: 'kind-demo', editor: {} as any, toast: vi.fn() }))
|
|
9
|
+
vi.mock('../../../api/cnpg', () => ({
|
|
10
|
+
useCNPGWorkspace: () => ({ isLoading: false, data: { coverage: { backups: { state: 'full' }, objectStores: { state: 'full' } }, objects: { clusters: [{ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'pg', namespace: 'db' }, spec: { imageName: 'postgres:17', storage: { size: '20Gi' }, backup: { barmanObjectStore: { destinationPath: 's3://archive' } } } }], backups: [] } } }),
|
|
11
|
+
useCNPGRuntime: () => ({}),
|
|
12
|
+
}))
|
|
13
|
+
vi.mock('../../../api/client', () => ({ useResources: () => ({ data: [] }) }))
|
|
14
|
+
vi.mock('../../../api/cnpg-recovery', () => ({ useCNPGRestoreCapability: () => ({ data: { allowed: true } }) }))
|
|
15
|
+
vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context } }) }))
|
|
16
|
+
vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: state.toast }) }))
|
|
17
|
+
vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
|
|
18
|
+
vi.mock('../../shared/CreateResourceDialog', () => ({ CreateResourceDialog: (props: any) => { state.editor = props; return <pre>{props.initialYaml}</pre> } }))
|
|
19
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({
|
|
20
|
+
...(await original<typeof import('@skyhook-io/k8s-ui')>()),
|
|
21
|
+
ActionConfirmDialog: ({ onConfirm, onBack, backLabel, confirmLabel, disabledReason, incompleteReason, notes, children }: { onConfirm: () => void; onBack?: () => void; backLabel?: string; confirmLabel: string; disabledReason?: string; incompleteReason?: string; notes?: string[]; children: ReactNode }) => <div>{notes?.join(' ')}{disabledReason}{children}{onBack && <button onClick={onBack}>{backLabel}</button>}<button disabled={!!disabledReason || !!incompleteReason} onClick={onConfirm}>{confirmLabel}</button></div>,
|
|
22
|
+
}))
|
|
23
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
24
|
+
let root: ReturnType<typeof createRoot>, host: HTMLDivElement
|
|
25
|
+
const onClose = vi.fn()
|
|
26
|
+
const button = (text: string) => [...document.querySelectorAll('button')].find((item) => item.textContent?.trim() === text)!
|
|
27
|
+
const render = () => root.render(<CNPGRestoreDialog namespace="db" entry={{ kind: 'cluster', name: 'pg' }} onClose={onClose} />)
|
|
28
|
+
const changeField = (field: string, value: string) => act(() => {
|
|
29
|
+
const input = host.querySelector(`#cnpg-restore-${field}`)!
|
|
30
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
|
|
31
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
32
|
+
})
|
|
33
|
+
const changeName = (value: string) => changeField('name', value)
|
|
34
|
+
const selectTarget = (label: string) => act(() => [...host.querySelectorAll('label')].find((item) => item.textContent?.trim() === label)!.querySelector<HTMLInputElement>('input')!.click())
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
state.context = 'kind-demo'
|
|
37
|
+
onClose.mockClear()
|
|
38
|
+
state.toast.mockClear()
|
|
39
|
+
host = document.createElement('div')
|
|
40
|
+
document.body.appendChild(host)
|
|
41
|
+
root = createRoot(host)
|
|
42
|
+
await act(async () => render())
|
|
43
|
+
act(() => button('Continue to new Cluster').click())
|
|
44
|
+
})
|
|
45
|
+
afterEach(() => { act(() => root.unmount()); host.remove() })
|
|
46
|
+
|
|
47
|
+
it('retains setup choices and arbitrary YAML edits through Back', () => {
|
|
48
|
+
changeName('custom-restore')
|
|
49
|
+
act(() => button('Review manifest').click())
|
|
50
|
+
const draft = `${state.editor.initialYaml}\n# retained advanced settings\n`
|
|
51
|
+
act(() => state.editor.onBack(draft))
|
|
52
|
+
expect((host.querySelector('#cnpg-restore-name') as HTMLInputElement).value).toBe('custom-restore')
|
|
53
|
+
expect(host.textContent).toContain('Your YAML edits are retained')
|
|
54
|
+
act(() => button('Review manifest').click())
|
|
55
|
+
expect(state.editor.initialYaml).toBe(draft)
|
|
56
|
+
expect(state.editor.initialMode).toBe('create')
|
|
57
|
+
expect(state.editor.lockMode).toBe(true)
|
|
58
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('requires an explicit replacement when changed setup would discard edited YAML', () => {
|
|
62
|
+
act(() => button('Review manifest').click())
|
|
63
|
+
const draft = `${state.editor.initialYaml}\n# keep this edit\n`
|
|
64
|
+
act(() => state.editor.onBack(draft))
|
|
65
|
+
act(() => button('Back to recovery point').click())
|
|
66
|
+
selectTarget('A point in time')
|
|
67
|
+
changeField('time', '2026-10-01T12:00')
|
|
68
|
+
act(() => button('Continue to new Cluster').click())
|
|
69
|
+
act(() => button('Review manifest').click())
|
|
70
|
+
expect(document.body.textContent).toContain('Replace edited restore manifest?')
|
|
71
|
+
act(() => button('Keep editing setup').click())
|
|
72
|
+
act(() => button('Back to recovery point').click())
|
|
73
|
+
selectTarget('The latest archived WAL')
|
|
74
|
+
act(() => button('Continue to new Cluster').click())
|
|
75
|
+
act(() => button('Review manifest').click())
|
|
76
|
+
expect(state.editor.initialYaml).toBe(draft)
|
|
77
|
+
act(() => state.editor.onBack(draft))
|
|
78
|
+
act(() => button('Back to recovery point').click())
|
|
79
|
+
selectTarget('A point in time')
|
|
80
|
+
act(() => button('Continue to new Cluster').click())
|
|
81
|
+
act(() => button('Review manifest').click())
|
|
82
|
+
act(() => button('Replace manifest').click())
|
|
83
|
+
expect(yaml.parse(state.editor.initialYaml).spec.bootstrap.recovery.recoveryTarget.targetTime).toContain('2026-10-01T12:00:00')
|
|
84
|
+
expect(state.editor.initialYaml).not.toContain('keep this edit')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rebuilds an unedited draft after setup changes and preserves empty edited drafts', () => {
|
|
88
|
+
act(() => button('Review manifest').click())
|
|
89
|
+
act(() => state.editor.onBack(state.editor.initialYaml))
|
|
90
|
+
changeName('new-target')
|
|
91
|
+
act(() => button('Review manifest').click())
|
|
92
|
+
expect(yaml.parse(state.editor.initialYaml).metadata.name).toBe('new-target')
|
|
93
|
+
act(() => state.editor.onBack(''))
|
|
94
|
+
act(() => button('Continue editing current YAML').click())
|
|
95
|
+
expect(state.editor.initialYaml).toBe('')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('refuses to reuse the restore draft after a context switch', async () => {
|
|
99
|
+
act(() => button('Review manifest').click())
|
|
100
|
+
state.context = 'kind-other'
|
|
101
|
+
await act(async () => render())
|
|
102
|
+
expect(host.querySelector('pre')).toBeNull()
|
|
103
|
+
expect(host.textContent).toContain('start again in the current context')
|
|
104
|
+
expect(button('Review manifest').disabled).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
it('projects supported YAML inputs and preserves other edits when target fields change', () => {
|
|
109
|
+
act(() => button('Review manifest').click())
|
|
110
|
+
const doc = yaml.parseDocument(state.editor.initialYaml, { version: '1.1' })
|
|
111
|
+
doc.setIn(['spec', 'storage', 'size'], '40Gi')
|
|
112
|
+
doc.setIn(['spec', 'postgresql'], { parameters: { max_connections: '250' } })
|
|
113
|
+
const draft = `${doc.toString()}\n# preserve this note\n`
|
|
114
|
+
expect(yaml.parse(draft).spec.postgresql).toEqual({ parameters: { max_connections: '250' } })
|
|
115
|
+
act(() => state.editor.onBack(draft))
|
|
116
|
+
expect((host.querySelector('#cnpg-restore-size') as HTMLInputElement).value).toBe('40Gi')
|
|
117
|
+
changeName('different-target')
|
|
118
|
+
changeField('instances', '2')
|
|
119
|
+
act(() => button('Review manifest').click())
|
|
120
|
+
const manifest = yaml.parse(state.editor.initialYaml)
|
|
121
|
+
expect(manifest.metadata.name).toBe('different-target')
|
|
122
|
+
expect(manifest.spec.instances).toBe(2)
|
|
123
|
+
expect(manifest.spec.storage.size).toBe('40Gi')
|
|
124
|
+
expect(manifest.spec.postgresql.parameters.max_connections).toBe('250')
|
|
125
|
+
expect(state.editor.initialYaml).toContain('preserve this note')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('does not claim an edited initdb manifest started a restore', () => {
|
|
129
|
+
act(() => button('Review manifest').click())
|
|
130
|
+
const doc = yaml.parseDocument(state.editor.initialYaml)
|
|
131
|
+
doc.setIn(['spec', 'bootstrap'], { initdb: {} })
|
|
132
|
+
const created = { kind: 'Cluster', apiVersion: 'postgresql.cnpg.io/v1', namespace: 'db', name: 'pg-restore' }
|
|
133
|
+
act(() => state.editor.onCreated(created, doc.toString()))
|
|
134
|
+
expect(state.toast).toHaveBeenCalledWith('Cluster pg-restore created', 'Review its current state on the Cluster page.', expect.objectContaining({ label: 'Open Cluster' }))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
it('puts recovery decisions first and keeps target inputs when stepping back', () => {
|
|
139
|
+
changeName('chosen-target')
|
|
140
|
+
act(() => button('Back to recovery point').click())
|
|
141
|
+
expect(host.querySelector('#cnpg-restore-name')).toBeNull()
|
|
142
|
+
expect(host.textContent).toContain('What the source holds')
|
|
143
|
+
selectTarget('A point in time')
|
|
144
|
+
expect(button('Continue to new Cluster').disabled).toBe(true)
|
|
145
|
+
changeField('time', '2026-10-01T12:00')
|
|
146
|
+
act(() => button('Continue to new Cluster').click())
|
|
147
|
+
expect((host.querySelector('#cnpg-restore-name') as HTMLInputElement).value).toBe('chosen-target')
|
|
148
|
+
expect(host.textContent).toContain('Recover to 2026-10-01 12:00:00 UTC')
|
|
149
|
+
})
|