@skyhook-io/k8s-ui 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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -1,8 +1,27 @@
1
- import { getCNPGPoolerDeploymentName, getCNPGPoolerMode, getCNPGPoolerStatus, isCNPGPoolerPaused } from '../resources/resource-utils-cnpg'
1
+ import { Tooltip } from '../ui/Tooltip'
2
+ import type { ReactNode } from 'react'
3
+ import { getCNPGPoolerDeploymentName, getCNPGPoolerMode, isCNPGPoolerPaused } from '../resources/resource-utils-cnpg'
2
4
  import type { CNPGWorkspaceResponse } from './workspace'
3
- import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, type CNPGNavigate } from './primitives'
4
5
  import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell } from './CNPGSharedSummary'
5
6
  import { refOf } from './relations'
7
+ import {
8
+ POOLER_LIMIT_PARAMETERS,
9
+ aggregatePoolerPools,
10
+ poolerPodPressure,
11
+ poolerPressureCoverage,
12
+ poolerPressureFact,
13
+ observedPause,
14
+ poolerBackendService,
15
+ poolerReadiness,
16
+ type CNPGPoolerLive,
17
+ type CNPGPoolerPoolRow,
18
+ } from './pooler'
19
+ import { type NavigateToRef, RefLink } from '../ui/RefLink'
20
+ import { toneTextClass } from '../ui/status-tone'
21
+ import { FactGrid, FactRow, FactValue } from '../facts'
22
+ import { Badge } from '../ui/Badge'
23
+ import { summarizeSchedulerMessage } from '../resources/resource-utils'
24
+ import { SectionHeading, FoldSection } from '../ui/FoldSection'
6
25
 
