@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,223 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
vi.mock('./useCNPGKubectlContext', () => ({ useCNPGKubectlContext: () => ({ name: 'kind-test', source: 'test-config' }) }))
|
|
8
|
+
import { CNPGConfiguration, CNPGDeclaredSettings } from './CNPGConfiguration'
|
|
9
|
+
|
|
10
|
+
const state = vi.hoisted(() => ({ cluster: {} as any, params: {} as any, ha: {} as any, row: undefined as any, workspace: {} as any, navigate: vi.fn() }))
|
|
11
|
+
vi.mock('../../api/client', () => ({ useResource: (kind: string, ns: string, name: string, group: string) => {
|
|
12
|
+
expect([kind, ns, name, group]).toEqual(['clusters', 'db', 'pg', 'postgresql.cnpg.io'])
|
|
13
|
+
return { data: state.cluster }
|
|
14
|
+
} }))
|
|
15
|
+
vi.mock('../../api/cnpg-inspect', () => ({ useCNPGParameters: () => state.params }))
|
|
16
|
+
vi.mock('./useCNPGClusterAssessment', () => ({ useCNPGClusterAssessment: () => ({ query: state.workspace, row: state.row, ha: state.ha }) }))
|
|
17
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
|
|
18
|
+
vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
|
|
19
|
+
|
|
20
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
21
|
+
|
|
22
|
+
const render = () => renderToStaticMarkup(<MemoryRouter><CNPGConfiguration namespace="db" name="pg" onNavigate={() => {}} onSelectTab={() => {}} /></MemoryRouter>)
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
state.cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { namespace: 'db', name: 'pg' }, spec: { postgresql: { parameters: { shared_buffers: '256MB', work_mem: '8MB' } } }, status: { conditions: [{ type: 'Ready', status: 'False' }] } }
|
|
25
|
+
state.params = { data: { state: 'denied', declared: [], permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] } }
|
|
26
|
+
state.ha = { error: new Error('HA read denied') }
|
|
27
|
+
state.row = undefined
|
|
28
|
+
state.workspace = {}
|
|
29
|
+
state.navigate.mockClear()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('Configuration composition', () => {
|
|
33
|
+
it('shows declarations when exec is denied, without duplicating status, events or certificates', () => {
|
|
34
|
+
const html = render()
|
|
35
|
+
for (const text of ['shared_buffers', '256MB', 'work_mem', '8MB', 'Reported by instance', 'create pods/exec', 'Certificates could not be read: HA read denied']) expect(html).toContain(text)
|
|
36
|
+
for (const text of ['Conditions', 'Recent Events', 'Audit Findings', 'Cluster Overview', 'on this tab below']) expect(html).not.toContain(text)
|
|
37
|
+
expect(html.match(/>Certificates</g)).toHaveLength(1)
|
|
38
|
+
expect(html.indexOf('Connect')).toBeLessThan(html.indexOf('PostgreSQL parameters'))
|
|
39
|
+
expect(html.indexOf('PostgreSQL parameters')).toBeLessThan(html.indexOf('Certificates'))
|
|
40
|
+
expect(html.indexOf('Certificates')).toBeLessThan(html.indexOf('Labels and annotations'))
|
|
41
|
+
expect(html).toContain('aria-expanded="false"')
|
|
42
|
+
expect(html).toContain('kubectl --context kind-test -n db port-forward service/pg-rw')
|
|
43
|
+
expect(html).toContain('Context as named in your kubeconfig (test-config); kubectl must read the same kubeconfig file.')
|
|
44
|
+
})
|
|
45
|
+
it.each([{ isLoading: true }, { error: new Error('runtime failed') }])('keeps Cluster declarations when observations are unavailable: %j', (query) => {
|
|
46
|
+
state.params = query
|
|
47
|
+
const html = render()
|
|
48
|
+
expect(html).toContain('shared_buffers')
|
|
49
|
+
expect(html).toContain('256MB')
|
|
50
|
+
expect(html).toContain('Not read:')
|
|
51
|
+
expect(html).not.toContain('>In effect<')
|
|
52
|
+
})
|
|
53
|
+
it('names which instances answered each parameter and which restart is pending', () => {
|
|
54
|
+
state.params = { data: { state: 'partial', declared: [{ name: 'shared_buffers', value: '256MB' }, { name: 'work_mem', value: '8MB' }], permission: { exec: 'allowed' }, instances: [
|
|
55
|
+
{ pod: 'pg-1', state: 'ok', settings: [{ name: 'shared_buffers', value: '256MB', source: 'configuration file', context: 'postmaster' }, { name: 'work_mem', value: '8MB', context: 'user' }] },
|
|
56
|
+
{ pod: 'pg-2', state: 'ok', settings: [{ name: 'shared_buffers', value: '128MB', source: 'configuration file', context: 'postmaster', pendingRestart: true }] },
|
|
57
|
+
{ pod: 'pg-3', state: 'unreachable', error: 'timeout' },
|
|
58
|
+
] } }
|
|
59
|
+
const html = render()
|
|
60
|
+
for (const text of ['pg-1: 256MB', 'pg-2: 128MB', 'Restart pending on pg-2', 'Not reported by pg-2', 'pg-3 (timeout)', '>restart<', 'reload']) expect(html).toContain(text)
|
|
61
|
+
})
|
|
62
|
+
it('shows parameters beyond the sampled set as declared and unreported', () => {
|
|
63
|
+
state.cluster.spec.postgresql.parameters.extra = 'x'
|
|
64
|
+
state.params = { data: { state: 'ok', declared: [{ name: 'shared_buffers', value: 'old declaration' }], permission: { exec: 'allowed' }, instances: [], omitted: 1 } }
|
|
65
|
+
const html = render()
|
|
66
|
+
expect(html).toContain('extra')
|
|
67
|
+
expect(html).toContain('256MB')
|
|
68
|
+
expect(html).not.toContain('old declaration')
|
|
69
|
+
expect(html).toContain('no instance Pod to read')
|
|
70
|
+
expect(html).toContain('1 declaration was not sampled')
|
|
71
|
+
expect(html).not.toContain('>unknown<')
|
|
72
|
+
})
|
|
73
|
+
it('renders the shared certificate section once when HA answers, including the renewal owner', () => {
|
|
74
|
+
state.ha = { data: { cluster: { namespace: 'db', name: 'pg' }, rwEndpoints: { state: 'denied', pods: [] }, pods: { state: 'denied' }, instances: [], certificates: [{ secret: 'pg-server', raw: '2100-01-01', expiresAt: '2100-01-01T00:00:00Z', renewal: 'operator' }] } }
|
|
75
|
+
const html = render()
|
|
76
|
+
expect(html.match(/>Certificates</g)).toHaveLength(1)
|
|
77
|
+
expect(html).toContain('pg-server')
|
|
78
|
+
expect(html).toContain('CloudNativePG renews it')
|
|
79
|
+
expect(html).not.toContain('Certificates could not be read')
|
|
80
|
+
})
|
|
81
|
+
it('keeps cached parameter observations with a refresh-failed notice', () => {
|
|
82
|
+
state.params = { isRefetchError: true, dataUpdatedAt: Date.now() - 60_000, error: new Error('refresh timed out'), data: { state: 'ok', sampledAt: '2026-10-04T00:00:00Z', declared: [{ name: 'work_mem', value: '8MB' }], permission: { exec: 'allowed' }, instances: [{ pod: 'pg-1', state: 'ok', settings: [{ name: 'work_mem', value: '8MB', context: 'user' }] }] } }
|
|
83
|
+
const html = render()
|
|
84
|
+
expect(html).toContain('Last refresh failed: refresh timed out')
|
|
85
|
+
expect(html).toContain('showing data from')
|
|
86
|
+
expect(html).toContain('>8MB</span>')
|
|
87
|
+
expect(html).toContain('read on pg-1')
|
|
88
|
+
expect(html).not.toContain('Not read: refresh timed out')
|
|
89
|
+
})
|
|
90
|
+
it('distinguishes skipped names, no readable instances and a parameter missing from a successful read', () => {
|
|
91
|
+
state.cluster.spec.postgresql.parameters['invalid name'] = 'x'
|
|
92
|
+
state.params = { data: { state: 'ok', declared: Object.entries(state.cluster.spec.postgresql.parameters).map(([name, value]) => ({ name, value })), skipped: ['invalid name'], permission: { exec: 'allowed' }, instances: [{ pod: 'pg-1', state: 'unreachable', error: 'timeout' }] } }
|
|
93
|
+
let html = render()
|
|
94
|
+
expect(html).toContain('Not read (not a parameter name): invalid name')
|
|
95
|
+
expect(html).toContain('no instance answered')
|
|
96
|
+
expect(html).not.toContain('>unknown<')
|
|
97
|
+
state.params.data.instances = [{ pod: 'pg-1', state: 'ok', settings: [] }]
|
|
98
|
+
html = render()
|
|
99
|
+
expect(html).toContain('Not reported by pg-1')
|
|
100
|
+
})
|
|
101
|
+
it('opens Declarations scoped to this Cluster with return context', () => {
|
|
102
|
+
state.cluster.spec.managed = { roles: [{ name: 'reader' }] }
|
|
103
|
+
const host = document.createElement('div')
|
|
104
|
+
const root = createRoot(host)
|
|
105
|
+
act(() => root.render(<MemoryRouter><CNPGConfiguration namespace="db" name="pg" onNavigate={() => {}} onSelectTab={() => {}} /></MemoryRouter>))
|
|
106
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Declarations →')!.click())
|
|
107
|
+
expect(state.navigate).toHaveBeenCalledWith('/cnpg/declarations?cluster=db%2Fpg', { state: { returnLabel: expect.any(String), returnCtx: 'test' } })
|
|
108
|
+
act(() => root.unmount())
|
|
109
|
+
})
|
|
110
|
+
it('labels a retained workspace row when its Pooler refresh fails', () => {
|
|
111
|
+
state.row = { poolersKnown: true, poolerObjects: [{ metadata: { namespace: 'db', name: 'pg-pool' }, spec: { cluster: { name: 'pg' } } }] }
|
|
112
|
+
state.workspace = { isRefetchError: true, dataUpdatedAt: Date.now() - 60_000, error: new Error('workspace timed out') }
|
|
113
|
+
const html = render()
|
|
114
|
+
expect(html).toContain('pg-pool')
|
|
115
|
+
expect(html).toContain('Last refresh failed: workspace timed out')
|
|
116
|
+
expect(html).toContain('showing data from')
|
|
117
|
+
})
|
|
118
|
+
it('labels newly declared parameters as unsampled rather than inferring absent Pods', () => {
|
|
119
|
+
state.params = { data: { state: 'ok', declared: [], instances: [], permission: { exec: 'allowed' } } }
|
|
120
|
+
const html = render()
|
|
121
|
+
expect(html).toContain('2 declared · no parameters sampled')
|
|
122
|
+
expect(html).not.toContain('no instance Pod')
|
|
123
|
+
expect(html).toContain('no parameters sampled')
|
|
124
|
+
expect(html).not.toContain('>unknown<')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('Declared settings', () => {
|
|
129
|
+
const settings = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
|
|
130
|
+
it('omits empty cards and does not invent defaults from the restore preflight', () => {
|
|
131
|
+
state.cluster.spec = {}
|
|
132
|
+
state.cluster.status.image = 'running:17'
|
|
133
|
+
expect(settings()).toBe('')
|
|
134
|
+
state.cluster.spec = { instances: 2, enableSuperuserAccess: false }
|
|
135
|
+
const html = settings()
|
|
136
|
+
expect(html).toContain('Instances and image')
|
|
137
|
+
expect(html).toContain('Disabled')
|
|
138
|
+
for (const title of ['Placement and resources', 'Storage', 'Bootstrap and source', 'Backups configuration', 'Roles, services and monitoring']) expect(html).not.toContain(title)
|
|
139
|
+
})
|
|
140
|
+
it('omits empty storage stanzas and names PVC template size and class paths precisely', () => {
|
|
141
|
+
state.cluster.spec = { storage: {}, walStorage: {}, tablespaces: [], resources: { requests: {}, limits: {} }, affinity: { tolerations: [], nodeSelector: {} }, replica: {}, monitoring: {}, managed: { roles: [], services: { additional: [], disabledDefaultServices: [] } }, plugins: [] }
|
|
142
|
+
expect(settings()).toBe('')
|
|
143
|
+
state.cluster.spec.storage = { pvcTemplate: { resources: { requests: { storage: '30Gi' } }, storageClassName: 'template-class' } }
|
|
144
|
+
const html = settings()
|
|
145
|
+
for (const text of ['30Gi', 'template-class', 'spec.storage.pvcTemplate.resources.requests.storage', 'spec.storage.pvcTemplate.storageClassName']) expect(html).toContain(text)
|
|
146
|
+
expect(html).not.toContain('size from pvcTemplate')
|
|
147
|
+
})
|
|
148
|
+
it('shows each populated card with spec paths and links to operational evidence', () => {
|
|
149
|
+
state.cluster.spec = {
|
|
150
|
+
instances: 3, imageName: 'desired:17', primaryUpdateStrategy: 'supervised', primaryUpdateMethod: 'switchover', minSyncReplicas: 1, maxSyncReplicas: 2, postgresql: { synchronous: { method: 'any', number: 1, dataDurability: 'required' } },
|
|
151
|
+
affinity: { enablePodAntiAffinity: true, topologyKey: 'zone', nodeSelector: { disk: 'ssd' } },
|
|
152
|
+
topologySpreadConstraints: [{ topologyKey: 'zone', maxSkew: 1, whenUnsatisfiable: 'DoNotSchedule' }], resources: { requests: { cpu: '500m' }, limits: { memory: '1Gi' } }, priorityClassName: 'database',
|
|
153
|
+
storage: { size: '20Gi', storageClass: 'fast' }, walStorage: { size: '5Gi', storageClass: 'wal' }, tablespaces: [{ name: 'analytics', storage: { size: '10Gi', storageClass: 'slow' } }],
|
|
154
|
+
bootstrap: { initdb: { database: 'appdb', owner: 'owner' } }, replica: { enabled: false, source: 'origin' }, externalClusters: [{ name: 'origin', connectionParameters: { password: 'DO-NOT-DISPLAY' } }],
|
|
155
|
+
backup: { barmanObjectStore: { destinationPath: 's3://bucket' }, retentionPolicy: '7d', volumeSnapshot: { className: 'csi' }, target: 'primary' },
|
|
156
|
+
managed: { roles: [{ name: 'reader' }], services: { disabledDefaultServices: ['ro'], additional: [{ serviceTemplate: { metadata: { name: 'custom' } } }] } },
|
|
157
|
+
monitoring: { enablePodMonitor: false, customQueriesConfigMap: [{ name: 'queries', key: 'sql' }], customQueriesSecret: [{ name: 'secret-queries', key: 'sql' }] },
|
|
158
|
+
plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'archive', serverName: 'origin-server' } }],
|
|
159
|
+
}
|
|
160
|
+
state.cluster.status.image = 'running:16'
|
|
161
|
+
const html = settings()
|
|
162
|
+
for (const title of ['Instances and image', 'Placement and resources', 'Storage', 'Bootstrap and source', 'Backups configuration', 'Roles, services and monitoring']) expect(html).toContain(title)
|
|
163
|
+
for (const value of ['desired:17', 'running:16', 'status.image', 'spec.instances', 'spec.primaryUpdateMethod', 'disk=ssd', 'max skew 1', '500m', '20Gi', '5Gi', 'analytics', 'appdb', 'owner', 'origin', 's3://bucket', 'volumeSnapshot', '7d', 'archive', 'custom', 'queries', 'secret-queries', 'Declarations', 'Usage → Storage tab', 'Runs and recovery evidence → Backups tab']) expect(html).toContain(value)
|
|
164
|
+
expect(html).not.toContain('DO-NOT-DISPLAY')
|
|
165
|
+
for (const path of ['spec.minSyncReplicas', 'spec.maxSyncReplicas', 'spec.postgresql.synchronous.method', 'spec.backup.target', '.isWALArchiver', '.parameters.serverName']) expect(html).toContain(path)
|
|
166
|
+
})
|
|
167
|
+
it('links image catalogs and preserves their declared major alongside the resolved image', () => {
|
|
168
|
+
state.cluster.spec = { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'catalog', major: 17 } }
|
|
169
|
+
state.cluster.status.image = 'resolved:17'
|
|
170
|
+
const html = settings()
|
|
171
|
+
expect(html).toContain('catalog')
|
|
172
|
+
expect(html).toContain('major 17')
|
|
173
|
+
expect(html).toContain('resolved:17')
|
|
174
|
+
expect(html).toContain('spec.imageCatalogRef')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it.each(['unsupervised', 'supervised'])('explains %s and anti-affinity while keeping raw fields', (strategy) => {
|
|
179
|
+
state.cluster.spec = { primaryUpdateStrategy: strategy, primaryUpdateMethod: 'restart', affinity: { podAntiAffinityType: 'preferred' } }
|
|
180
|
+
const html = renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
|
|
181
|
+
expect(html).toContain(strategy === 'unsupervised' ? 'Updates the primary automatically' : 'Waits for a manual switchover')
|
|
182
|
+
expect(html).toContain(`${strategy} · spec.primaryUpdateStrategy`)
|
|
183
|
+
expect(html).toContain('Pod anti-affinity')
|
|
184
|
+
expect(html).toContain('Spreads instances across nodes when possible')
|
|
185
|
+
expect(html).toContain('preferred · spec.affinity.podAntiAffinityType')
|
|
186
|
+
expect(html).toContain('Updates the primary in place')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('does not promise spreading when anti-affinity is disabled and respects its topology', () => {
|
|
190
|
+
state.cluster.spec = { affinity: { podAntiAffinityType: 'preferred', enablePodAntiAffinity: false } }
|
|
191
|
+
const render = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
|
|
192
|
+
expect(render()).toContain('Not applied: pod anti-affinity is disabled')
|
|
193
|
+
expect(render()).not.toContain('Spreads instances across')
|
|
194
|
+
state.cluster.spec.affinity = { podAntiAffinityType: 'preferred', topologyKey: 'topology.kubernetes.io/zone' }
|
|
195
|
+
expect(render()).toContain('Spreads instances across topology domains when possible')
|
|
196
|
+
expect(render()).toContain('topology.kubernetes.io/zone')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('explains required anti-affinity for node and zone topology', () => {
|
|
200
|
+
state.cluster.spec = { affinity: { podAntiAffinityType: 'required' } }
|
|
201
|
+
const render = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
|
|
202
|
+
expect(render()).toContain('Requires instances on separate nodes')
|
|
203
|
+
state.cluster.spec.affinity.topologyKey = 'topology.kubernetes.io/zone'
|
|
204
|
+
expect(render()).toContain('Requires instances on separate topology domains')
|
|
205
|
+
expect(render()).toContain('required · spec.affinity.podAntiAffinityType')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('shows one shared denial while retaining every declaration with empty observation cells', () => {
|
|
209
|
+
const host = document.createElement('div'); host.innerHTML = render()
|
|
210
|
+
const table = [...host.querySelectorAll('table')].find((t) => t.textContent?.includes('Reported by instance'))!
|
|
211
|
+
expect(table.querySelectorAll('tbody tr')).toHaveLength(2)
|
|
212
|
+
for (const r of table.querySelectorAll('tbody tr')) {
|
|
213
|
+
expect(r.children[2].textContent).toBe('')
|
|
214
|
+
expect(r.children[3].textContent).toBe('')
|
|
215
|
+
}
|
|
216
|
+
expect(host.textContent!.match(/Reading instance values needs/g)).toHaveLength(1)
|
|
217
|
+
})
|
|
218
|
+
it('labels the operator target image without claiming a running image', () => {
|
|
219
|
+
state.cluster.spec = { imageName: 'declared:17' }; state.cluster.status.image = 'target:17'
|
|
220
|
+
const html = render()
|
|
221
|
+
expect(html).toContain('Target image (status.image): target:17')
|
|
222
|
+
expect(html).not.toContain('Running target:17')
|
|
223
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useCNPGKubectlContext } from './useCNPGKubectlContext'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
CNPG_GROUP, CNPG_BARMAN_OBJECTSTORE_GROUP, CNPGClusterCertificates, CNPGConnectSection,
|
|
5
|
+
FactGrid, FactRow, FoldSection, KeyValueBadgeList, PaneLoader, RefLink, SectionHeading,
|
|
6
|
+
cnpgConnectInfo, getCNPGClusterBackupConfig, getCNPGClusterPostgresParams,
|
|
7
|
+
getCNPGClusterReplicaSource, refToSelectedResource, type NavigateToRef,
|
|
8
|
+
} from '@skyhook-io/k8s-ui'
|
|
9
|
+
import { useResource } from '../../api/client'
|
|
10
|
+
import type { SelectedResource } from '../../types'
|
|
11
|
+
import { buildWorkloadPath } from '../../utils/navigation'
|
|
12
|
+
import { RefreshFailedNotice } from '../workspace/layout'
|
|
13
|
+
import { useCNPGClusterAssessment } from './useCNPGClusterAssessment'
|
|
14
|
+
import { useCNPGNavigate } from './useCNPGNavigate'
|
|
15
|
+
import { CNPGParametersInEffect } from './CNPGParametersInEffect'
|
|
16
|
+
import { preflightFacts } from './recovery/restoreModel'
|
|
17
|
+
import { cnpgScreenPath } from './routes'
|
|
18
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
19
|
+
import { currentPageLabel } from '../../utils/page-links'
|
|
20
|
+
|
|
21
|
+
const cardClass = 'rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm'
|
|
22
|
+
|
|
23
|
+
export function CNPGConfiguration({ namespace, name, onNavigate, onSelectTab }: {
|
|
24
|
+
namespace: string; name: string; onNavigate: (r: SelectedResource) => void; onSelectTab: (tab: string) => void
|
|
25
|
+
}) {
|
|
26
|
+
const kubeconfigContext = useCNPGKubectlContext()
|
|
27
|
+
const object = useResource<any>('clusters', namespace, name, CNPG_GROUP)
|
|
28
|
+
const { query, row, ha } = useCNPGClusterAssessment(namespace, name)
|
|
29
|
+
const navigate = useCNPGNavigate()
|
|
30
|
+
const { connection } = useConnection()
|
|
31
|
+
const go: NavigateToRef = (ref) => onNavigate(refToSelectedResource(ref))
|
|
32
|
+
const cluster = object.data
|
|
33
|
+
if (!cluster) return object.isLoading ? <PaneLoader label="Loading Configuration…" className="h-40" /> : (
|
|
34
|
+
<p className="p-4 text-sm text-theme-text-tertiary">{object.error instanceof Error ? object.error.message : 'The Cluster could not be read.'}</p>
|
|
35
|
+
)
|
|
36
|
+
const info = cnpgConnectInfo(cluster, row?.poolerObjects)
|
|
37
|
+
const services = info.endpoints.filter((e) => e.role !== 'pooler').map((e) => e.name).join(', ')
|
|
38
|
+
const poolers = row?.poolersKnown ? info.endpoints.filter((e) => e.role === 'pooler').map((e) => e.name).join(', ') || 'none' : 'not read'
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-4 p-4">
|
|
41
|
+
<RefreshFailedNotice queries={[object, ha, query]} />
|
|
42
|
+
<section className={cardClass}>
|
|
43
|
+
<FoldSection title="Connect" summary={`Services ${services} · poolers ${poolers} · database ${info.database.value ?? 'unknown'}`} attention={false}>
|
|
44
|
+
<CNPGConnectSection ha={ha.data} haUnavailableReason={ha.isLoading ? 'Reading availability…' : ha.error instanceof Error ? `Availability could not be read: ${ha.error.message}` : undefined} kubeconfigContext={kubeconfigContext?.name} kubeconfigSource={kubeconfigContext?.source} cluster={cluster} poolers={row?.poolerObjects} poolersKnown={row?.poolersKnown ?? false} showHeading={false} onNavigate={go}
|
|
45
|
+
onOpenReachability={(svc) => navigate(buildWorkloadPath({ kind: 'services', group: '', namespace: svc.namespace, name: svc.name, tab: 'reachability' }))} />
|
|
46
|
+
</FoldSection>
|
|
47
|
+
</section>
|
|
48
|
+
<section className={cardClass}>
|
|
49
|
+
<CNPGParametersInEffect namespace={namespace} name={name} declared={getCNPGClusterPostgresParams(cluster)} scope={{ declaredInstances: cluster.spec?.instances, expectedInstances: [...new Set<string>([...(cluster.status?.instanceNames ?? []), ...(ha.data?.expectedInstances ?? [])])] }} />
|
|
50
|
+
</section>
|
|
51
|
+
<CNPGDeclaredSettings cluster={cluster} onNavigate={go} onSelectTab={onSelectTab} onOpenDeclarations={() => navigate(`${cnpgScreenPath('declarations')}?${new URLSearchParams({ cluster: `${namespace}/${name}` })}`, { state: { returnLabel: currentPageLabel(), returnCtx: connection.context } })} />
|
|
52
|
+
<section className={cardClass}>
|
|
53
|
+
{ha.data ? <CNPGClusterCertificates ha={ha.data} onNavigate={go} /> : <>
|
|
54
|
+
<SectionHeading>Certificates</SectionHeading>
|
|
55
|
+
<p className="text-sm text-theme-text-tertiary">{ha.isLoading ? 'Reading certificates…' : ha.error instanceof Error ? `Certificates could not be read: ${ha.error.message}` : 'Certificates could not be read.'}</p>
|
|
56
|
+
</>}
|
|
57
|
+
</section>
|
|
58
|
+
<section className={cardClass}>
|
|
59
|
+
<FoldSection title="Labels and annotations" summary={`${Object.keys(cluster.metadata?.labels ?? {}).length} labels · ${Object.keys(cluster.metadata?.annotations ?? {}).length} annotations`} attention={false}>
|
|
60
|
+
<SectionHeading>Labels</SectionHeading>
|
|
61
|
+
{Object.keys(cluster.metadata?.labels ?? {}).length > 0 ? <KeyValueBadgeList items={cluster.metadata.labels} /> : <p className="text-sm text-theme-text-tertiary">No labels</p>}
|
|
62
|
+
<SectionHeading>Annotations</SectionHeading>
|
|
63
|
+
<FactGrid>{Object.entries(cluster.metadata?.annotations ?? {}).map(([key, value]) => <FactRow key={key} label={key}><span className="break-all">{String(value)}</span></FactRow>)}</FactGrid>
|
|
64
|
+
{Object.keys(cluster.metadata?.annotations ?? {}).length === 0 && <p className="text-sm text-theme-text-tertiary">No annotations</p>}
|
|
65
|
+
</FoldSection>
|
|
66
|
+
</section>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type DeclaredFact = { label: string; path: string; value: ReactNode; raw?: string }
|
|
72
|
+
|
|
73
|
+
export function CNPGDeclaredSettings({ cluster, onNavigate, onSelectTab, onOpenDeclarations }: {
|
|
74
|
+
cluster: any; onNavigate?: NavigateToRef; onSelectTab: (tab: string) => void; onOpenDeclarations: () => void
|
|
75
|
+
}) {
|
|
76
|
+
const spec = cluster.spec ?? {}
|
|
77
|
+
const ns = cluster.metadata?.namespace ?? ''
|
|
78
|
+
const copied = preflightFacts(cluster).filter((f) => f.copied)
|
|
79
|
+
const basic: DeclaredFact[] = copied.filter((f) => f.path === 'spec.instances' || (f.label === 'Image' && (spec.imageName || spec.imageCatalogRef)))
|
|
80
|
+
if (spec.imageCatalogRef) {
|
|
81
|
+
const ref = spec.imageCatalogRef
|
|
82
|
+
const fact = basic.find((f) => f.label === 'Image')!
|
|
83
|
+
fact.value = <><RefLink refTo={{ kind: ref.kind ?? 'ImageCatalog', group: CNPG_GROUP, namespace: ref.kind === 'ClusterImageCatalog' ? '' : ns, name: ref.name }} onNavigate={onNavigate} mono /> · major {ref.major}</>
|
|
84
|
+
}
|
|
85
|
+
const add = (facts: DeclaredFact[], label: string, path: string, value: unknown) => {
|
|
86
|
+
if (value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && value.length === 0)) facts.push({ label, path, value: typeof value === 'boolean' ? (value ? 'Enabled' : 'Disabled') : String(value) })
|
|
87
|
+
}
|
|
88
|
+
if (cluster.status?.image && (spec.imageCatalogRef || (spec.imageName && cluster.status.image !== spec.imageName))) {
|
|
89
|
+
const image = basic.find((f) => f.label === 'Image')
|
|
90
|
+
if (image) image.value = <>{image.value}<div className="text-xs text-theme-text-secondary">Target image (status.image): {cluster.status.image}</div></>
|
|
91
|
+
}
|
|
92
|
+
if (spec.primaryUpdateStrategy) basic.push({ label: 'Primary update strategy', path: 'spec.primaryUpdateStrategy', raw: spec.primaryUpdateStrategy, value: spec.primaryUpdateStrategy === 'unsupervised' ? 'Updates the primary automatically (switchover or restart per update method)' : spec.primaryUpdateStrategy === 'supervised' ? 'Waits for a manual switchover' : spec.primaryUpdateStrategy })
|
|
93
|
+
if (spec.primaryUpdateMethod) basic.push({ label: 'Primary update method', path: 'spec.primaryUpdateMethod', raw: spec.primaryUpdateMethod, value: spec.primaryUpdateMethod === 'restart' ? 'Updates the primary in place, interrupting its connections' : spec.primaryUpdateMethod === 'switchover' ? 'Promotes an updated standby before updating the old primary' : spec.primaryUpdateMethod })
|
|
94
|
+
add(basic, 'Superuser access', 'spec.enableSuperuserAccess', spec.enableSuperuserAccess)
|
|
95
|
+
add(basic, 'Minimum sync replicas', 'spec.minSyncReplicas', spec.minSyncReplicas)
|
|
96
|
+
add(basic, 'Maximum sync replicas', 'spec.maxSyncReplicas', spec.maxSyncReplicas)
|
|
97
|
+
if (spec.postgresql?.synchronous) {
|
|
98
|
+
for (const key of ['method', 'number', 'dataDurability', 'maxStandbyNamesFromCluster']) add(basic, `Synchronous ${key}`, `spec.postgresql.synchronous.${key}`, spec.postgresql.synchronous[key])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const placement: DeclaredFact[] = Object.keys(spec.resources?.requests ?? {}).length > 0 || Object.keys(spec.resources?.limits ?? {}).length > 0 ? copied.filter((f) => f.path === 'spec.resources') : []
|
|
102
|
+
const affinity = spec.affinity
|
|
103
|
+
if (affinity && Object.keys(affinity).length > 0) {
|
|
104
|
+
const bits: string[] = []
|
|
105
|
+
if (affinity.enablePodAntiAffinity !== undefined) bits.push(`pod anti-affinity ${affinity.enablePodAntiAffinity ? 'enabled' : 'disabled'}`)
|
|
106
|
+
if (affinity.podAntiAffinityType) placement.push({ label: 'Pod anti-affinity', path: 'spec.affinity.podAntiAffinityType', raw: affinity.podAntiAffinityType, value: affinity.enablePodAntiAffinity === false ? `Not applied: pod anti-affinity is disabled` : affinity.podAntiAffinityType === 'preferred' ? `Spreads instances across ${affinity.topologyKey && affinity.topologyKey !== 'kubernetes.io/hostname' ? 'topology domains' : 'nodes'} when possible` : affinity.podAntiAffinityType === 'required' ? `Requires instances on separate ${affinity.topologyKey && affinity.topologyKey !== 'kubernetes.io/hostname' ? 'topology domains' : 'nodes'}` : affinity.podAntiAffinityType })
|
|
107
|
+
if (affinity.topologyKey) bits.push(`topology ${affinity.topologyKey}`)
|
|
108
|
+
if (Object.keys(affinity.nodeSelector ?? {}).length > 0) bits.push(`nodes ${Object.entries(affinity.nodeSelector).map(([k, v]) => `${k}=${v}`).join(', ')}`)
|
|
109
|
+
for (const key of ['nodeAffinity', 'podAffinity', 'podAntiAffinity', 'additionalPodAffinity', 'additionalPodAntiAffinity', 'tolerations']) {
|
|
110
|
+
if (affinity[key] && Object.keys(affinity[key]).length > 0) bits.push(`${key} declared`)
|
|
111
|
+
}
|
|
112
|
+
add(placement, 'Affinity', 'spec.affinity', bits.join(' · '))
|
|
113
|
+
}
|
|
114
|
+
add(placement, 'Topology spread', 'spec.topologySpreadConstraints', spec.topologySpreadConstraints?.map((c: any) => `${c.topologyKey} · max skew ${c.maxSkew} · ${c.whenUnsatisfiable}`).join('; '))
|
|
115
|
+
add(placement, 'Priority class', 'spec.priorityClassName', spec.priorityClassName)
|
|
116
|
+
|
|
117
|
+
const storage: DeclaredFact[] = []
|
|
118
|
+
for (const volume of [
|
|
119
|
+
{ label: 'Data', path: 'spec.storage', config: spec.storage },
|
|
120
|
+
{ label: 'WAL', path: 'spec.walStorage', config: spec.walStorage },
|
|
121
|
+
...(spec.tablespaces ?? []).map((t: any) => ({ label: `Tablespace ${t.name}`, path: `spec.tablespaces[name=${t.name}].storage`, config: t.storage })),
|
|
122
|
+
]) {
|
|
123
|
+
if (!volume.config) continue
|
|
124
|
+
if (volume.config.size !== undefined) add(storage, `${volume.label} size`, `${volume.path}.size`, volume.config.size)
|
|
125
|
+
else add(storage, `${volume.label} size`, `${volume.path}.pvcTemplate.resources.requests.storage`, volume.config.pvcTemplate?.resources?.requests?.storage)
|
|
126
|
+
if (volume.config.storageClass !== undefined) add(storage, `${volume.label} storage class`, `${volume.path}.storageClass`, volume.config.storageClass)
|
|
127
|
+
else add(storage, `${volume.label} storage class`, `${volume.path}.pvcTemplate.storageClassName`, volume.config.pvcTemplate?.storageClassName)
|
|
128
|
+
}
|
|
129
|
+
const bootstrap: DeclaredFact[] = []
|
|
130
|
+
add(bootstrap, 'Application database', 'spec.bootstrap.initdb.database', spec.bootstrap?.initdb?.database)
|
|
131
|
+
add(bootstrap, 'Owner', 'spec.bootstrap.initdb.owner', spec.bootstrap?.initdb?.owner)
|
|
132
|
+
if (spec.bootstrap?.initdb && bootstrap.length === 0) add(bootstrap, 'Bootstrap', 'spec.bootstrap.initdb', 'initdb declared')
|
|
133
|
+
add(bootstrap, 'Recovery source', 'spec.bootstrap.recovery.source', spec.bootstrap?.recovery?.source)
|
|
134
|
+
if (spec.bootstrap?.recovery?.backup?.name) bootstrap.push({ label: 'Recovery backup', path: 'spec.bootstrap.recovery.backup.name', value: <RefLink refTo={{ kind: 'Backup', group: CNPG_GROUP, namespace: ns, name: spec.bootstrap.recovery.backup.name }} onNavigate={onNavigate} mono /> })
|
|
135
|
+
if (spec.bootstrap?.recovery && !spec.bootstrap.recovery.source && !spec.bootstrap.recovery.backup?.name) add(bootstrap, 'Bootstrap', 'spec.bootstrap.recovery', 'recovery declared')
|
|
136
|
+
add(bootstrap, 'Base backup source', 'spec.bootstrap.pg_basebackup.source', spec.bootstrap?.pg_basebackup?.source)
|
|
137
|
+
if (spec.replica) {
|
|
138
|
+
if (spec.replica.source || spec.replica.primary) add(bootstrap, 'Replica source', `spec.replica.${spec.replica.source ? 'source' : 'primary'}`, getCNPGClusterReplicaSource(cluster))
|
|
139
|
+
add(bootstrap, 'Replica enabled', 'spec.replica.enabled', spec.replica.enabled)
|
|
140
|
+
}
|
|
141
|
+
add(bootstrap, 'External clusters', 'spec.externalClusters', spec.externalClusters?.map((c: any) => c.name).join(', '))
|
|
142
|
+
|
|
143
|
+
const backups: DeclaredFact[] = []
|
|
144
|
+
const backup = getCNPGClusterBackupConfig(cluster)
|
|
145
|
+
if (spec.backup?.barmanObjectStore) {
|
|
146
|
+
add(backups, 'Method', 'spec.backup.barmanObjectStore', 'barmanObjectStore')
|
|
147
|
+
add(backups, 'Destination', 'spec.backup.barmanObjectStore.destinationPath', backup.destinationPath)
|
|
148
|
+
}
|
|
149
|
+
if (backup.plugin) {
|
|
150
|
+
add(backups, 'Method', 'spec.plugins', `plugin ${backup.plugin.name}`)
|
|
151
|
+
const pluginPath = `spec.plugins[name=${backup.plugin.name}]`
|
|
152
|
+
const declaredPlugin = spec.plugins.find((p: any) => p.name === backup.plugin!.name)
|
|
153
|
+
add(backups, 'WAL archiver', `${pluginPath}.isWALArchiver`, declaredPlugin.isWALArchiver)
|
|
154
|
+
add(backups, 'Archive server', `${pluginPath}.parameters.serverName`, declaredPlugin.parameters?.serverName)
|
|
155
|
+
if (backup.plugin.barmanObjectName) backups.push({ label: 'Destination', path: `spec.plugins[name=${backup.plugin.name}].parameters.barmanObjectName`, value: <RefLink refTo={{ kind: 'ObjectStore', group: CNPG_BARMAN_OBJECTSTORE_GROUP, namespace: ns, name: backup.plugin.barmanObjectName }} onNavigate={onNavigate} mono /> })
|
|
156
|
+
}
|
|
157
|
+
if (spec.backup?.volumeSnapshot) {
|
|
158
|
+
add(backups, 'Method', 'spec.backup.volumeSnapshot', 'volumeSnapshot')
|
|
159
|
+
add(backups, 'Snapshot class', 'spec.backup.volumeSnapshot.className', spec.backup.volumeSnapshot.className)
|
|
160
|
+
add(backups, 'WAL snapshot class', 'spec.backup.volumeSnapshot.walClassName', spec.backup.volumeSnapshot.walClassName)
|
|
161
|
+
add(backups, 'Snapshot ownership', 'spec.backup.volumeSnapshot.snapshotOwnerReference', spec.backup.volumeSnapshot.snapshotOwnerReference)
|
|
162
|
+
}
|
|
163
|
+
add(backups, 'Retention', 'spec.backup.retentionPolicy', backup.retentionPolicy)
|
|
164
|
+
add(backups, 'Backup target', 'spec.backup.target', spec.backup?.target)
|
|
165
|
+
|
|
166
|
+
const other: DeclaredFact[] = []
|
|
167
|
+
if (spec.managed?.roles?.length > 0) other.push({ label: 'Managed roles', path: 'spec.managed.roles', value: <>{spec.managed.roles.length} · <button type="button" onClick={onOpenDeclarations} className="text-xs text-accent-text hover:underline">Declarations →</button></> })
|
|
168
|
+
add(other, 'Additional services', 'spec.managed.services.additional', spec.managed?.services?.additional?.map((s: any) => s.serviceTemplate?.metadata?.name).join(', '))
|
|
169
|
+
add(other, 'Disabled services', 'spec.managed.services.disabledDefaultServices', spec.managed?.services?.disabledDefaultServices?.join(', '))
|
|
170
|
+
add(other, 'PodMonitor', 'spec.monitoring.enablePodMonitor', spec.monitoring?.enablePodMonitor)
|
|
171
|
+
for (const [field, kind] of [['customQueriesConfigMap', 'ConfigMap'], ['customQueriesSecret', 'Secret']] as const) {
|
|
172
|
+
if (spec.monitoring?.[field]?.length > 0) other.push({ label: `Query ${kind}s`, path: `spec.monitoring.${field}`, value: <div className="flex flex-wrap gap-x-3">{spec.monitoring[field].map((ref: { name: string; key: string }) => <span key={`${ref.name}/${ref.key}`}><RefLink refTo={{ kind, group: '', namespace: ns, name: ref.name }} onNavigate={onNavigate} mono /> · key {ref.key}</span>)}</div> })
|
|
173
|
+
}
|
|
174
|
+
add(other, 'Plugins', 'spec.plugins', spec.plugins?.map((p: any) => `${p.name}${p.enabled === false ? ' (disabled)' : ''}`).join(', '))
|
|
175
|
+
|
|
176
|
+
return <>{[
|
|
177
|
+
{ title: 'Instances and image', facts: basic },
|
|
178
|
+
{ title: 'Placement and resources', facts: placement },
|
|
179
|
+
{ title: 'Storage', facts: storage, link: 'Usage → Storage tab', tab: 'storage' },
|
|
180
|
+
{ title: 'Bootstrap and source', facts: bootstrap },
|
|
181
|
+
{ title: 'Backups configuration', facts: backups, link: 'Runs and recovery evidence → Backups tab', tab: 'backups' },
|
|
182
|
+
{ title: 'Roles, services and monitoring', facts: other },
|
|
183
|
+
].filter((c) => c.facts.length > 0).map((c) => <section key={c.title} className={cardClass}>
|
|
184
|
+
<SectionHeading>{c.title}</SectionHeading>
|
|
185
|
+
<FactGrid>{c.facts.map((f) => <FactRow key={`${f.label}/${f.path}`} label={f.label}>
|
|
186
|
+
{f.value}<div className="mt-0.5 text-[11px] text-theme-text-tertiary">{f.raw ? `${f.raw} · ${f.path}` : f.path}</div>
|
|
187
|
+
</FactRow>)}</FactGrid>
|
|
188
|
+
{c.tab && <button type="button" onClick={() => onSelectTab(c.tab!)} className="mt-2 text-xs text-accent-text hover:underline">{c.link}</button>}
|
|
189
|
+
</section>)}</>
|
|
190
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } 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 { CNPGCreateClusterDialog } from './CNPGCreateClusterDialog'
|
|
7
|
+
|
|
8
|
+
const state = vi.hoisted(() => ({ context: 'kind-demo', namespaces: { data: [{ name: 'db' }, { name: 'prod' }], isError: false }, editor: {} as any }))
|
|
9
|
+
vi.mock('../../api/client', () => ({ useNamespaces: () => state.namespaces, useResources: () => ({ data: [] }) }))
|
|
10
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context } }) }))
|
|
11
|
+
vi.mock('../shared/CreateResourceDialog', () => ({ CreateResourceDialog: (props: any) => { state.editor = props; return <pre>{props.initialYaml}</pre> } }))
|
|
12
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
13
|
+
let root: ReturnType<typeof createRoot>, host: HTMLDivElement
|
|
14
|
+
const button = (text: string) => [...document.querySelectorAll('button')].find((item) => item.textContent?.trim() === text)!
|
|
15
|
+
const change = (id: string, value: string) => act(() => {
|
|
16
|
+
const input = document.querySelector<HTMLInputElement>(`#cnpg-create-${id}`)!
|
|
17
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
|
|
18
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
19
|
+
})
|
|
20
|
+
const fillTarget = () => { change('name', 'orders-db'); change('size', '20Gi') }
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
state.context = 'kind-demo'
|
|
25
|
+
state.editor = {}
|
|
26
|
+
state.namespaces = { data: [{ name: 'db' }, { name: 'prod' }], isError: false }
|
|
27
|
+
host = document.createElement('div')
|
|
28
|
+
document.body.appendChild(host)
|
|
29
|
+
root = createRoot(host)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('projects YAML fields back into setup and preserves advanced edits when resizing the target', async () => {
|
|
33
|
+
await act(async () => root.render(<CNPGCreateClusterDialog namespaces={['db']} onClose={vi.fn()} onCreated={vi.fn()} />))
|
|
34
|
+
fillTarget()
|
|
35
|
+
act(() => button('Review manifest').click())
|
|
36
|
+
const doc = yaml.parseDocument(state.editor.initialYaml)
|
|
37
|
+
doc.setIn(['metadata', 'name'], 'custom-name')
|
|
38
|
+
doc.setIn(['spec', 'resources'], { requests: { memory: '1Gi' } })
|
|
39
|
+
act(() => state.editor.onBack(`${doc.toString()}\n# keep memory request\n`))
|
|
40
|
+
expect(document.querySelector<HTMLInputElement>('#cnpg-create-name')!.value).toBe('custom-name')
|
|
41
|
+
change('size', '40Gi')
|
|
42
|
+
act(() => button('Review manifest').click())
|
|
43
|
+
expect(yaml.parse(state.editor.initialYaml)).toMatchObject({ metadata: { name: 'custom-name' }, spec: { storage: { size: '40Gi' }, resources: { requests: { memory: '1Gi' } } } })
|
|
44
|
+
expect(state.editor.initialYaml).toContain('keep memory request')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('preserves unsupported drafts until replacement is explicitly chosen', async () => {
|
|
48
|
+
await act(async () => root.render(<CNPGCreateClusterDialog namespaces={['db']} onClose={vi.fn()} onCreated={vi.fn()} />))
|
|
49
|
+
fillTarget()
|
|
50
|
+
act(() => button('Review manifest').click())
|
|
51
|
+
act(() => state.editor.onBack(''))
|
|
52
|
+
act(() => button('Continue editing current YAML').click())
|
|
53
|
+
expect(state.editor.initialYaml).toBe('')
|
|
54
|
+
const incomplete = 'apiVersion: postgresql.cnpg.io/v1\nkind: Cluster\nmetadata:\n name: orders-db\n namespace: db\nspec: {}\n'
|
|
55
|
+
act(() => state.editor.onBack(incomplete))
|
|
56
|
+
expect(button('Review manifest').disabled).toBe(true)
|
|
57
|
+
act(() => button('Continue editing current YAML').click())
|
|
58
|
+
expect(state.editor.initialYaml).toBe(incomplete)
|
|
59
|
+
act(() => state.editor.onBack('kind: Secret\n'))
|
|
60
|
+
fillTarget()
|
|
61
|
+
act(() => button('Review manifest').click())
|
|
62
|
+
expect(document.body.textContent).toContain('Replace the current manifest?')
|
|
63
|
+
act(() => button('Replace manifest').click())
|
|
64
|
+
expect(yaml.parse(state.editor.initialYaml).kind).toBe('Cluster')
|
|
65
|
+
})
|
|
66
|
+
afterEach(() => { act(() => root.unmount()); host.remove() })
|
|
67
|
+
|
|
68
|
+
it.each([{ namespaces: [] }, { namespaces: ['db', 'prod'] }])('requires a target namespace for scope $namespaces', async ({ namespaces }) => {
|
|
69
|
+
await act(async () => root.render(<CNPGCreateClusterDialog namespaces={namespaces} onClose={vi.fn()} onCreated={vi.fn()} />))
|
|
70
|
+
expect(document.querySelector<HTMLInputElement>('#cnpg-create-namespace')!.value).toBe('')
|
|
71
|
+
expect(button('Review manifest').disabled).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('seeds the selected namespace and opens strict create for the exact CNPG identity', async () => {
|
|
75
|
+
const onCreated = vi.fn()
|
|
76
|
+
await act(async () => root.render(<CNPGCreateClusterDialog namespaces={['prod']} onClose={vi.fn()} onCreated={onCreated} />))
|
|
77
|
+
expect(document.querySelector<HTMLInputElement>('#cnpg-create-namespace')!.value).toBe('prod')
|
|
78
|
+
fillTarget()
|
|
79
|
+
act(() => button('Review manifest').click())
|
|
80
|
+
expect(yaml.parse(state.editor.initialYaml)).toMatchObject({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { namespace: 'prod' } })
|
|
81
|
+
expect(state.editor.initialMode).toBe('create')
|
|
82
|
+
expect(state.editor.lockMode).toBe(true)
|
|
83
|
+
state.editor.onCreated({ apiVersion: 'cluster.x-k8s.io/v1beta1', kind: 'Cluster', namespace: 'prod', name: 'capi' })
|
|
84
|
+
expect(onCreated).not.toHaveBeenCalled()
|
|
85
|
+
state.editor.onCreated({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', namespace: 'prod', name: 'pg' })
|
|
86
|
+
expect(onCreated).toHaveBeenCalledWith({ kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'pg' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('allows an explicit namespace when suggestions are unavailable', async () => {
|
|
90
|
+
state.namespaces = { data: [], isError: true }
|
|
91
|
+
await act(async () => root.render(<CNPGCreateClusterDialog namespaces={[]} onClose={vi.fn()} onCreated={vi.fn()} />))
|
|
92
|
+
expect(document.body.textContent).toContain('Namespace suggestions could not be read')
|
|
93
|
+
act(() => {
|
|
94
|
+
const input = document.querySelector('#cnpg-create-namespace')!
|
|
95
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'restricted')
|
|
96
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
97
|
+
})
|
|
98
|
+
fillTarget()
|
|
99
|
+
act(() => button('Review manifest').click())
|
|
100
|
+
expect(yaml.parse(state.editor.initialYaml).metadata.namespace).toBe('restricted')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does not carry a prepared manifest into a different context', async () => {
|
|
104
|
+
const render = () => root.render(<CNPGCreateClusterDialog namespaces={['db']} onClose={vi.fn()} onCreated={vi.fn()} />)
|
|
105
|
+
await act(async () => render())
|
|
106
|
+
fillTarget()
|
|
107
|
+
act(() => button('Review manifest').click())
|
|
108
|
+
state.context = 'kind-other'
|
|
109
|
+
await act(async () => render())
|
|
110
|
+
expect(host.querySelector('pre')).toBeNull()
|
|
111
|
+
expect(document.body.textContent).toContain('The Kubernetes context changed')
|
|
112
|
+
expect(button('Review manifest').disabled).toBe(true)
|
|
113
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import yaml from 'yaml'
|
|
3
|
+
import { ActionConfirmDialog, ConfirmDialog, isApiGroup } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useNamespaces, useResources } from '../../api/client'
|
|
5
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
6
|
+
import type { SelectedResource } from '../../types'
|
|
7
|
+
import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
8
|
+
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
9
|
+
import { CNPGTargetFields } from './creation/CNPGTargetFields'
|
|
10
|
+
import { cnpgTargetDraft, cnpgTargetFromManifest, cnpgTargetIssue, updateCNPGTargetYaml, withCNPGTarget, type CNPGTarget } from './creation/targetModel'
|
|
11
|
+
import { clusterResource } from './shared'
|
|
12
|
+
|
|
13
|
+
export function CNPGCreateClusterDialog({ namespaces, onClose, onCreated }: {
|
|
14
|
+
namespaces: string[]
|
|
15
|
+
onClose: () => void
|
|
16
|
+
onCreated: (resource: SelectedResource) => void
|
|
17
|
+
}) {
|
|
18
|
+
const { connection } = useConnection()
|
|
19
|
+
const [context] = useState(connection.context)
|
|
20
|
+
const [base] = useState(() => yaml.parse(getSkeletonYaml('Cluster', 'postgresql.cnpg.io')))
|
|
21
|
+
const [target, setTarget] = useState<CNPGTarget>(() => ({ ...cnpgTargetFromManifest(base), name: '', namespace: namespaces.length === 1 ? namespaces[0] : '', size: '' }))
|
|
22
|
+
const [manifest, setManifest] = useState<string | null>(null)
|
|
23
|
+
const [editing, setEditing] = useState(false)
|
|
24
|
+
const [replace, setReplace] = useState(false)
|
|
25
|
+
const availableNamespaces = useNamespaces()
|
|
26
|
+
const storageClasses = useResources<any>('storageclasses', undefined, 'storage.k8s.io')
|
|
27
|
+
const contextChanged = context !== connection.context
|
|
28
|
+
const generated = () => yaml.stringify(withCNPGTarget(base, target))
|
|
29
|
+
|
|
30
|
+
if (contextChanged) return <ActionConfirmDialog open onClose={onClose} onConfirm={() => {}} title="Create Cluster" subject={{ kind: 'Cluster', namespace: target.namespace, name: target.name || 'new cluster' }} context={context || undefined} effect="The Kubernetes context changed." confirmLabel="Review manifest" disabledReason="Close this dialog and start again in the current context." />
|
|
31
|
+
if (replace) return <ConfirmDialog open onClose={() => setReplace(false)} onConfirm={() => { setManifest(generated()); setReplace(false); setEditing(true) }} variant="warning" showWarning={false} title="Replace the current manifest?" message="This YAML cannot be represented by the Cluster setup. Rebuilding uses your setup choices and discards the current YAML." confirmLabel="Replace manifest" cancelLabel="Keep editing setup" />
|
|
32
|
+
if (editing && manifest !== null) return <CreateResourceDialog
|
|
33
|
+
open
|
|
34
|
+
onClose={onClose}
|
|
35
|
+
onBack={(draft) => {
|
|
36
|
+
setManifest(draft)
|
|
37
|
+
const parsed = cnpgTargetDraft(draft)
|
|
38
|
+
if (parsed) setTarget(cnpgTargetFromManifest(parsed))
|
|
39
|
+
setEditing(false)
|
|
40
|
+
}}
|
|
41
|
+
backLabel="Back to cluster setup"
|
|
42
|
+
initialYaml={manifest}
|
|
43
|
+
initialMode="create"
|
|
44
|
+
lockMode
|
|
45
|
+
title="Create Cluster"
|
|
46
|
+
onCreated={(created) => {
|
|
47
|
+
if (created.kind === 'Cluster' && isApiGroup(created.apiVersion, 'postgresql.cnpg.io')) onCreated(clusterResource(created.namespace, created.name))
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
return <ActionConfirmDialog
|
|
52
|
+
open
|
|
53
|
+
onClose={onClose}
|
|
54
|
+
onConfirm={() => {
|
|
55
|
+
if (manifest !== null) {
|
|
56
|
+
const updated = updateCNPGTargetYaml(manifest, target)
|
|
57
|
+
if (updated === null) { setReplace(true); return }
|
|
58
|
+
setManifest(updated)
|
|
59
|
+
} else setManifest(generated())
|
|
60
|
+
setEditing(true)
|
|
61
|
+
}}
|
|
62
|
+
title="Create Cluster"
|
|
63
|
+
subject={{ kind: 'Cluster', namespace: target.namespace.trim(), name: target.name.trim() || 'new cluster' }}
|
|
64
|
+
context={context || undefined}
|
|
65
|
+
effect="Create a PostgreSQL Cluster with the placement and resources below, then review the exact manifest."
|
|
66
|
+
confirmLabel="Review manifest"
|
|
67
|
+
incompleteReason={cnpgTargetIssue(target)}
|
|
68
|
+
notes={['An existing Cluster will not be updated. This does not change the namespace filter.', 'The new Cluster starts without backups or WAL archiving. Its Backups tab guides the next step.']}
|
|
69
|
+
>
|
|
70
|
+
{manifest !== null && <div className="mb-4 space-y-2 text-sm text-theme-text-secondary">{!cnpgTargetDraft(manifest) && <p>The current YAML cannot be represented by these fields. Rebuilding from setup requires confirmation.</p>}<button type="button" className="btn-secondary px-3 py-1.5 text-xs" onClick={() => setEditing(true)}>Continue editing current YAML</button></div>}
|
|
71
|
+
<CNPGTargetFields value={target} onChange={setTarget} idPrefix="cnpg-create" namespaces={availableNamespaces.data?.map((item) => item.name)} storageClasses={storageClasses.data?.map((item) => item.metadata.name)} defaultClassDisabled={manifest !== null && cnpgTargetDraft(manifest)?.spec.storage?.pvcTemplate?.storageClassName === ''} />
|
|
72
|
+
{(availableNamespaces.isError || storageClasses.isError) && <p className="mt-3 text-xs text-theme-text-tertiary">{availableNamespaces.isError && 'Namespace suggestions could not be read. '}{storageClasses.isError && 'Storage class suggestions could not be read. '}You can enter names directly; access and configuration are checked at manifest review.</p>}
|
|
73
|
+
</ActionConfirmDialog>
|
|
74
|
+
}
|