@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.
Files changed (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. 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
+ })