@skyhook-io/k8s-ui 1.16.3 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -15
  5. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  6. package/src/components/charts/AreaChart.test.tsx +25 -0
  7. package/src/components/charts/AreaChart.tsx +141 -12
  8. package/src/components/charts/index.ts +1 -0
  9. package/src/components/checks/ChecksView.tsx +18 -1
  10. package/src/components/checks/types.ts +2 -13
  11. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  12. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  13. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  14. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  15. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  16. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  17. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  18. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  19. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  20. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  21. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  22. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  23. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  24. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  25. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  26. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  27. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  28. package/src/components/cnpg/backupRuns.test.ts +17 -0
  29. package/src/components/cnpg/backupRuns.ts +19 -0
  30. package/src/components/cnpg/connect.test.ts +124 -0
  31. package/src/components/cnpg/connect.ts +155 -0
  32. package/src/components/cnpg/databaseRole.test.ts +38 -0
  33. package/src/components/cnpg/databaseRole.ts +80 -0
  34. package/src/components/cnpg/ha.test.ts +446 -0
  35. package/src/components/cnpg/ha.ts +621 -0
  36. package/src/components/cnpg/index.ts +18 -0
  37. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  38. package/src/components/cnpg/logicalReplication.ts +252 -0
  39. package/src/components/cnpg/pooler.test.ts +84 -0
  40. package/src/components/cnpg/pooler.ts +225 -0
  41. package/src/components/cnpg/primitives.tsx +21 -117
  42. package/src/components/cnpg/problems.ts +12 -0
  43. package/src/components/cnpg/relations.test.ts +63 -13
  44. package/src/components/cnpg/relations.ts +61 -52
  45. package/src/components/cnpg/schedule.ts +35 -0
  46. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  47. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  48. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  49. package/src/components/cnpg/workspace.test.ts +380 -9
  50. package/src/components/cnpg/workspace.ts +1137 -136
  51. package/src/components/dock/DockContext.test.tsx +32 -0
  52. package/src/components/dock/DockContext.tsx +18 -4
  53. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  54. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  55. package/src/components/dock/TerminalTab.tsx +20 -8
  56. package/src/components/facts/certainty.tsx +36 -0
  57. package/src/components/facts/facts.test.tsx +24 -0
  58. package/src/components/facts/facts.tsx +62 -0
  59. package/src/components/facts/index.ts +6 -0
  60. package/src/components/facts/managed-by.test.tsx +17 -0
  61. package/src/components/facts/managed-by.tsx +36 -0
  62. package/src/components/facts/public-exports.test.tsx +35 -0
  63. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  64. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  65. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  66. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  67. package/src/components/gitops/detail-helpers.ts +1 -1
  68. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  69. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  70. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  71. package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
  72. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  73. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
  74. package/src/components/gitops/tree/layout.test.ts +111 -0
  75. package/src/components/gitops/tree/layout.ts +128 -0
  76. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  77. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  78. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  79. package/src/components/issues/IssuesView.tsx +40 -5
  80. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  81. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  82. package/src/components/issues/index.ts +3 -1
  83. package/src/components/issues/issue-timing.ts +14 -3
  84. package/src/components/issues/issues.test.ts +50 -2
  85. package/src/components/issues/severity.ts +21 -0
  86. package/src/components/issues/types.ts +23 -0
  87. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  88. package/src/components/logs/LogCore.tsx +25 -17
  89. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  90. package/src/components/logs/LogsViewer.test.tsx +291 -0
  91. package/src/components/logs/LogsViewer.tsx +72 -11
  92. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  93. package/src/components/logs/StructuredLogLine.tsx +22 -30
  94. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  95. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  96. package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
  97. package/src/components/logs/useLogBuffer.test.ts +8 -0
  98. package/src/components/logs/useLogStream.ts +28 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  105. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  106. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  107. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  108. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  109. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  110. package/src/components/resources/ResourcesView.tsx +309 -117
  111. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  112. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  113. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  114. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  115. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
  116. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  117. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  118. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  119. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  120. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  121. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  122. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  124. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
  125. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  127. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
  128. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  130. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  131. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  132. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  133. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  134. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  135. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  136. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  137. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  138. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
  139. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
  140. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  141. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  142. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  143. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  144. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  145. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  146. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  147. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  148. package/src/components/resources/renderers/HPARenderer.tsx +57 -17
  149. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  150. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  151. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  152. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  153. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  154. package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
  155. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  156. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  157. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  158. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  159. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  160. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
  161. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  163. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  164. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  165. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  166. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  167. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  170. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  171. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  172. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  173. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  174. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  175. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  176. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  177. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  178. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  179. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  180. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  181. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  182. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  183. package/src/components/resources/renderers/PodRenderer.tsx +62 -36
  184. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  185. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  186. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  187. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  188. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  189. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  190. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  191. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
  192. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  193. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  194. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  195. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  196. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  197. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  198. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  199. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  200. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  201. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  202. package/src/components/resources/resource-utils-capi.ts +1 -1
  203. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  204. package/src/components/resources/resource-utils-cnpg.ts +51 -24
  205. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  206. package/src/components/resources/resource-utils-contour.ts +16 -0
  207. package/src/components/resources/resource-utils-istio.ts +12 -8
  208. package/src/components/resources/resource-utils-keda.ts +1 -7
  209. package/src/components/resources/resource-utils-knative.ts +18 -0
  210. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  211. package/src/components/resources/resource-utils-velero.ts +0 -12
  212. package/src/components/resources/resource-utils.ts +99 -39
  213. package/src/components/resources/rules-column-width.test.ts +1 -1
  214. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  215. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  216. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  217. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  218. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  219. package/src/components/shared/DetailShell.test.tsx +80 -0
  220. package/src/components/shared/DetailShell.tsx +85 -10
  221. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  222. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  223. package/src/components/shared/EditableYamlView.tsx +1 -1
  224. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  225. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  226. package/src/components/shared/ResourceActionsBar.tsx +20 -24
  227. package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
  228. package/src/components/shared/SetImageDialog.test.ts +75 -6
  229. package/src/components/shared/SetImageDialog.tsx +92 -84
  230. package/src/components/shared/index.ts +4 -1
  231. package/src/components/timeline/TimelineList.tsx +2 -15
  232. package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
  233. package/src/components/timeline/timeline-filters.ts +1 -0
  234. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  235. package/src/components/topology/K8sResourceNode.tsx +44 -2
  236. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  237. package/src/components/topology/TopologyGraph.tsx +8 -74
  238. package/src/components/topology/TopologySearch.tsx +1 -0
  239. package/src/components/topology/internet-groups.test.ts +35 -0
  240. package/src/components/topology/internet-groups.ts +55 -0
  241. package/src/components/topology/layout.ts +1 -1
  242. package/src/components/ui/Badge.tsx +1 -0
  243. package/src/components/ui/FoldSection.tsx +68 -0
  244. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  245. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  246. package/src/components/ui/RefLink.tsx +20 -0
  247. package/src/components/ui/RowActionMenu.tsx +8 -3
  248. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  249. package/src/components/ui/ScheduleValue.tsx +3 -2
  250. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  251. package/src/components/ui/drawer-components.test.tsx +175 -1
  252. package/src/components/ui/drawer-components.tsx +103 -36
  253. package/src/components/ui/index.ts +3 -1
  254. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  255. package/src/components/ui/monacoRuntime.ts +14 -19
  256. package/src/components/ui/status-tone.test.ts +10 -1
  257. package/src/components/ui/status-tone.tsx +29 -0
  258. package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
  259. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  260. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  261. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  262. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  263. package/src/components/workload/WorkloadView.tsx +297 -104
  264. package/src/index.ts +14 -1
  265. package/src/monaco-deep.d.ts +19 -12
  266. package/src/theme/components.css +13 -0
  267. package/src/types/capacity.ts +21 -2
  268. package/src/types/core.ts +60 -2
  269. package/src/types/gitops-flux-status.test.ts +32 -1
  270. package/src/types/gitops-insights.ts +19 -2
  271. package/src/types/gitops-tree.ts +10 -0
  272. package/src/types/gitops.ts +26 -6
  273. package/src/utils/api-resources.ts +7 -0
  274. package/src/utils/application-history.ts +1 -0
  275. package/src/utils/cnpg-backup.test.ts +26 -0
  276. package/src/utils/cnpg-backup.ts +70 -0
  277. package/src/utils/format.test.ts +49 -2
  278. package/src/utils/format.ts +9 -1
  279. package/src/utils/gateway-references.test.ts +17 -0
  280. package/src/utils/gateway-references.ts +29 -0
  281. package/src/utils/gitops-owner.ts +3 -2
  282. package/src/utils/gitops-write-guard.test.ts +401 -0
  283. package/src/utils/gitops-write-guard.ts +509 -0
  284. package/src/utils/grant.test.ts +19 -0
  285. package/src/utils/grant.ts +35 -0
  286. package/src/utils/index.ts +6 -0
  287. package/src/utils/ingress-class-references.ts +22 -0
  288. package/src/utils/k8s-errors.ts +7 -7
  289. package/src/utils/log-format.test.ts +41 -0
  290. package/src/utils/log-format.ts +71 -15
  291. package/src/utils/log-level.ts +12 -8
  292. package/src/utils/navigation.test.ts +70 -0
  293. package/src/utils/navigation.ts +91 -8
  294. package/src/utils/node-lifecycle.test.ts +36 -0
  295. package/src/utils/node-lifecycle.ts +77 -0
  296. package/src/utils/object-reference-navigation.test.ts +43 -0
  297. package/src/utils/resource-hierarchy.test.ts +69 -0
  298. package/src/utils/resource-hierarchy.ts +41 -7
  299. package/src/utils/resource-icons.ts +2 -0
  300. package/src/utils/resource-refs.test.ts +23 -0
  301. package/src/utils/resource-refs.ts +30 -0
  302. package/src/utils/skeleton-yaml.test.ts +21 -0
  303. package/src/utils/skeleton-yaml.ts +16 -0
  304. package/src/utils/url-path.test.ts +35 -0
  305. package/src/utils/url-path.ts +21 -0
  306. package/src/utils/validators.test.ts +4 -13
  307. package/src/utils/validators.ts +5 -19
  308. package/src/utils/view-transition.test.ts +35 -1
  309. package/src/utils/view-transition.ts +16 -1