7
26
  const TYPE_LABEL: Record<string, string> = {
8
27
  rw: 'rw · routes to the primary',
@@ -14,36 +33,53 @@ export function CNPGPoolerSummary({
14
33
  resource,
15
34
  workspace,
16
35
  onNavigate,
36
+ live,
37
+ actions,
38
+ lead,
17
39
  }: {
18
40
  resource: any
19
41
  workspace: CNPGWorkspaceResponse | null
20
- onNavigate?: CNPGNavigate
42
+ onNavigate?: NavigateToRef
43
+ /** Live reads a host adds (Deployment readiness, PgBouncer metrics and state). */
44
+ live?: CNPGPoolerLive
45
+ /** Operations rendered beside the paused state (pause / resume). */
46
+ actions?: ReactNode
47
+ /** Rendered first, e.g. a host's notice that a live read is stale. */
48
+ lead?: ReactNode
21
49
  }) {
22
50
  const ns = resource?.metadata?.namespace ?? ''
23
51
  const type = resource?.spec?.type
24
52
  const desired = resource?.spec?.instances
25
- const scheduled = resource?.status?.instances
53
+ const reported = resource?.status?.instances
26
54
  const deployment = getCNPGPoolerDeploymentName(resource)
55
+ const paused = isCNPGPoolerPaused(resource)
56
+ const readiness = poolerReadiness(live?.deployment)
57
+ const observed = observedPause(live?.observed)
27
58
 
28
59
  return (
29
60
  <SummaryShell>
61
+ {lead}
30
62
  <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'Pooler')} onNavigate={onNavigate} />
31
63
 
32
- <SummaryHeading>State</SummaryHeading>
64
+ <SectionHeading>State</SectionHeading>
33
65
  <FactGrid>
34
66
  <FactRow label="Status">
35
- <PhaseBadge status={getCNPGPoolerStatus(resource)} />
36
- {isCNPGPoolerPaused(resource) && <Note>PgBouncer is paused: it holds client connections instead of serving them.</Note>}
67
+ <div className="flex flex-wrap items-center gap-1">
68
+ <PhaseBadge status={{ text: readiness.text, color: '', level: readiness.level }} />
69
+ {paused && <Badge severity="warning" size="sm">Pause requested</Badge>}
70
+ </div>
71
+ {readiness.detail && <Note>{readiness.detail}</Note>}
72
+ <CNPGPoolerScheduling namespace={ns} pods={live?.pressure?.pods ?? []} onNavigate={onNavigate} />
37
73
  </FactRow>
38
74
  <FactRow label="Instances">
39
75
  <span>
40
- {typeof scheduled === 'number' ? `${scheduled} scheduled` : <NotReported text="Scheduled count not reported" />}
76
+ {typeof reported === 'number' ? `Pooler status reports ${reported} ${reported === 1 ? 'instance' : 'instances'}` : <NotReported text="Pooler instance count not reported" />}
41
77
  <span className="text-theme-text-secondary">
42
78
  {' · '}
43
- {typeof desired === 'number' ? `${desired} desired` : 'desired not set'}
79
+ {typeof desired === 'number' ? `${desired} requested` : 'requested count not set'}
44
80
  </span>
45
81
  </span>
46
- <Note>The Pooler counts scheduled pods, not ready ones; readiness is on its Deployment.</Note>
82
+ <Note>status.instances is the operator’s count; observed readiness is on its Deployment and Pods.</Note>
47
83
  </FactRow>
48
84
  <FactRow label="Deployment">
49
85
  {deployment ? (
@@ -52,19 +88,210 @@ export function CNPGPoolerSummary({
52
88
  <NotReported />
53
89
  )}
54
90
  </FactRow>
55
- <FactRow label="Connection pressure">
56
- <FactValue fact={{ text: 'Not measured — needs PgBouncer metrics, which Radar does not read yet', tone: 'unknown' }} />
91
+ <FactRow label="Pause state">
92
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
93
+ <span>{paused ? 'Pause requested' : 'Serving requested (not paused)'}</span>
94
+ {actions}
95
+ </div>
96
+ <Note>spec.pgbouncer.paused is what was asked for; each PgBouncer applies it with PAUSE / RESUME.</Note>
97
+ {paused && <Note>When PgBouncer applies the pause, it holds client connections instead of serving them.</Note>}
98
+ {observed && (
99
+ <div className="mt-1">
100
+ <FactValue fact={{ text: `Observed: ${observed.text}`, tone: observed.level, source: 'Each PgBouncer’s SHOW STATE' }} />
101
+ </div>
102
+ )}
103
+ </FactRow>
104
+ </FactGrid>
105
+
106
+ <SectionHeading hint={live?.pressure ? 'live from each PgBouncer' : undefined}>Connections</SectionHeading>
107
+ {live?.pressure ? (
108
+ <PoolerPressure pressure={live.pressure} />
109
+ ) : (
110
+ <FactGrid>
111
+ <FactRow label="Connection pressure">
112
+ <FactValue fact={{ text: 'Not measured — needs PgBouncer metrics, which Radar does not read yet', tone: 'unknown' }} />
113
+ </FactRow>
114
+ </FactGrid>
115
+ )}
116
+
117
+ <SectionHeading>Limits</SectionHeading>
118
+ <FactGrid>
119
+ <FactRow label="Pool mode">
120
+ {getCNPGPoolerMode(resource) === 'transaction' ? 'A client uses a server connection only for each transaction.' : getCNPGPoolerMode(resource) === 'session' ? 'A client keeps one server connection for its whole session.' : 'A client uses a server connection for each statement.'}
121
+ <Note>{getCNPGPoolerMode(resource)}{!resource?.spec?.pgbouncer?.poolMode ? ' (default)' : ''}</Note>
57
122
  </FactRow>
123
+ {POOLER_LIMIT_PARAMETERS.map((p) => {
124
+ const v = resource?.spec?.pgbouncer?.parameters?.[p.key]
125
+ return (
126
+ <FactRow key={p.key} label={p.label}>
127
+ {v !== undefined ? (
128
+ <span className="font-mono">{String(v)}</span>
129
+ ) : (
130
+ <span className="text-theme-text-secondary">
131
+ default{p.pgbouncerDefault ? <span className="text-theme-text-tertiary"> · PgBouncer uses {p.pgbouncerDefault}</span> : null}
132
+ </span>
133
+ )}
134
+ <Note>
135
+ <span className="font-mono">{p.key}</span>
136
+ </Note>
137
+ </FactRow>
138
+ )
139
+ })}
58
140
  </FactGrid>
59
141
 
60
- <SummaryHeading>Routing</SummaryHeading>
142
+ <SectionHeading>Routing</SectionHeading>
61
143
  <FactGrid>
62
144
  <FactRow label="Cluster">
63
145
  <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
64
146
  </FactRow>
65
147
  <FactRow label="Type">{type ? TYPE_LABEL[type] ?? type : <NotReported text="Not set" />}</FactRow>
66
- <FactRow label="Pool mode">{getCNPGPoolerMode(resource)}</FactRow>
148
+ {live?.service && (
149
+ <FactRow label="Path">
150
+ <PoolerPath resource={resource} live={live} onNavigate={onNavigate} />
151
+ </FactRow>
152
+ )}
67
153
  </FactGrid>
68
154
  </SummaryShell>
69
155
  )
70
156
  }
157
+
158
+ function PoolerPath({ resource, live, onNavigate }: { resource: any; live: CNPGPoolerLive; onNavigate?: NavigateToRef }) {
159
+ const ns = resource?.metadata?.namespace ?? ''
160
+ const svc = live.service!
161
+ const backend = poolerBackendService(resource?.spec?.cluster?.name, resource?.spec?.type)
162
+ const svcState: Record<string, string> = { missing: 'does not exist', unreadable: 'no access', foreign: 'not controlled by this Pooler' }
163
+ return (
164
+ <div className="space-y-0.5">
165
+ <div>
166
+ Service <RefLink refTo={{ kind: 'Service', namespace: ns, name: svc.name }} onNavigate={onNavigate} mono />
167
+ {svc.state === 'ok' ? (
168
+ <span className="text-theme-text-secondary">{svc.port ? ` :${svc.port}` : ''}{svc.type ? ` · ${svc.type}` : ''}</span>
169
+ ) : (
170
+ <span className={toneTextClass('unknown')}> · {svcState[svc.state]}</span>
171
+ )}
172
+ </div>
173
+ <div className="text-theme-text-secondary">→ PgBouncer ({resource?.metadata?.name})</div>
174
+ <div className="text-theme-text-secondary">
175
+ → {backend ? <RefLink refTo={{ kind: 'Service', namespace: ns, name: backend }} onNavigate={onNavigate} mono /> : 'the cluster'}
176
+ {' '}of Cluster {resource?.spec?.cluster?.name ?? '—'}
177
+ </div>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ // Pods take their client connections independently, so one can queue while
183
+ // the sum still looks calm.
184
+ function PoolerPodPressure({ pods }: { pods: NonNullable<CNPGPoolerLive['pressure']>['pods'] }) {
185
+ return (
186
+ <table className="mt-3 w-full text-sm">
187
+ <thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
188
+ <tr>
189
+ <th className="py-1 pr-3">PgBouncer Pod</th>
190
+ <th className="pr-3 text-right">Clients active</th>
191
+ <th className="pr-3 text-right">Waiting</th>
192
+ <th className="pr-3 text-right">Servers active</th>
193
+ <th className="text-right">Max wait</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody className="table-divide-subtle">
197
+ {poolerPodPressure(pods).map((r) =>
198
+ r.state === 'ok' || r.state === 'partial' ? (
199
+ <tr key={r.pod}>
200
+ <td className="py-1 pr-3 font-mono text-xs">
201
+ {r.pod}
202
+ {r.state === 'partial' && <span className="ml-1 font-sans text-theme-text-tertiary">partial</span>}
203
+ </td>
204
+ <td className="pr-3 text-right font-mono"><FactValue fact={poolerPressureFact(pods.filter((p) => p.pod === r.pod), 'clActive')} /></td>
205
+ <td className={`pr-3 text-right font-mono ${r.clWaiting ? toneTextClass('degraded') : ''}`}><FactValue fact={poolerPressureFact(pods.filter((p) => p.pod === r.pod), 'clWaiting')} /></td>
206
+ <td className="pr-3 text-right font-mono"><FactValue fact={poolerPressureFact(pods.filter((p) => p.pod === r.pod), 'svActive')} /></td>
207
+ <td className={`text-right font-mono ${r.maxwaitSeconds ? toneTextClass('degraded') : ''}`}><FactValue fact={poolerPressureFact(pods.filter((p) => p.pod === r.pod), 'maxwaitSeconds')} /></td>
208
+ </tr>
209
+ ) : (
210
+ <tr key={r.pod}>
211
+ <td className="py-1 pr-3 font-mono text-xs">{r.pod}</td>
212
+ <td colSpan={4} className="text-right text-xs text-theme-text-tertiary">
213
+ Not read: {r.error ?? r.state}
214
+ </td>
215
+ </tr>
216
+ ),
217
+ )}
218
+ </tbody>
219
+ </table>
220
+ )
221
+ }
222
+
223
+ function PoolerPressure({ pressure }: { pressure: NonNullable<CNPGPoolerLive['pressure']> }) {
224
+ if (pressure.state === 'loading') return <div className="text-sm text-theme-text-tertiary">Reading PgBouncer metrics…</div>
225
+ if (pressure.state === 'denied' || pressure.state === 'error') {
226
+ return <FactValue fact={{ text: `Not measured — ${pressure.reason ?? (pressure.state === 'denied' ? 'needs get pods/proxy' : 'read failed')}`, tone: 'unknown' }} />
227
+ }
228
+ const { reporting, limitation, empty } = poolerPressureCoverage(pressure.pods)
229
+ const rows = aggregatePoolerPools(reporting)
230
+ if (reporting.length === 0) {
231
+ return <CNPGPoolerUnmeasured pods={pressure.pods} />
232
+ }
233
+ return (
234
+ <div>
235
+ {rows.length === 0 ? (
236
+ <div className="text-sm text-theme-text-secondary">{empty}</div>
237
+ ) : (
238
+ <table className="w-full text-sm">
239
+ <thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
240
+ <tr>
241
+ <th className="py-1 pr-3">Pool</th>
242
+ <th className="pr-3">Mode</th>
243
+ <th className="pr-3 text-right">Clients active</th>
244
+ <th className="pr-3 text-right">Waiting</th>
245
+ <th className="pr-3 text-right">Servers active / idle / used</th>
246
+ <th className="text-right">Max wait</th>
247
+ </tr>
248
+ </thead>
249
+ <tbody className="table-divide-subtle">
250
+ {rows.map((r: CNPGPoolerPoolRow) => (
251
+ <tr key={`${r.database}/${r.user}`}>
252
+ <td className="py-1 pr-3 font-mono text-xs">{r.database}/{r.user}</td>
253
+ <td className="pr-3 text-xs">{r.poolModes.join(', ') || '—'}</td>
254
+ <td className="pr-3 text-right font-mono"><FactValue fact={poolerPressureFact(pressure.pods, 'clActive', r)} /></td>
255
+ <td className={`pr-3 text-right font-mono ${r.clWaiting ? toneTextClass('degraded') : ''}`}><FactValue fact={poolerPressureFact(pressure.pods, 'clWaiting', r)} /></td>
256
+ <td className="pr-3 text-right font-mono"><FactValue fact={poolerPressureFact(pressure.pods, 'svActive', r)} /> / <FactValue fact={poolerPressureFact(pressure.pods, 'svIdle', r)} /> / <FactValue fact={poolerPressureFact(pressure.pods, 'svUsed', r)} /></td>
257
+ <td className={`text-right font-mono ${r.maxwaitSeconds ? toneTextClass('degraded') : ''}`}><FactValue fact={poolerPressureFact(pressure.pods, 'maxwaitSeconds', r)} /></td>
258
+ </tr>
259
+ ))}
260
+ </tbody>
261
+ </table>
262
+ )}
263
+ <Note>
264
+ Summed over {reporting.length} of {pressure.pods.length} PgBouncer Pods{limitation ? ` · ${limitation}` : ''}. PgBouncer’s admin and
265
+ authentication pools are excluded.
266
+ </Note>
267
+ {pressure.pods.length > 1 && <PoolerPodPressure pods={pressure.pods} />}
268
+ </div>
269
+ )
270
+ }
271
+
272
+ export function CNPGPoolerScheduling({ namespace, pods, onNavigate }: { namespace: string; pods: NonNullable<CNPGPoolerLive['pressure']>['pods']; onNavigate?: NavigateToRef }) {
273
+ const byCause = new Map<string, typeof pods>()
274
+ for (const pod of pods) {
275
+ if (!pod.schedulingReason) continue
276
+ const cause = summarizeSchedulerMessage(pod.schedulingReason, { plain: true })
277
+ const group = byCause.get(cause) ?? []
278
+ group.push(pod)
279
+ byCause.set(cause, group)
280
+ }
281
+ return <>{[...byCause].map(([cause, blocked]) => <div key={cause} className={`mt-1 text-xs ${toneTextClass('degraded')}`}>
282
+ <div>Cannot be scheduled: {cause}.</div>
283
+ {blocked.map((p) => <div key={p.pod}>
284
+ <div className="mt-0.5 text-theme-text-secondary"><Tooltip content={p.pod} wrapperClassName="block max-w-full [&_button]:block [&_button]:max-w-full [&_button]:truncate [&_span.font-mono]:block [&_span.font-mono]:truncate"><RefLink refTo={{ kind: 'Pod', group: '', namespace, name: p.pod }} onNavigate={onNavigate} mono /></Tooltip></div>
285
+ <FoldSection title="Scheduler message" summary="" attention={false}><div className="break-words text-theme-text-secondary">{p.schedulingReason}</div></FoldSection>
286
+ </div>)}
287
+ </div>)}</>
288
+ }
289
+
290
+ export function CNPGPoolerUnmeasured({ pods }: { pods: NonNullable<CNPGPoolerLive['pressure']>['pods'] }) {
291
+ if (pods.length === 0) return <div className="text-theme-text-tertiary">Not measured: no PgBouncer answered</div>
292
+ return <>{pods.map((p) => <div key={p.pod} className="text-theme-text-tertiary">
293
+ Not measured: {p.state === 'denied' ? 'needs get pods/proxy' : p.reason ?? 'PgBouncer did not answer'}
294
+ <Note>{p.pod}</Note>
295
+ {p.error && <FoldSection title="Measurement details" summary="" attention={false}><div className="break-words text-xs">{p.error}</div></FoldSection>}
296
+ </div>)}</>
297
+ }
@@ -3,8 +3,11 @@ import { Badge } from '../ui/Badge'
3
3
  import type { StatusBadge as StatusBadgeValue } from '../resources/resource-utils'
4
4
  import { CNPG_GROUP } from '../resources/resource-utils-cnpg'
5
5
  import type { CNPGWorkspaceIssue, CNPGWorkspaceResponse } from './workspace'
6
- import { FactValue, ProblemCallout, RefLink, type CNPGNavigate } from './primitives'
7
- import { clustersIn, healthSeverity, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations'
6
+ import { healthToSeverity } from '../../utils/badge-colors'
7
+ import { clustersIn, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations'
8
+ import { type NavigateToRef, RefLink } from '../ui/RefLink'
9
+ import { FactValue } from '../facts'
10
+ import { ProblemCallout } from './primitives'
8
11
 
9
12
  const MAX_PROBLEMS = 3
10
13
 
@@ -20,7 +23,7 @@ export function ObjectProblems({
20
23
  }: {
21
24
  issues: CNPGWorkspaceIssue[] | undefined
22
25
  subject: CNPGObjectRef
23
- onNavigate?: CNPGNavigate
26
+ onNavigate?: NavigateToRef
24
27
  }) {
25
28
  const problems = problemsForObject(issues, subject)
26
29
  if (problems.length === 0) return null
@@ -43,7 +46,7 @@ export function ObjectProblems({
43
46
 
44
47
  export function PhaseBadge({ status }: { status: StatusBadgeValue }) {
45
48
  return (
46
- <Badge severity={healthSeverity(status.level)} size="sm">
49
+ <Badge severity={healthToSeverity(status.level)} size="sm">
47
50
  {status.text}
48
51
  </Badge>
49
52
  )
@@ -71,16 +74,20 @@ export function ClusterLink({
71
74
  }: {
72
75
  resource: any
73
76
  workspace: CNPGWorkspaceResponse | null
74
- onNavigate?: CNPGNavigate
77
+ onNavigate?: NavigateToRef
75
78
  }) {
76
79
  const name = resource?.spec?.cluster?.name
77
80
  if (!name) return <NotReported text="Not set" />
78
81
  const ns = resource?.metadata?.namespace ?? ''
79
- const visible = !!targetCluster(resource, clustersIn(workspace))
82
+ const clusters = clustersIn(workspace)
83
+ const visible = !!targetCluster(resource, clusters)
84
+ const replaced = !visible && clusters.some((c) => c.metadata?.namespace === ns && c.metadata?.name === name)
80
85
  return (
81
86
  <span>
82
- <RefLink refTo={{ kind: 'Cluster', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
83
- {workspace && !visible && !relationUnavailable(workspace, 'clusters', ns, 'Clusters') && (
87
+ <RefLink refTo={{ kind: 'Cluster', group: CNPG_GROUP, namespace: ns, name }} onNavigate={replaced ? undefined : onNavigate} mono />
88
+ {replaced ? (
89
+ <span className="text-theme-text-tertiary"> · an earlier Cluster of that name; the current one is a different object</span>
90
+ ) : workspace && !visible && !relationUnavailable(workspace, 'clusters', ns, 'Clusters') && (
84
91
  <span className="text-theme-text-tertiary"> · not found in this namespace</span>
85
92
  )}
86
93
  </span>
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { CNPGWALArchivingFact } from './CNPGClusterSummary'
6
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
7
+ it('folds the raw condition and exposes relative age with the exact timestamp on hover', () => {
8
+ vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-01T13:00:00Z'))
9
+ const stamp = '2026-10-01T12:00:00Z'
10
+ const host = document.createElement('div'); document.body.append(host); const root = createRoot(host)
11
+ act(() => root.render(<CNPGWALArchivingFact fact={{ state: 'no_destination' as const, text: 'Not archived: no destination configured', tone: 'neutral', detail: 'WAL is not archived to recovery storage, so point-in-time recovery is unavailable.', operatorCondition: { type: 'ContinuousArchiving', status: 'True', message: 'Continuous archiving is working', lastTransitionTime: stamp } }} />))
12
+ expect(host.textContent).toContain('WAL is not archived to recovery storage')
13
+ const button = host.querySelector('button')!
14
+ expect(button.getAttribute('aria-expanded')).toBe('false')
15
+ const panel = document.getElementById(button.getAttribute('aria-controls')!)!
16
+ expect(panel.firstElementChild!.hasAttribute('inert')).toBe(true)
17
+ act(() => button.click())
18
+ expect(button.getAttribute('aria-expanded')).toBe('true')
19
+ expect(host.textContent).toContain('ContinuousArchiving: True')
20
+ expect(host.textContent).toContain('Continuous archiving is working')
21
+ expect(host.textContent).toContain('1h ago')
22
+ expect(host.querySelector('time')?.dateTime).toBe(stamp)
23
+ act(() => { host.querySelector('time')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(350) })
24
+ expect(document.body.textContent).toContain(stamp)
25
+ act(() => root.unmount()); host.remove(); vi.useRealTimers()
26
+ })
27
+
28
+ it('keeps the compact verdict, consequence and per-row operator disclosure', () => {
29
+ const host = document.createElement('div'); const root = createRoot(host)
30
+ act(() => root.render(<CNPGWALArchivingFact compact fact={{ state: 'no_destination' as const, text: 'Archive destination absent', tone: 'neutral', detail: 'CloudNativePG still reports archiving as working because it accepts each WAL file without keeping it.', operatorCondition: { type: 'ContinuousArchiving', status: 'True', message: 'working' } }} />))
31
+ expect(host.textContent).toContain('Archive destination absent')
32
+ expect(host.textContent).toContain('No point-in-time recovery')
33
+ expect(host.textContent).not.toContain('because')
34
+ const fold = host.querySelector('button')!
35
+ expect(fold.textContent).toContain('Operator report')
36
+ act(() => fold.click())
37
+ expect(host.textContent).toContain('ContinuousArchiving: True')
38
+ act(() => root.unmount())
39
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cnpgBackupRunInFlight, cnpgBackupRunsInWindow } from './backupRuns'
3
+
4
+ const now = Date.parse('2026-10-05T12:00:00Z')
5
+ const backup = (phase: string, ageDays: number) => ({ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: phase }, status: { phase, startedAt: new Date(now - ageDays * 86400000).toISOString() } })
6
+
7
+ describe('backup run window', () => {
8
+ it('retains every in-flight phase regardless of age, including an unknown phase', () => {
9
+ const runs = ['pending', 'started', 'running', 'finalizing', 'walArchivingFailing', 'new-phase'].map((phase) => backup(phase, 10))
10
+ expect(cnpgBackupRunsInWindow(runs, now)).toHaveLength(runs.length)
11
+ expect(runs.every(cnpgBackupRunInFlight)).toBe(true)
12
+ })
13
+ it('applies the cutoff only to settled CNPG runs', () => {
14
+ const runs = [backup('completed', 10), backup('failed', 10), backup('completed', 2), backup('failed', 1), { ...backup('running', 1), apiVersion: 'velero.io/v1' }]
15
+ expect(cnpgBackupRunsInWindow(runs, now).map((r) => r.status.phase)).toEqual(['failed', 'completed'])
16
+ })
17
+ })
@@ -0,0 +1,19 @@
1
+ import { isApiGroup } from '../resources/resource-utils-cnpg'
2
+
3
+ export const CNPG_BACKUP_RUN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
4
+
5
+ export function cnpgBackupRunInFlight(backup: any): boolean {
6
+ return backup?.status?.phase !== 'completed' && backup?.status?.phase !== 'failed'
7
+ }
8
+
9
+ export function cnpgBackupRunTime(backup: any): string | undefined {
10
+ return backup?.status?.stoppedAt || backup?.status?.startedAt || backup?.metadata?.creationTimestamp
11
+ }
12
+
13
+ export function cnpgBackupRunsInWindow(backups: any[], now = Date.now()): any[] {
14
+ return backups.filter((b) => {
15
+ if (!isApiGroup(b.apiVersion, 'postgresql.cnpg.io')) return false
16
+ const time = Date.parse(cnpgBackupRunTime(b) ?? '')
17
+ return cnpgBackupRunInFlight(b) || (Number.isFinite(time) && now - time <= CNPG_BACKUP_RUN_WINDOW_MS)
18
+ }).sort((a, b) => Date.parse(cnpgBackupRunTime(b) ?? '') - Date.parse(cnpgBackupRunTime(a) ?? ''))
19
+ }
@@ -0,0 +1,124 @@
1
+ import { cnpgEndpointAvailability } from './connect'
2
+ import type { CNPGClusterHA } from './ha'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { cnpgConnectInfo, cnpgConnectionURI, cnpgPortForwardCommand, cnpgPsqlCommand } from './connect'
5
+
6
+ const cluster = (spec: any = {}) => ({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'db' }, spec: { instances: 3, ...spec } })
7
+
8
+ describe('cnpgConnectInfo', () => {
9
+ it('forwards the actual Service port to the local template port', () => {
10
+ const endpoint = { ...cnpgConnectInfo(cluster()).endpoints[0], port: 6543 }
11
+ expect(cnpgPortForwardCommand(endpoint, 'db')).toBe('kubectl -n db port-forward service/pg-rw 5432:6543')
12
+ expect(cnpgPortForwardCommand(endpoint, 'db', 15432)).toBe('kubectl -n db port-forward service/pg-rw 15432:6543')
13
+ })
14
+ it('lists the default Services and names the Secret by convention when the spec does not', () => {
15
+ const info = cnpgConnectInfo(cluster())
16
+ expect(info.endpoints.map((e) => `${e.role} ${e.host}:${e.port}`)).toEqual(['rw pg-rw.db.svc:5432', 'ro pg-ro.db.svc:5432', 'r pg-r.db.svc:5432'])
17
+ expect(info.database).toEqual({ value: 'app', source: "CloudNativePG's default" })
18
+ expect(info.owner.value).toBe('app')
19
+ expect(info.secret).toEqual({ name: 'pg-app', source: 'name by convention (<cluster>-app)', byConvention: true })
20
+ expect(info.endpoints.find((e) => e.role === 'r')?.selects).toBe('any instance (may reach the primary; not read-only)')
21
+ expect(info.endpoints.find((e) => e.role === 'ro')?.selects).toBe('standbys only (read-only)')
22
+ const extra = cnpgConnectInfo(cluster({ managed: { services: { additional: [{ selectorType: 'r', serviceTemplate: { metadata: { name: 'pg-any' } } }] } } }))
23
+ expect(extra.endpoints.find((e) => e.name === 'pg-any')?.selects).toContain('not read-only')
24
+ })
25
+
26
+ it('reads database, owner and Secret the way CloudNativePG resolves them: recovery, pg_basebackup, then initdb', () => {
27
+ const info = cnpgConnectInfo(
28
+ cluster({
29
+ bootstrap: {
30
+ initdb: { database: 'orders', owner: 'orders_owner', secret: { name: 'init-secret' } },
31
+ recovery: { database: 'restored', secret: { name: 'recovery-secret' } },
32
+ },
33
+ }),
34
+ )
35
+ expect(info.database).toEqual({ value: 'restored', source: 'spec.bootstrap.recovery.database' })
36
+ expect(info.owner).toEqual({ value: 'orders_owner', source: 'spec.bootstrap.initdb.owner' })
37
+ expect(info.secret).toEqual({ name: 'recovery-secret', source: 'spec.bootstrap.recovery.secret.name', byConvention: false })
38
+ })
39
+
40
+ it('treats a distributed-topology replica (replica.primary names another cluster) as a replica, like the operator', () => {
41
+ expect(cnpgConnectInfo(cluster({ replica: { primary: 'pg-east', source: 'pg-east' } })).replicaCluster).toBe(true)
42
+ expect(cnpgConnectInfo(cluster({ replica: { primary: 'pg', source: 'pg-east' } })).replicaCluster).toBe(false)
43
+ expect(cnpgConnectInfo(cluster({ replica: { enabled: true, source: 'pg-east' } })).replicaCluster).toBe(true)
44
+ expect(cnpgConnectInfo(cluster()).replicaCluster).toBe(false)
45
+ })
46
+
47
+ it('owner defaults to the database name', () => {
48
+ expect(cnpgConnectInfo(cluster({ bootstrap: { initdb: { database: 'orders' } } })).owner).toEqual({
49
+ value: 'orders',
50
+ source: "CloudNativePG's default: the database's name",
51
+ })
52
+ })
53
+
54
+ it('does not invent a database for a monolithic import', () => {
55
+ const info = cnpgConnectInfo(cluster({ bootstrap: { initdb: { import: { type: 'monolith' } } } }))
56
+ expect(info.database.value).toBeUndefined()
57
+ expect(info.owner.value).toBeUndefined()
58
+ })
59
+
60
+ it('drops disabled default Services and adds managed and Pooler Services with their ports', () => {
61
+ const info = cnpgConnectInfo(
62
+ cluster({
63
+ managed: {
64
+ services: {
65
+ disabledDefaultServices: ['ro', 'r'],
66
+ additional: [{ selectorType: 'rw', serviceTemplate: { metadata: { name: 'pg-lb' }, spec: { type: 'LoadBalancer', ports: [{ port: 6543 }] } } }],
67
+ },
68
+ },
69
+ }),
70
+ [
71
+ { metadata: { name: 'pg-pooler-ro', namespace: 'db' }, spec: { cluster: { name: 'pg' }, type: 'ro' } },
72
+ { metadata: { name: 'other', namespace: 'db' }, spec: { cluster: { name: 'pg2' } } },
73
+ { metadata: { name: 'pg-pooler', namespace: 'elsewhere' }, spec: { cluster: { name: 'pg' } } },
74
+ ],
75
+ )
76
+ expect(info.disabled).toEqual(['ro', 'r'])
77
+ expect(info.endpoints.map((e) => `${e.role} ${e.name}:${e.port}${e.portFromTemplate ? '*' : ''}`)).toEqual(['rw pg-rw:5432', 'additional pg-lb:6543*', 'pooler pg-pooler-ro:5432'])
78
+ expect(info.endpoints[2].poolerType).toBe('ro')
79
+ expect(info.endpoints[1].selects).toBe('the primary (read-write)')
80
+ })
81
+
82
+ it('builds templates with a password placeholder only', () => {
83
+ const info = cnpgConnectInfo(cluster({ bootstrap: { initdb: { database: 'orders', owner: 'o w' } } }))
84
+ expect(cnpgConnectionURI(info.endpoints[0], info)).toBe('postgresql://o%20w:<password>@pg-rw.db.svc:5432/orders')
85
+ expect(cnpgPsqlCommand(info.endpoints[0], info)).toBe("psql -h pg-rw.db.svc -p 5432 -U 'o w' -d orders")
86
+ const quoted = cnpgConnectInfo(cluster({ bootstrap: { initdb: { database: "it's" } } }))
87
+ expect(cnpgPsqlCommand(quoted.endpoints[0], quoted)).toBe(`psql -h pg-rw.db.svc -p 5432 -U 'it'\\''s' -d 'it'\\''s'`)
88
+ })
89
+ })
90
+
91
+ it('pins port-forward to a shell-quoted kubeconfig context when supplied', () => {
92
+ const ep = cnpgConnectInfo({ metadata: { name: 'orders', namespace: 'prod' } }).endpoints[0]
93
+ expect(cnpgPortForwardCommand(ep, 'prod', 5432, 'kind-cnpg')).toBe('kubectl --context kind-cnpg -n prod port-forward service/orders-rw 5432:5432')
94
+ expect(cnpgPortForwardCommand(ep, 'prod', 5432, 'test context')).toContain("--context 'test context'")
95
+ })
96
+
97
+ it('reads rw endpoints separately from standby and any-instance Pod readiness', () => {
98
+ const eps = cnpgConnectInfo({ metadata: { name: 'orders', namespace: 'prod' } }).endpoints
99
+ const ha = { rwEndpoints: { state: 'ok', pods: ['orders-1'] }, pods: { state: 'ok' }, instances: [{ pod: 'orders-1', role: 'primary', ready: true }, { pod: 'orders-2', role: 'replica', ready: false }] } as CNPGClusterHA
100
+ expect(eps.map((ep) => cnpgEndpointAvailability(ep, ha).text)).toEqual(['Ready endpoints', 'Unavailable: no ready standby', 'Ready instance observed'])
101
+ expect(cnpgEndpointAvailability(eps[1], { ...ha, instances: [] }).text).toBe('Unavailable: no ready standby')
102
+ expect(eps.map((ep) => cnpgEndpointAvailability(ep).text)).toEqual(['Not checked', 'Not checked', 'Not checked'])
103
+ expect(cnpgEndpointAvailability(eps[0], { ...ha, rwEndpoints: { ...ha.rwEndpoints, state: 'denied' } }).text).toBe('Not checked')
104
+ expect(cnpgEndpointAvailability(eps[1], { ...ha, pods: { state: 'denied' } }).text).toBe('Not checked')
105
+ expect(cnpgEndpointAvailability(eps[1], { ...ha, instances: [{ ...ha.instances[0], role: 'unknown' }] }).text).toBe('Not checked')
106
+ })
107
+
108
+ it('keeps Pooler routing faithful for any-instance and unrecognized selectors', () => {
109
+ for (const [type, selects] of [['rw', 'the primary'], ['ro', 'the standbys'], ['r', 'any instance (may reach the primary; not read-only)'], ['future', 'selector future']]) {
110
+ const ep = cnpgConnectInfo(cluster(), [{ metadata: { name: 'pooler', namespace: 'db' }, spec: { cluster: { name: 'pg' }, type } }]).endpoints.find((e) => e.role === 'pooler')!
111
+ expect(ep.poolerType).toBe(type)
112
+ expect(ep.selects).toBe(`PgBouncer in front of ${selects}`)
113
+ expect(cnpgEndpointAvailability(ep).source).toBe('This Service’s availability has not been read')
114
+ }
115
+ })
116
+ it('names the denied grant, failed read, unknown role and unread source alongside Not checked', () => {
117
+ const eps = cnpgConnectInfo(cluster()).endpoints
118
+ const ha = { rwEndpoints: { state: 'denied', grant: { verb: 'list', group: 'discovery.k8s.io', resource: 'endpointslices', namespace: 'db' } }, pods: { state: 'error', reason: 'API timeout' }, instances: [] } as unknown as CNPGClusterHA
119
+ expect(cnpgEndpointAvailability(eps[0], ha)).toEqual({ text: 'Not checked', source: 'No access to Service EndpointSlices (needs list endpointslices (discovery.k8s.io) in namespace db)' })
120
+ expect(cnpgEndpointAvailability(eps[1], ha)).toEqual({ text: 'Not checked', source: 'instance Pods could not be read: API timeout' })
121
+ expect(cnpgEndpointAvailability(eps[0], undefined, 'Reading availability…').source).toBe('Reading availability…')
122
+ expect(cnpgEndpointAvailability(eps[0], undefined, 'Availability could not be read: timeout').source).toBe('Availability could not be read: timeout')
123
+ expect(cnpgEndpointAvailability(eps[1], { ...ha, pods: { state: 'ok' }, instances: [{ role: 'unknown', ready: true }] } as CNPGClusterHA).source).toBe('Ready instance roles were not reported')
124
+ })