@skyhook-io/radar-app 1.17.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGWorkspaceResponse } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPGDeclarations, cnpgDeclarationBlocks, type DeclItem } from './CNPGDeclarations'
|
|
6
|
+
|
|
7
|
+
const item = (kind: DeclItem['kind'], pgName: string, database: string | undefined, state: DeclItem['state'] = 'applied'): DeclItem => ({
|
|
8
|
+
key: `${kind}/${pgName}`,
|
|
9
|
+
kind,
|
|
10
|
+
pgName,
|
|
11
|
+
database,
|
|
12
|
+
state,
|
|
13
|
+
resource: { kind, namespace: 'pg', name: pgName } as DeclItem['resource'],
|
|
14
|
+
isField: false,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const items = [
|
|
18
|
+
item('Database', 'demo_app', 'demo_app'),
|
|
19
|
+
item('Publication', 'demo_app_pub', 'demo_app'),
|
|
20
|
+
item('Subscription', 'demo_app_sub', 'demo_app', 'failed'),
|
|
21
|
+
item('Database', 'demo_reporting', 'demo_reporting'),
|
|
22
|
+
item('Publication', 'missing_pub', 'no_such_database', 'failed'),
|
|
23
|
+
item('DatabaseRole', 'reader', undefined),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
describe('cnpgDeclarationBlocks', () => {
|
|
27
|
+
it('nests what lives in each database under it, undeclared databases after the declared ones, roles last', () => {
|
|
28
|
+
const blocks = cnpgDeclarationBlocks(items, null)
|
|
29
|
+
expect(blocks.map((b) => [b.database, b.head?.pgName, b.children.map((c) => c.pgName)])).toEqual([
|
|
30
|
+
['demo_app', 'demo_app', ['demo_app_pub', 'demo_app_sub']],
|
|
31
|
+
['demo_reporting', 'demo_reporting', []],
|
|
32
|
+
['no_such_database', undefined, ['missing_pub']],
|
|
33
|
+
[undefined, undefined, ['reader']],
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('keeps a database as context for a matching item in it, and drops what does not match', () => {
|
|
38
|
+
const blocks = cnpgDeclarationBlocks(items, 'failed')
|
|
39
|
+
expect(blocks.map((b) => [b.database, b.head?.pgName, b.children.map((c) => c.pgName)])).toEqual([
|
|
40
|
+
['demo_app', 'demo_app', ['demo_app_sub']],
|
|
41
|
+
['no_such_database', undefined, ['missing_pub']],
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
|
|
47
|
+
|
|
48
|
+
const G = 'postgresql.cnpg.io/v1'
|
|
49
|
+
const declaration = (cluster: string, name: string, applied?: boolean, stale = false) => ({ apiVersion: G, kind: 'Database', metadata: { name, namespace: 'pg', generation: 2 }, spec: { name, cluster: { name: cluster } }, status: { applied, observedGeneration: stale ? 1 : 2 } })
|
|
50
|
+
|
|
51
|
+
function renderDeclarations(query: string, pgName?: string, partial: false | 'pg' | 'other' = false) {
|
|
52
|
+
const data: CNPGWorkspaceResponse = {
|
|
53
|
+
installed: true, context: 'test', namespaces: null,
|
|
54
|
+
coverage: Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }])),
|
|
55
|
+
objects: {
|
|
56
|
+
clusters: ['a', 'b'].map((name) => ({ apiVersion: G, kind: 'Cluster', metadata: { name, namespace: 'pg' }, spec: {} })),
|
|
57
|
+
databases: [declaration('a', 'failed-a', false), declaration('a', 'stale-a', true, true), declaration('b', 'failed-b1', false), declaration('b', 'failed-b2', false), declaration('b', 'pending-b')],
|
|
58
|
+
}, issues: [], audit: [], backupsOmitted: 0,
|
|
59
|
+
}
|
|
60
|
+
if (partial) data.coverage.publications = { state: 'partial', allowedNamespaces: [partial] }
|
|
61
|
+
if (pgName) data.objects.databases![0].spec.name = pgName
|
|
62
|
+
return renderToStaticMarkup(createElement(CNPGDeclarations, {
|
|
63
|
+
data, fleet: buildCNPGFleet(data), namespaces: [], searchParams: new URLSearchParams(query), onSetParams: () => {}, onInspect: () => {}, inspected: null, onClearNamespaces: () => {},
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it('counts the selected cluster’s actual items before applying the state filter', () => {
|
|
68
|
+
const html = renderDeclarations('cluster=pg/a&show=failed')
|
|
69
|
+
expect(html).toMatch(/Not applied[^<]*<[^>]*>1</)
|
|
70
|
+
expect(html).toMatch(/Pending[^<]*<[^>]*>1</)
|
|
71
|
+
expect(html).toContain('failed-a')
|
|
72
|
+
expect(html).not.toContain('failed-b1')
|
|
73
|
+
expect(html).not.toContain('stale-a')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shows stale success as pending in the declaration list', () => {
|
|
77
|
+
const html = renderDeclarations('cluster=pg/a&show=pending')
|
|
78
|
+
expect(html).toContain('stale-a')
|
|
79
|
+
expect(html).toContain('awaiting the operator for the current spec')
|
|
80
|
+
expect(html).not.toContain('failed-a')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('offers inspection with the Kubernetes object identity beside the database name', () => {
|
|
84
|
+
const html = renderDeclarations('cluster=pg/a', 'appdb')
|
|
85
|
+
expect(html).toContain('appdb')
|
|
86
|
+
expect(html).toContain('Inspect →')
|
|
87
|
+
expect(html).toContain('Database failed-a')
|
|
88
|
+
expect(html).toContain('role="button"')
|
|
89
|
+
expect(html).toContain('tabindex="0"')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('qualifies declaration filter counts over partial coverage including zero matches', () => {
|
|
93
|
+
expect(renderDeclarations('cluster=pg/a', undefined, 'other')).toMatch(/Not applied[^<]*<[^>]*>≥1</)
|
|
94
|
+
expect(renderDeclarations('cluster=pg/a', undefined, 'other')).toMatch(/Pending[^<]*<[^>]*>≥1</)
|
|
95
|
+
const empty = renderDeclarations('cluster=pg/missing', undefined, 'other')
|
|
96
|
+
expect(empty).toMatch(/Not applied[^<]*<[^>]*>Unknown</)
|
|
97
|
+
expect(empty).toMatch(/Pending[^<]*<[^>]*>Unknown</)
|
|
98
|
+
expect(renderDeclarations('cluster=pg/missing')).toMatch(/Not applied[^<]*<[^>]*>0</)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps filtered counts exact in a fully read namespace even when another namespace is unread', () => {
|
|
102
|
+
const html = renderDeclarations('cluster=pg/a', undefined, 'pg')
|
|
103
|
+
expect(html).toMatch(/Not applied[^<]*<[^>]*>1</)
|
|
104
|
+
expect(html).toMatch(/Pending[^<]*<[^>]*>1</)
|
|
105
|
+
expect(renderDeclarations('cluster=pg/missing', undefined, 'pg')).toMatch(/Not applied[^<]*<[^>]*>0</)
|
|
106
|
+
expect(renderDeclarations('', undefined, 'pg')).toMatch(/Not applied[^<]*<[^>]*>≥3</)
|
|
107
|
+
})
|