@@ -0,0 +1,446 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ cnpgCertificateViews,
4
+ cnpgCertificatesSummary,
5
+ cnpgHASummary,
6
+ cnpgImageDrift,
7
+ cnpgDimensions,
8
+ cnpgPDBFact,
9
+ cnpgLeaseHolderPod,
10
+ cnpgLiveGap,
11
+ cnpgPendingRestart,
12
+ cnpgQuorumFact,
13
+ cnpgZoneSpread,
14
+ type CNPGClusterHA,
15
+ type CNPGHAQuorum,
16
+ } from './ha'
17
+ import { cnpgLagTone, type CNPGFleetRow } from './workspace'
18
+
19
+ function ha(over: Partial<CNPGClusterHA> = {}): CNPGClusterHA {
20
+ return {
21
+ cluster: { namespace: 'db', name: 'pg', uid: 'u' },
22
+ sampledAt: '2026-09-30T12:00:00Z',
23
+ desiredImage: 'pg:17',
24
+ instances: [
25
+ { pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, node: 'n1', zone: 'z1', restartCount: 0, image: 'pg:17', imageMatches: true },
26
+ { pod: 'pg-2', podUID: 'b', role: 'replica', ready: true, node: 'n2', zone: 'z2', restartCount: 0, image: 'pg:17', imageMatches: true },
27
+ ],
28
+ pods: { state: 'ok' },
29
+ nodes: { state: 'ok' },
30
+ quorum: { enabled: false, object: { state: 'notFound' } },
31
+ pdbs: { state: 'ok', enabled: true, items: [] },
32
+ primaryLease: { state: 'notFound' },
33
+ operatorLease: { state: 'unavailable' },
34
+ jobs: { state: 'ok', items: [] },
35
+ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-1'] },
36
+ certificates: [],
37
+ maintenance: { declared: false, inProgress: false, reusePVC: true },
38
+ ...over,
39
+ }
40
+ }
41
+
42
+ function row(over: Partial<CNPGFleetRow> = {}): CNPGFleetRow {
43
+ return {
44
+ key: 'db/pg',
45
+ namespace: 'db',
46
+ name: 'pg',
47
+ cluster: { status: { currentPrimary: 'pg-1' } },
48
+ controllerStatus: { text: 'Healthy', level: 'healthy' },
49
+ instances: { ready: 2, desired: 2 },
50
+ pods: [
51
+ { name: 'pg-1', role: 'primary', ready: true },
52
+ { name: 'pg-2', role: 'replica', ready: true },
53
+ ],
54
+ replicaCluster: null,
55
+ hibernated: false,
56
+ pgVersion: '17',
57
+ catalog: null,
58
+ replication: { text: 'Lag unknown', tone: 'unknown' },
59
+ protection: {
60
+ schedule: { text: 'Active', tone: 'healthy', names: [] },
61
+ destination: { text: 'ObjectStore', tone: 'healthy', method: 'plugin' },
62
+ lastSuccessfulBackup: { text: '1 h ago', tone: 'healthy' },
63
+ walArchiving: { text: 'Archiving', tone: 'healthy' },
64
+ recoveryWindow: { text: 'x', tone: 'healthy' },
65
+ restoreValidation: { text: 'None recorded', tone: 'unknown' },
66
+ summary: { text: 'ok', tone: 'healthy' },
67
+ },
68
+ declarations: { summary: { text: 'None', tone: 'neutral' }, total: 0, failed: 0, pending: 0 },
69
+ poolers: [],
70
+ poolersKnown: true,
71
+ problems: [],
72
+ attention: false,
73
+ categories: new Set(),
74
+ ...over,
75
+ }
76
+ }
77
+
78
+ it('does not call successful snapshot protection WAL archiving when no archive is configured', () => {
79
+ const r = row()
80
+ r.protection.destination = { text: 'Volume snapshots', tone: 'neutral', method: 'volumeSnapshot' }
81
+ r.protection.walArchiving = { text: 'No archive destination configured', tone: 'neutral', source: 'Cluster spec' }
82
+ r.protection.lastSuccessfulBackup = { text: 'Completed', tone: 'healthy', source: 'Backup snapshot' }
83
+ expect(cnpgDimensions({ row: r }).find((d) => d.id === 'protection')).toMatchObject({ text: 'Backup completed', tone: 'healthy', source: 'Backup snapshot' })
84
+ })
85
+
86
+ describe('cnpgZoneSpread', () => {
87
+ it('groups instances by zone and names the primary’s', () => {
88
+ const s = cnpgZoneSpread(ha())
89
+ expect(s.known).toBe(true)
90
+ expect(s.zones.map((z) => z.zone)).toEqual(['z1', 'z2'])
91
+ expect(s.primaryZone).toBe('z1')
92
+ expect(s.singleZone).toBe(false)
93
+ })
94
+ it('flags every instance in one zone and a shared node', () => {
95
+ const s = cnpgZoneSpread(
96
+ ha({
97
+ instances: [
98
+ { pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, node: 'n1', zone: 'z1', restartCount: 0 },
99
+ { pod: 'pg-2', podUID: 'b', role: 'replica', ready: true, node: 'n1', zone: 'z1', restartCount: 0 },
100
+ ],
101
+ }),
102
+ )
103
+ expect(s.singleZone).toBe(true)
104
+ expect(s.sharedNode).toBe(true)
105
+ })
106
+ it('is unknown, not single-zone, when Nodes are not readable', () => {
107
+ const s = cnpgZoneSpread(ha({ nodes: { state: 'denied', grant: { verb: 'get', resource: 'nodes' } } }))
108
+ expect(s.known).toBe(false)
109
+ expect(s.singleZone).toBe(false)
110
+ })
111
+ })
112
+
113
+ describe('cnpgQuorumFact', () => {
114
+ const q = (over: Partial<CNPGHAQuorum>): CNPGHAQuorum => ({ enabled: true, object: { state: 'ok' }, ...over })
115
+ it('states R + W > N when it holds', () => {
116
+ const f = cnpgQuorumFact(q({ n: 2, w: 1, r: 2, holds: true, status: { standbyNames: ['a', 'b'], standbyNumber: 1 } }))
117
+ expect(f.text).toContain('R + W > N')
118
+ expect(f.tone).toBe('healthy')
119
+ })
120
+ it('states when it does not', () => {
121
+ const f = cnpgQuorumFact(q({ n: 2, w: 1, r: 1, holds: false }))
122
+ expect(f.text).toContain('R + W ≤ N')
123
+ expect(f.tone).toBe('degraded')
124
+ })
125
+ it('a reset object is no configuration, not a pass', () => {
126
+ expect(cnpgQuorumFact(q({ status: { standbyNames: [], standbyNumber: 0 } })).text).toContain('no synchronous configuration recorded')
127
+ })
128
+ it('an unreadable object is unknown', () => {
129
+ expect(cnpgQuorumFact(q({ object: { state: 'denied', grant: { verb: 'get', group: 'postgresql.cnpg.io', resource: 'failoverquorums', namespace: 'db' } } })).tone).toBe('unknown')
130
+ })
131
+ })
132
+
133
+ describe('cnpgPDBFact', () => {
134
+ it('distinguishes disabled from missing and unreadable', () => {
135
+ expect(cnpgPDBFact({ state: 'ok', enabled: false, items: [] }).tone).toBe('neutral')
136
+ expect(cnpgPDBFact({ state: 'ok', enabled: true, items: [] }).tone).toBe('degraded')
137
+ expect(cnpgPDBFact({ state: 'denied', enabled: true, items: [] }).tone).toBe('unknown')
138
+ })
139
+ })
140
+
141
+ describe('cnpgCertificateViews', () => {
142
+ const now = Date.parse('2026-09-30T00:00:00Z')
143
+ const at = (days: number) => new Date(now + days * 86_400_000).toISOString()
144
+ it('uses the issues-engine thresholds and owner', () => {
145
+ const v = cnpgCertificateViews(
146
+ [
147
+ { secret: 'u-20', raw: '', expiresAt: at(20), renewal: 'user' },
148
+ { secret: 'u-3', raw: '', expiresAt: at(3), renewal: 'user' },
149
+ { secret: 'o-3', raw: '', expiresAt: at(3), renewal: 'operator' },
150
+ { secret: 'x', raw: 'garbage', renewal: 'operator' },
151
+ ],
152
+ now,
153
+ )
154
+ expect(v.map((c) => c.tone)).toEqual(['degraded', 'unhealthy', 'healthy', 'unknown'])
155
+ expect(v[0].daysLeft).toBe(20)
156
+ })
157
+ })
158
+
159
+ describe('cnpgPendingRestart', () => {
160
+ it('is unknown without live data and names the instances otherwise', () => {
161
+ expect(cnpgPendingRestart(undefined).known).toBe(false)
162
+ const p = cnpgPendingRestart([
163
+ { pod: 'pg-1', state: 'ok', pendingRestart: true, pendingRestartForDecrease: true },
164
+ { pod: 'pg-2', state: 'unreachable' },
165
+ ])
166
+ expect(p.pods).toEqual(['pg-1'])
167
+ expect(p.forDecrease).toBe(true)
168
+ expect(p.known).toBe(false)
169
+ })
170
+ it('does not count an incomplete report as "no restart pending"', () => {
171
+ const p = cnpgPendingRestart([
172
+ { pod: 'pg-1', state: 'ok' },
173
+ { pod: 'pg-2', state: 'partial', incomplete: true },
174
+ ])
175
+ expect(p.known).toBe(false)
176
+ expect(cnpgPendingRestart([{ pod: 'pg-2', state: 'partial', incomplete: true }]).known).toBe(false)
177
+ })
178
+ })
179
+
180
+ describe('cnpgLiveGap', () => {
181
+ it('names the missing grant when there is no runtime read', () => {
182
+ expect(cnpgLiveGap(undefined, 'needs get pods/proxy in db')).toBe('needs get pods/proxy in db')
183
+ })
184
+ it('names each instance that did not report, and why, when runtime access exists', () => {
185
+ const gap = cnpgLiveGap([
186
+ { pod: 'pg-1', state: 'ok' },
187
+ { pod: 'pg-2', state: 'unreachable', reason: 'PostgreSQL is not running on this instance' },
188
+ { pod: 'pg-3', state: 'partial', incomplete: true, reason: 'pg_rewind is running' },
189
+ ])
190
+ expect(gap).toBe('pg-2 did not report (PostgreSQL is not running on this instance); pg-3 reported incompletely (pg_rewind is running)')
191
+ expect(gap).not.toContain('runtime access')
192
+ expect(cnpgLiveGap([{ pod: 'pg-1', state: 'ok' }])).toBe('')
193
+ })
194
+ })
195
+
196
+ describe('cnpgDimensions', () => {
197
+ it('reads each dimension from its own source and leaves storage unassessed', () => {
198
+ const d = cnpgDimensions({ row: row(), ha: ha(), replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0 } })
199
+ expect(d.map((x) => [x.id, x.tone])).toEqual([
200
+ ['serving', 'healthy'],
201
+ ['replication', 'healthy'],
202
+ ['storage', 'unknown'],
203
+ ['protection', 'healthy'],
204
+ ])
205
+ expect(d.find((x) => x.id === 'protection')?.label).toBe('Backups')
206
+ })
207
+ it('storage follows the measured disk fact, and stays unassessed without a measurement', () => {
208
+ const measured = cnpgDimensions({ row: row({ disk: { text: '91% used', tone: 'unhealthy', source: 'Fullest: data of pg-1' } }) })
209
+ expect(measured[2]).toMatchObject({ id: 'storage', tone: 'unhealthy', text: '91% used' })
210
+ const unmeasured = cnpgDimensions({ row: row({ disk: { text: 'No usage metrics', tone: 'unknown', source: 'needs Prometheus' } }) })
211
+ expect(unmeasured[2]).toMatchObject({ tone: 'unknown', text: 'No usage metrics: needs Prometheus', source: '' })
212
+ })
213
+ it('names WAL an inactive slot holds even while volume usage is unassessed', () => {
214
+ const slot = { id: 'slot:pg/pg:_cnpg_pg_2', reason: 'CNPGInactiveSlot', slot: '_cnpg_pg_2', severity: 'warning', category: 'availability', title: 'Inactive slot _cnpg_pg_2 holds 4.5 GiB of WAL on pg-1 for pg-2', subject: { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'pg' }, source: 'measurement' } as const
215
+ const r = row({ key: 'pg/pg', problems: [slot], disk: { text: 'No usage metrics', tone: 'unknown', source: 'no series' } })
216
+ const d = cnpgDimensions({ row: r })
217
+ expect(d[2]).toMatchObject({ id: 'storage', tone: 'degraded', text: 'WAL held by an inactive slot' })
218
+ expect(d[2].source).toContain('4.5 GiB')
219
+ expect(d[2].source).toContain('No usage metrics: no series')
220
+ const measured = cnpgDimensions({ row: { ...r, disk: { text: '40% used', tone: 'healthy', source: 'Fullest' } } })
221
+ expect(measured[2]).toMatchObject({ tone: 'degraded', text: '40% used · WAL held by an inactive slot' })
222
+ })
223
+ it('a standby another source saw receiving nothing keeps Replication from reading unassessed or calm', () => {
224
+ const gap = { id: 'standby:pg/pg:pg-2', reason: 'CNPGStandbyNotReceiving', severity: 'warning', category: 'availability', title: 'pg-2 is not receiving WAL from the primary', subject: { kind: 'Pod', group: '', namespace: 'pg', name: 'pg-2' }, source: 'measurement' } as const
225
+ const r = row({ key: 'pg/pg', problems: [gap] })
226
+ expect(cnpgDimensions({ row: r })[1]).toMatchObject({ tone: 'degraded', text: 'pg-2 not receiving WAL' })
227
+ const live = cnpgDimensions({ row: r, replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0 } })[1]
228
+ expect(live.tone).toBe('degraded')
229
+ expect(live.text).toContain('pg-2 not receiving WAL')
230
+ })
231
+ it('a replica cluster never reads healthy replication without evidence about its upstream feed', () => {
232
+ const replica = { replicaCluster: { source: 'origin' } }
233
+ const streaming = cnpgDimensions({ row: row({ ...replica, instances: { ready: 3, desired: 3 } }), replication: { streaming: 2, standbys: 2, maxReplayLagSeconds: 0 } })[1]
234
+ expect(streaming).toMatchObject({ tone: 'unknown', text: '2 of 2 standbys streaming · upstream feed not assessed' })
235
+ expect(streaming.source).toContain('external cluster origin; Radar does not assess that feed')
236
+ const single = cnpgDimensions({ row: row({ ...replica, instances: { ready: 1, desired: 1 }, pods: [{ name: 'pg-1', role: 'primary', ready: true }] }) })[1]
237
+ expect(single).toMatchObject({ tone: 'unknown', text: 'no standby · upstream feed not assessed' })
238
+ const local = cnpgDimensions({ row: row({ ...replica, instances: { ready: 3, desired: 3 } }), replication: { streaming: 1, standbys: 2, maxReplayLagSeconds: 0 } })[1]
239
+ expect(local).toMatchObject({ tone: 'degraded', text: '1 of 2 expected standbys streaming · upstream feed not assessed' })
240
+ const unread = cnpgDimensions({ row: row(replica), replicationGap: 'needs get pods/proxy in db' })[1]
241
+ expect(unread).toMatchObject({ tone: 'unknown', text: 'unassessed' })
242
+ expect(cnpgDimensions({ row: row({ ...replica, hibernated: true }) })[1]).toMatchObject({ tone: 'neutral', text: 'hibernated' })
243
+ expect(cnpgDimensions({ row: row(), replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0 } })[1].tone).toBe('healthy')
244
+ })
245
+ it('counts expected standbys from spec.instances, not from the Pods still running', () => {
246
+ // spec.instances 3, only the primary's Pod exists, nothing streams.
247
+ const r = row({ instances: { ready: 1, desired: 3 }, pods: [{ name: 'pg-1', role: 'primary', ready: true }] })
248
+ const d = cnpgDimensions({ row: r, replication: { streaming: 0, standbys: 0 } })
249
+ expect(d[1]).toMatchObject({ tone: 'degraded', text: '0 of 2 expected standbys streaming' })
250
+ const one = cnpgDimensions({ row: r, replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0 } })
251
+ expect(one[1]).toMatchObject({ tone: 'degraded', text: '1 of 2 expected standbys streaming' })
252
+ })
253
+ it('replication lag takes the same tone as the Replication fact', () => {
254
+ const at = (lag: number) => cnpgDimensions({ row: row(), replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: lag } })[1]
255
+ expect(at(2)).toMatchObject({ tone: 'healthy' })
256
+ expect(at(8)).toMatchObject({ tone: 'degraded', text: '1 of 1 streaming · replay delay 8.0 s' })
257
+ expect(at(23.6)).toMatchObject({ tone: 'degraded', text: '1 of 1 streaming · replay delay 23 s' })
258
+ expect(at(72)).toMatchObject({ tone: 'unhealthy', text: '1 of 1 streaming · replay delay 72 s' })
259
+ expect(at(72).tone).toBe(cnpgLagTone(72))
260
+ })
261
+ it('a missing standby does not hide a severe lag on the one that streams', () => {
262
+ const r = row({ instances: { ready: 2, desired: 3 } })
263
+ const d = cnpgDimensions({ row: r, replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 72 } })[1]
264
+ expect(d).toMatchObject({ tone: 'unhealthy', text: '1 of 2 expected standbys streaming · replay delay 72 s' })
265
+ expect(cnpgDimensions({ row: r, replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0.2 } })[1]).toMatchObject({
266
+ tone: 'degraded',
267
+ text: '1 of 2 expected standbys streaming',
268
+ })
269
+ })
270
+ it('replication is unknown when spec.instances is not reported', () => {
271
+ const d = cnpgDimensions({ row: row({ instances: { ready: null, desired: null } }), replication: { streaming: 0, standbys: 0 } })
272
+ expect(d[1].tone).toBe('unknown')
273
+ })
274
+ it('a single instance and a missing backup destination are posture, not marked', () => {
275
+ const single = cnpgDimensions({ row: row({ instances: { ready: 1, desired: 1 } }) }).find((x) => x.id === 'replication')
276
+ expect(single).toMatchObject({ tone: 'neutral', text: 'no standby' })
277
+ const unprotected = row({ protection: { ...row().protection, destination: { text: 'None', tone: 'neutral', method: 'none' } } })
278
+ expect(cnpgDimensions({ row: unprotected }).find((x) => x.id === 'protection')).toMatchObject({ tone: 'neutral', text: 'no backup destination' })
279
+ })
280
+ it('replication is unassessed without runtime, never healthy', () => {
281
+ const d = cnpgDimensions({ row: row() })
282
+ expect(d.find((x) => x.id === 'replication')?.text).toBe('unassessed')
283
+ })
284
+ it('serving fails when the rw endpoint is not on the primary', () => {
285
+ const d = cnpgDimensions({ row: row(), ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }) })
286
+ expect(d[0].tone).toBe('unhealthy')
287
+ })
288
+ it('serving is unassessed when instance Pods are not readable', () => {
289
+ const d = cnpgDimensions({ row: row({ pods: [] }) })
290
+ expect(d[0].text).toBe('unassessed')
291
+ })
292
+ })
293
+
294
+ describe('cnpgLeaseHolderPod', () => {
295
+ it('names the Pod of a controller-runtime holder identity', () => {
296
+ expect(cnpgLeaseHolderPod('cnpg-controller-manager-5fbdd6bb78-jx82z_6ec0566c-6da6-47aa-9ab1-0e5d3b2c1f11')).toBe('cnpg-controller-manager-5fbdd6bb78-jx82z')
297
+ expect(cnpgLeaseHolderPod('pg-1')).toBe('pg-1')
298
+ })
299
+ })
300
+
301
+ describe('folded HA and certificates summaries', () => {
302
+ const pdbs = { state: 'ok' as const, enabled: true, items: [{ name: 'pg', role: 'replicas', disruptionsAllowed: 1, currentHealthy: 1, expectedPods: 1, observed: true }] } as unknown as CNPGClusterHA['pdbs']
303
+
304
+ it('stays folded with the known facts when nothing is out of line', () => {
305
+ const live = [{ pod: 'pg-1', state: 'ok' }, { pod: 'pg-2', state: 'ok' }] as never
306
+ expect(cnpgHASummary(ha({ pdbs, operatorLease: { state: 'ok', holder: 'op_1' } }), live)).toEqual({ text: '2/2 observed instances ready · 2 zones · images match', attention: false })
307
+ })
308
+
309
+ it('claims neither readiness nor matching images without instances', () => {
310
+ expect(cnpgHASummary(ha({ pdbs, instances: [], operatorLease: { state: 'ok' } }), [] as never)).toEqual({ text: 'no instance Pods', attention: true })
311
+ const unset = ha({ pdbs, operatorLease: { state: 'ok' }, instances: [{ pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, node: 'n1', zone: 'z1', restartCount: 0 }] })
312
+ expect(cnpgHASummary(unset, [{ pod: 'pg-1', state: 'ok' }] as never).text).toBe('1/1 observed instances ready')
313
+ })
314
+
315
+ it('names what it could not read instead of reading calm', () => {
316
+ const denied = { state: 'denied' as const, grant: { verb: 'list', resource: 'pods', namespace: 'db' } }
317
+ const unread = ha({ pods: denied, nodes: denied, pdbs: { ...denied, enabled: true, items: [] }, primaryLease: denied, operatorLease: denied, jobs: { ...denied, items: [] } } as never)
318
+ expect(cnpgHASummary(unread, undefined)).toEqual({
319
+ text: 'Not read: Pods, zones, disruption budgets, primary lease, operator lease, Jobs, pending restarts',
320
+ attention: false,
321
+ })
322
+ expect(cnpgHASummary(ha({ pdbs }), undefined).text).toBe('2/2 observed instances ready · 2 zones · images match · not read: operator lease, pending restarts')
323
+ })
324
+
325
+ it('opens and leads with what is wrong', () => {
326
+ const shared = ha({
327
+ pdbs,
328
+ instances: [
329
+ { pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, node: 'n1', zone: 'z1', restartCount: 0, image: 'pg:17', imageMatches: true },
330
+ { pod: 'pg-2', podUID: 'b', role: 'replica', ready: false, node: 'n1', zone: 'z1', restartCount: 0, image: 'pg:16', imageMatches: false },
331
+ ],
332
+ })
333
+ const s = cnpgHASummary(shared, [{ pod: 'pg-2', state: 'ok', pendingRestart: true } as never])
334
+ expect(s.attention).toBe(true)
335
+ expect(s.text).toBe('1 of 2 instances not ready · every instance in one zone · instances share a Node · restart pending on pg-2 · an instance Pod has a different image · not read: operator lease')
336
+ })
337
+
338
+ it('names the nearest certificate expiry and who renews them', () => {
339
+ const now = Date.parse('2026-09-30T00:00:00Z')
340
+ const certs = [
341
+ { secret: 'pg-ca', expiresAt: '2026-12-29T00:00:00Z', renewal: 'operator' },
342
+ { secret: 'pg-server', expiresAt: '2026-10-20T00:00:00Z', renewal: 'operator' },
343
+ ] as never
344
+ expect(cnpgCertificatesSummary(certs, now)).toEqual({ text: '2 certificates · nearest reported expiry in 20 d (pg-server) · CloudNativePG renews them', attention: false })
345
+ const oneUnread = [{ secret: 'pg-ca', expiresAt: '2026-12-29T00:00:00Z', renewal: 'operator' }, { secret: 'pg-server', raw: 'garbage', renewal: 'operator' }] as never
346
+ expect(cnpgCertificatesSummary(oneUnread, now)).toEqual({ text: '2 certificates · nearest reported expiry in 90 d (pg-ca) · 1 expiry unreadable · CloudNativePG renews them', attention: true })
347
+ const userSoon = [{ secret: 'app-tls', expiresAt: '2026-10-05T00:00:00Z', renewal: 'user' }] as never
348
+ expect(cnpgCertificatesSummary(userSoon, now).attention).toBe(true)
349
+ expect(cnpgCertificatesSummary([], now)).toEqual({ text: 'No expiry reported by the operator', attention: false })
350
+ })
351
+ })
352
+
353
+ describe('replication chip and the sustained-lag finding', () => {
354
+ it('reads no calmer than a standby measured far behind for the whole window', () => {
355
+ const problem = { id: 'lag:db/pg', reason: 'CNPGSustainedLag', severity: 'critical', category: 'availability', title: 'pg-2 ≥ 24 h behind in every sample for 10 min', subject: { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }, source: 'measurement' } as never
356
+ const dims = cnpgDimensions({ row: row({ problems: [problem] }), replication: { streaming: 1, standbys: 1, maxReplayLagSeconds: 0 } })
357
+ const rep = dims.find((d) => d.id === 'replication')!
358
+ expect(rep.tone).toBe('unhealthy')
359
+ expect(rep.text).toBe('1 of 1 standbys streaming · sustained lag')
360
+ expect(rep.source).toBe('pg-2 ≥ 24 h behind in every sample for 10 min')
361
+ const unread = cnpgDimensions({ row: row({ problems: [problem] }) }).find((d) => d.id === 'replication')!
362
+ expect(unread).toMatchObject({ tone: 'unhealthy', text: 'sustained lag' })
363
+ })
364
+ })
365
+
366
+ describe('backup dimension certainty', () => {
367
+ it('never treats archiving alone as restorable', () => {
368
+ const r = row()
369
+ r.protection.lastSuccessfulBackup = { text: 'No successful backup yet', tone: 'degraded', source: 'Backups read in this namespace; none completed' }
370
+ expect(cnpgDimensions({ row: r }).find((d) => d.id === 'protection')).toMatchObject({ text: 'CNPG reports archiving · no successful backup yet', tone: 'degraded', source: 'Backups read in this namespace; none completed' })
371
+ r.protection.lastSuccessfulBackup = { text: 'No access to Backups', tone: 'unknown', source: 'Backups not read' }
372
+ expect(cnpgDimensions({ row: r }).find((d) => d.id === 'protection')).toMatchObject({ text: 'unassessed', tone: 'unknown', source: 'Backups not read' })
373
+ })
374
+ })
375
+
376
+ it('uses declared instances for readiness and opens for an instance Pod not observed', () => {
377
+ const base = ha({ declaredInstances: 2, expectedInstances: ['pg-1', 'pg-2'] })
378
+ base.instances = base.instances.slice(0, 1)
379
+ const summary = cnpgHASummary(base, [{ pod: 'pg-1', state: 'ok' }])
380
+ expect(summary.attention).toBe(true)
381
+ expect(summary.text).toContain('1 of 2 declared instances ready; no instance Pod observed for pg-2')
382
+ expect(summary.text).not.toContain('1/1')
383
+ })
384
+
385
+ it('names excess observed instances without putting them over a smaller denominator', () => {
386
+ const summary = cnpgHASummary(ha({ declaredInstances: 1 }), undefined)
387
+ expect(summary.text).toContain('2 observed instances ready; 1 instances declared')
388
+ expect(summary.text).not.toContain('2 of 1')
389
+ })
390
+ it('does not claim matching images without observed image evidence', () => {
391
+ expect(cnpgImageDrift(ha({ instances: [] })).known).toBe(false)
392
+ const base = ha()
393
+ base.instances[0].imageMatches = undefined
394
+ expect(cnpgImageDrift(base).known).toBe(false)
395
+ })
396
+
397
+ it('uses a definitive non-serving verdict from an empty complete endpoint read without a primary', () => {
398
+ const r = row({ cluster: { status: {} }, pods: [] })
399
+ const empty = ha({ rwEndpoints: { state: 'ok', service: 'analytics-rw', pods: [] } })
400
+ expect(cnpgDimensions({ row: r, ha: empty })[0]).toMatchObject({ tone: 'unhealthy', text: 'not serving: no ready read-write endpoint', source: 'EndpointSlices of Service analytics-rw' })
401
+ expect(cnpgDimensions({ row: r, ha: empty })[0].tone).toBe('unhealthy')
402
+ expect(cnpgDimensions({ row: r, ha: ha({ rwEndpoints: { state: 'denied', service: 'analytics-rw', pods: [] } }) })[0].tone).toBe('unknown')
403
+ expect(cnpgDimensions({ row: { ...r, hibernated: true }, ha: empty })[0].text).toBe('hibernated')
404
+ })
405
+ it('uses storage reasons for loading, missing Prometheus and denied usage', () => {
406
+ expect(cnpgDimensions({ row: row() })[2].text).toBe('Reading…')
407
+ expect(cnpgDimensions({ row: row({ disk: { tone: 'unknown', text: 'No usage metrics', source: 'Prometheus not connected' } }) })[2].text).toBe('No usage metrics')
408
+ expect(cnpgDimensions({ row: row({ disk: { tone: 'unknown', text: 'No access', source: 'Needs list persistentvolumeclaims in namespace db' } }) })[2].text).toContain('Needs list persistentvolumeclaims')
409
+ })
410
+
411
+ it('retains the missing standby and failover consequence when streaming is denied', () => {
412
+ const h = ha({ expectedInstances: ['pg-1', 'pg-2'], instances: [ha().instances[0]] })
413
+ const d = cnpgDimensions({ row: row(), ha: h, replicationGap: 'needs get pods/proxy in namespace db' }).find((d) => d.id === 'replication')!
414
+ expect(d.tone).toBe('degraded')
415
+ expect(d.text).toBe('Expected standby pg-2 is not running')
416
+ expect(d.source).toContain('streaming not measured: needs get pods/proxy in namespace db')
417
+ expect(d.source).toContain('No ready standby to fail over to')
418
+ const unread = cnpgDimensions({ row: row(), ha: { ...h, pods: { state: 'denied' } } }).find((d) => d.id === 'replication')!
419
+ expect(unread.text).toBe('unassessed')
420
+ expect(unread.source).not.toContain('No ready standby')
421
+ })
422
+
423
+ it('keeps the Storage verdict concise while the Storage notice owns discovery details', () => {
424
+ const r = row({ disk: { tone: 'unknown', text: 'No usage metrics', source: 'Prometheus not connected', detail: 'No working endpoint. Candidate monitoring/prometheus.' } })
425
+ expect(cnpgDimensions({ row: r }).find((d) => d.id === 'storage')).toMatchObject({ text: 'No usage metrics', source: '' })
426
+ })
427
+ it('adds the failover consequence to measured replication without duplicating streaming facts', () => {
428
+ const h = ha({ instances: [ha().instances[0]], expectedInstances: ['pg-1', 'pg-2'] })
429
+ const d = cnpgDimensions({ row: row(), ha: h, replication: { streaming: 0, standbys: 0 } }).find((d) => d.id === 'replication')!
430
+ expect(d.text).toBe('0 of 1 expected standbys streaming')
431
+ expect(d.source).toContain('No ready standby to fail over to')
432
+ expect(d.tone).toBe('degraded')
433
+ })
434
+
435
+ it('preserves separately measured standby gaps beside missing Pods and avoids inventing a grant', () => {
436
+ const h = ha({ expectedInstances: ['pg-1', 'pg-2', 'pg-3'], instances: [ha().instances[0], { ...ha().instances[1], pod: 'pg-3' }] })
437
+ const r = row({ instances: { desired: 3, ready: 2 }, problems: [{ id: 'standby:db/pg:pg-3', reason: 'CNPGStandbyNotReceiving', severity: 'warning', category: 'replication', title: 'pg-3 receiver is down', subject: { kind: 'Pod', name: 'pg-3' }, source: 'measurement' } as any] })
438
+ const d = cnpgDimensions({ row: r, ha: h }).find((d) => d.id === 'replication')!
439
+ expect(d.text).toContain('Expected standby pg-2 is not running')
440
+ expect(d.text).toContain('pg-3 not receiving WAL')
441
+ expect(d.source).toContain('pg-3 receiver is down')
442
+ expect(d.source).toContain('streaming not measured: not read')
443
+ expect(d.source).not.toContain('needs get pods/proxy')
444
+ h.instances = [h.instances[0]]
445
+ expect(cnpgDimensions({ row: r, ha: h }).find((d) => d.id === 'replication')!.text).toContain('Expected standbys pg-2, pg-3 are not running')
446
+ })