@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
@@ -0,0 +1,621 @@
1
+ // High-availability facts for one CloudNativePG Cluster, from
2
+ // GET /api/cnpg/clusters/{ns}/{name}/ha, plus pure derivations the Overview and
3
+ // the switchover dialog share. Each sub-read carries its own state: a denied or
4
+ // unavailable source is "unknown", never none or healthy.
5
+
6
+ import { formatAge, type HealthLevel } from '../resources/resource-utils'
7
+ import { formatGrant, type Grant } from '../../utils/grant'
8
+ import { CNPG_PROMETHEUS_NOT_CONNECTED, cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, type CNPGFleetRow } from './workspace'
9
+ import { type Fact } from '../facts'
10
+ import { type FoldSummary } from '../ui/FoldSection'
11
+ import { worseTone } from '../ui/status-tone'
12
+
13
+ export type CNPGHASourceState = 'ok' | 'denied' | 'notFound' | 'notInstalled' | 'unavailable' | 'error'
14
+
15
+ export interface CNPGHASource {
16
+ state: CNPGHASourceState
17
+ reason?: string
18
+ grant?: Grant
19
+ }
20
+
21
+ export interface CNPGHAInstance {
22
+ pod: string
23
+ podUID: string
24
+ role: 'primary' | 'replica' | 'unknown'
25
+ ready: boolean
26
+ node?: string
27
+ zone?: string
28
+ qosClass?: string
29
+ image?: string
30
+ imageMatches?: boolean
31
+ podCreatedAt?: string
32
+ postgresStartedAt?: string
33
+ restartCount: number
34
+ }
35
+
36
+ export interface CNPGHAQuorum {
37
+ enabled: boolean
38
+ enabledBy?: 'spec' | 'annotation'
39
+ method?: string
40
+ number?: number
41
+ dataDurability?: string
42
+ object: CNPGHASource
43
+ status?: { method?: string; standbyNames: string[]; standbyNumber: number; primary?: string }
44
+ n?: number
45
+ w?: number
46
+ r?: number
47
+ promotable?: string[]
48
+ holds?: boolean
49
+ }
50
+
51
+ export interface CNPGHAPDB {
52
+ name: string
53
+ role: 'primary' | 'replicas' | 'other'
54
+ minAvailable?: string
55
+ maxUnavailable?: string
56
+ expectedPods: number
57
+ currentHealthy: number
58
+ desiredHealthy: number
59
+ disruptionsAllowed: number
60
+ observed: boolean
61
+ }
62
+
63
+ export interface CNPGHALease extends CNPGHASource {
64
+ namespace?: string
65
+ name?: string
66
+ holder?: string
67
+ renewTime?: string
68
+ durationSeconds?: number
69
+ expired?: boolean
70
+ controlledByCluster?: boolean
71
+ }
72
+
73
+ export interface CNPGHAJob {
74
+ name: string
75
+ role?: string
76
+ instance?: string
77
+ phase: 'running' | 'active' | 'succeeded' | 'failed' | 'pending'
78
+ reason?: string
79
+ startTime?: string
80
+ completionTime?: string
81
+ }
82
+
83
+ export interface CNPGHACertificate {
84
+ secret: string
85
+ purposes?: string[]
86
+ raw: string
87
+ expiresAt?: string
88
+ renewal: 'operator' | 'user'
89
+ metadata?: CNPGHASource
90
+ certManager?: { certificate: string; issuer?: string; issuerKind?: string }
91
+ }
92
+
93
+ export interface CNPGMaintenanceFacts {
94
+ declared: boolean
95
+ inProgress: boolean
96
+ reusePVC: boolean
97
+ }
98
+
99
+ export interface CNPGClusterHA {
100
+ cluster: { namespace: string; name: string; uid: string }
101
+ sampledAt: string
102
+ desiredImage?: string
103
+ declaredInstances?: number
104
+ expectedInstances?: string[]
105
+ instances: CNPGHAInstance[]
106
+ pods: CNPGHASource
107
+ nodes: CNPGHASource
108
+ quorum: CNPGHAQuorum
109
+ pdbs: CNPGHASource & { enabled: boolean; items: CNPGHAPDB[] }
110
+ primaryLease: CNPGHALease
111
+ operatorLease: CNPGHALease
112
+ jobs: CNPGHASource & { items: CNPGHAJob[] }
113
+ rwEndpoints: CNPGHASource & { service: string; pods: string[] }
114
+ certificates: CNPGHACertificate[]
115
+ maintenance: CNPGMaintenanceFacts
116
+ }
117
+
118
+ /**
119
+ * Per-instance facts read live from each instance manager (/pg/status). Only
120
+ * callers who can read them pass them in: they never come from the cached
121
+ * Cluster object.
122
+ */
123
+ export interface CNPGInstanceLive {
124
+ pod: string
125
+ /** ok, or why the instance's own report is missing. */
126
+ state: string
127
+ pendingRestart?: boolean
128
+ pendingRestartForDecrease?: boolean
129
+ /** The report did not finish its reads, so pendingRestart is not established. */
130
+ incomplete?: boolean
131
+ /** Why the report is missing or incomplete, in words. */
132
+ reason?: string
133
+ roleDetail?: 'primary' | 'pgRewind' | 'replayPaused' | 'streaming' | 'fileBased'
134
+ instanceManagerVersion?: string
135
+ timeline?: number
136
+ }
137
+
138
+ export const CNPG_ROLE_DETAIL_TEXT: Record<NonNullable<CNPGInstanceLive['roleDetail']>, string> = {
139
+ primary: 'primary',
140
+ pgRewind: 'pg_rewind running',
141
+ replayPaused: 'replay paused',
142
+ streaming: 'streaming standby',
143
+ fileBased: 'file-based standby (no WAL receiver)',
144
+ }
145
+
146
+ export function cnpgHASourceText(src: CNPGHASource | undefined, what: string): string {
147
+ if (!src) return `${what}: unknown`
148
+ switch (src.state) {
149
+ case 'ok':
150
+ return ''
151
+ case 'denied':
152
+ return `No access to ${what}${src.grant ? ` (needs ${formatGrant(src.grant)})` : ''}`
153
+ case 'notInstalled':
154
+ return src.reason ?? `${what}: not available in this CloudNativePG version`
155
+ case 'notFound':
156
+ return src.reason ?? `No ${what}`
157
+ case 'unavailable':
158
+ return src.reason ?? `${what}: unavailable`
159
+ default:
160
+ return src.reason ? `${what} could not be read: ${src.reason}` : `${what} could not be read`
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Failure domains
166
+
167
+ export interface CNPGZoneSpread {
168
+ /** false when Nodes (or Pods) could not be read: zones are unknown. */
169
+ known: boolean
170
+ zones: { zone: string; pods: string[] }[]
171
+ /** Instances whose Node carries no zone label. */
172
+ unlabelled: string[]
173
+ primaryZone?: string
174
+ /** true when every labelled instance shares one zone (and there is more than one instance). */
175
+ singleZone: boolean
176
+ nodes: { node: string; pods: string[] }[]
177
+ /** true when two or more instances share one Node. */
178
+ sharedNode: boolean
179
+ }
180
+
181
+ export function cnpgZoneSpread(ha: CNPGClusterHA | undefined): CNPGZoneSpread {
182
+ const empty: CNPGZoneSpread = { known: false, zones: [], unlabelled: [], singleZone: false, nodes: [], sharedNode: false }
183
+ if (!ha || ha.pods.state !== 'ok') return empty
184
+ const byNode = new Map<string, string[]>()
185
+ for (const i of ha.instances) {
186
+ if (!i.node) continue
187
+ byNode.set(i.node, [...(byNode.get(i.node) ?? []), i.pod])
188
+ }
189
+ const nodes = [...byNode.entries()].map(([node, pods]) => ({ node, pods })).sort((a, b) => a.node.localeCompare(b.node))
190
+ const sharedNode = nodes.some((n) => n.pods.length > 1)
191
+ if (ha.nodes.state !== 'ok') return { ...empty, nodes, sharedNode }
192
+ const byZone = new Map<string, string[]>()
193
+ const unlabelled: string[] = []
194
+ let primaryZone: string | undefined
195
+ for (const i of ha.instances) {
196
+ if (!i.zone) {
197
+ unlabelled.push(i.pod)
198
+ continue
199
+ }
200
+ byZone.set(i.zone, [...(byZone.get(i.zone) ?? []), i.pod])
201
+ if (i.role === 'primary') primaryZone = i.zone
202
+ }
203
+ const zones = [...byZone.entries()].map(([zone, pods]) => ({ zone, pods })).sort((a, b) => a.zone.localeCompare(b.zone))
204
+ return {
205
+ known: true,
206
+ zones,
207
+ unlabelled,
208
+ primaryZone,
209
+ singleZone: zones.length === 1 && ha.instances.length > 1 && unlabelled.length === 0,
210
+ nodes,
211
+ sharedNode,
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Quorum, PDB, images, certificates
217
+
218
+ export function cnpgQuorumFact(q: CNPGHAQuorum | undefined): Fact {
219
+ if (!q) return { text: 'Unknown', tone: 'unknown' }
220
+ if (!q.enabled) {
221
+ if (q.number !== undefined || q.method) {
222
+ return { text: `Synchronous ${q.method ?? ''} ${q.number ?? ''}`.replace(/\s+/g, ' ').trim() + ' · quorum failover off', tone: 'neutral', source: 'Cluster spec.postgresql.synchronous' }
223
+ }
224
+ return { text: 'Off (asynchronous replication)', tone: 'neutral', source: 'Cluster spec' }
225
+ }
226
+ const unread = cnpgHASourceText(q.object, 'FailoverQuorum')
227
+ if (q.object.state !== 'ok') return { text: `Quorum failover on · ${unread}`, tone: 'unknown' }
228
+ if (q.n === undefined || q.w === undefined) {
229
+ return {
230
+ text: 'Quorum failover on · no synchronous configuration recorded: a failover would wait',
231
+ tone: 'degraded',
232
+ source: 'FailoverQuorum status (reset while PostgreSQL configuration changes)',
233
+ }
234
+ }
235
+ const base = `W ${q.w} of N ${q.n} potentially synchronous`
236
+ if (q.r === undefined || q.holds === undefined) {
237
+ return { text: `${base} · promotable replicas unknown`, tone: 'unknown', source: 'FailoverQuorum status; Pods not readable' }
238
+ }
239
+ return {
240
+ text: `${base} · R ${q.r} promotable · R + W ${q.holds ? '>' : '≤'} N`,
241
+ tone: q.holds ? 'healthy' : 'degraded',
242
+ source: 'FailoverQuorum status (the recorded configuration, not the operator’s decision); R from ready standby Pods',
243
+ }
244
+ }
245
+
246
+ export function cnpgPDBFact(pdbs: CNPGClusterHA['pdbs'] | undefined): Fact {
247
+ if (!pdbs) return { text: 'Unknown', tone: 'unknown' }
248
+ if (pdbs.state !== 'ok') return { text: cnpgHASourceText(pdbs, 'PodDisruptionBudgets'), tone: 'unknown' }
249
+ if (pdbs.items.length === 0) {
250
+ return pdbs.enabled
251
+ ? { text: 'None found although spec.enablePDB is on', tone: 'degraded', source: 'PodDisruptionBudgets owned by the Cluster' }
252
+ : { text: 'Disabled (spec.enablePDB: false): node drains are not held back', tone: 'neutral', source: 'Cluster spec' }
253
+ }
254
+ const parts = pdbs.items.map((p) => `${p.role === 'primary' ? 'primary' : p.role === 'replicas' ? 'standbys' : p.name}: ${p.disruptionsAllowed} disruption${p.disruptionsAllowed === 1 ? '' : 's'} allowed (${p.currentHealthy}/${p.expectedPods} healthy)`)
255
+ const stale = pdbs.items.some((p) => !p.observed)
256
+ return {
257
+ text: parts.join(' · '),
258
+ tone: stale ? 'unknown' : 'neutral',
259
+ source: stale ? 'PodDisruptionBudget status (not yet updated for the latest spec)' : 'PodDisruptionBudget status',
260
+ }
261
+ }
262
+
263
+ export function cnpgImageDrift(ha: CNPGClusterHA | undefined): { known: boolean; drifted: CNPGHAInstance[] } {
264
+ if (!ha || ha.pods.state !== 'ok' || !ha.desiredImage || ha.instances.length === 0) return { known: false, drifted: [] }
265
+ return { known: ha.instances.every((i) => i.imageMatches !== undefined), drifted: ha.instances.filter((i) => i.imageMatches === false) }
266
+ }
267
+
268
+ export interface CNPGCertificateView extends CNPGHACertificate {
269
+ /** Whole days until expiry; negative when expired; undefined when the expiry did not parse. */
270
+ daysLeft?: number
271
+ tone: HealthLevel
272
+ }
273
+
274
+ /**
275
+ * The same thresholds as the Issues engine: a certificate its owner renews is
276
+ * flagged from 30 days; the operator renews its own, so those only matter once
277
+ * renewal is overdue.
278
+ */
279
+ export function cnpgCertificateViews(certs: CNPGHACertificate[] | undefined, now = Date.now()): CNPGCertificateView[] {
280
+ return (certs ?? []).map((c) => {
281
+ if (!c.expiresAt) return { ...c, tone: 'unknown' as HealthLevel }
282
+ const ms = Date.parse(c.expiresAt) - now
283
+ const daysLeft = Math.floor(ms / 86_400_000)
284
+ let tone: HealthLevel = 'healthy'
285
+ if (ms <= 0) tone = 'unhealthy'
286
+ else if (c.renewal === 'user' && ms < 7 * 86_400_000) tone = 'unhealthy'
287
+ else if (c.renewal === 'user' && ms < 30 * 86_400_000) tone = 'degraded'
288
+ else if (c.renewal === 'operator' && ms < 86_400_000) tone = 'unhealthy'
289
+ return { ...c, daysLeft, tone }
290
+ })
291
+ }
292
+
293
+ /**
294
+ * "HA and instances" in one line: what is wrong when something is, otherwise
295
+ * the readiness and placement facts that are known. Unknown facts never read
296
+ * as fine; they are left out of a calm summary rather than claimed.
297
+ */
298
+ export function cnpgHASummary(
299
+ ha: CNPGClusterHA | undefined,
300
+ live: CNPGInstanceLive[] | undefined,
301
+ primaryConflict?: { status: string; labelled: string },
302
+ ): FoldSummary {
303
+ if (!ha) return { text: 'Not read', attention: false }
304
+ const issues: string[] = []
305
+ const calm: string[] = []
306
+ if (ha.pods.state === 'ok') {
307
+ const ready = ha.instances.filter((i) => i.ready).length
308
+ const missing = [...new Set(ha.expectedInstances ?? [])].filter((name) => !ha.instances.some((i) => i.pod === name))
309
+ if (ha.declaredInstances !== undefined && ready < ha.declaredInstances) {
310
+ issues.push(`${ready} of ${ha.declaredInstances} declared instances ready${missing.length ? `; no instance Pod observed for ${missing.join(', ')}` : ''}`)
311
+ } else if (ha.declaredInstances !== undefined && ready > ha.declaredInstances) {
312
+ issues.push(`${ready} observed instances ready; ${ha.declaredInstances} instances declared`)
313
+ } else if (ha.instances.length === 0) issues.push('no instance Pods')
314
+ else if (ready < ha.instances.length) issues.push(`${ha.instances.length - ready} of ${ha.instances.length} instances not ready`)
315
+ else calm.push(ha.declaredInstances !== undefined ? `${ready} of ${ha.declaredInstances} declared instances ready` : `${ready}/${ha.instances.length} observed instances ready`)
316
+ }
317
+ if (primaryConflict) issues.push('primary labels disagree')
318
+ const spread = cnpgZoneSpread(ha)
319
+ if (spread.singleZone) issues.push('every instance in one zone')
320
+ if (spread.sharedNode) issues.push('instances share a Node')
321
+ if (spread.known && !spread.singleZone && spread.zones.length > 1) calm.push(`${spread.zones.length} zones`)
322
+ const pending = cnpgPendingRestart(live)
323
+ if (pending.pods.length > 0) issues.push(`restart pending on ${pending.pods.join(', ')}`)
324
+ const drift = cnpgImageDrift(ha)
325
+ if (drift.drifted.length > 0) issues.push(`${drift.drifted.length === 1 ? 'an instance Pod has' : `${drift.drifted.length} instance Pods have`} a different image`)
326
+ else if (drift.known && ha.instances.length > 0 && ha.instances.every((i) => i.imageMatches === true)) calm.push('images match')
327
+ const quorum = cnpgQuorumFact(ha.quorum)
328
+ if (quorum.tone === 'degraded' || quorum.tone === 'unhealthy') issues.push('failover quorum does not hold')
329
+ const pdb = cnpgPDBFact(ha.pdbs)
330
+ if (pdb.tone === 'degraded' || pdb.tone === 'unhealthy') issues.push('disruption budgets missing')
331
+ for (const [lease, what] of [[ha.primaryLease, 'primary lease'], [ha.operatorLease, 'operator lease']] as const) {
332
+ if (lease.state === 'ok' && lease.expired) issues.push(`${what} expired`)
333
+ }
334
+ if (ha.primaryLease.state === 'ok' && ha.primaryLease.controlledByCluster === false) issues.push('primary lease not owned by this Cluster')
335
+ const failedJobs = ha.jobs.state === 'ok' ? ha.jobs.items.filter((j) => j.phase === 'failed').length : 0
336
+ if (failedJobs > 0) issues.push(`${failedJobs} failed ${failedJobs === 1 ? 'Job' : 'Jobs'}`)
337
+ // A fact that could not be read is named, never folded into a calm line.
338
+ const unread: string[] = []
339
+ const check = (src: CNPGHASource | undefined, label: string) => {
340
+ if (src && (src.state === 'denied' || src.state === 'unavailable' || src.state === 'error')) unread.push(label)
341
+ }
342
+ check(ha.pods, 'Pods')
343
+ check(ha.nodes, 'zones')
344
+ if (ha.quorum.enabled) check(ha.quorum.object, 'quorum')
345
+ check(ha.pdbs, 'disruption budgets')
346
+ check(ha.primaryLease, 'primary lease')
347
+ check(ha.operatorLease, 'operator lease')
348
+ check(ha.jobs, 'Jobs')
349
+ if (!pending.known && !(ha.pods.state === 'ok' && ha.instances.length === 0)) unread.push('pending restarts')
350
+ const notRead = unread.length > 0 ? `not read: ${unread.join(', ')}` : ''
351
+ if (issues.length > 0) return { text: [...issues, notRead].filter(Boolean).join(' · '), attention: true }
352
+ if (calm.length === 0) return { text: notRead ? notRead[0].toUpperCase() + notRead.slice(1) : 'Nothing reported out of line', attention: false }
353
+ return { text: [...calm, notRead].filter(Boolean).join(' · '), attention: false }
354
+ }
355
+
356
+ /** Certificates in one line: the nearest expiry and who renews them. */
357
+ export function cnpgCertificatesSummary(certs: CNPGHACertificate[] | undefined, now = Date.now()): FoldSummary {
358
+ const views = cnpgCertificateViews(certs, now)
359
+ if (views.length === 0) return { text: 'No expiry reported by the operator', attention: false }
360
+ const dated = views.filter((c) => Number.isFinite(c.daysLeft)).sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))
361
+ const unreadable = views.length - dated.length
362
+ // An unreadable expiry could be past already, so it opens the section too.
363
+ const attention = unreadable > 0 || views.some((c) => c.tone === 'degraded' || c.tone === 'unhealthy')
364
+ const nearest = dated[0]
365
+ const when = [
366
+ !nearest ? '' : nearest.daysLeft! < 0 ? `${nearest.secret} expired` : `nearest reported expiry in ${nearest.daysLeft} d (${nearest.secret})`,
367
+ unreadable > 0 ? `${unreadable} expiry unreadable` : '',
368
+ ]
369
+ .filter(Boolean)
370
+ .join(' · ')
371
+ const renewers = new Set(views.map((c) => c.renewal))
372
+ const who = renewers.size > 1 ? 'some renewed by you' : renewers.has('user') ? 'you renew them' : 'CloudNativePG renews them'
373
+ return { text: `${views.length} ${views.length === 1 ? 'certificate' : 'certificates'} · ${when} · ${who}`, attention }
374
+ }
375
+
376
+ function cnpgLiveRead(l: CNPGInstanceLive): boolean {
377
+ return (l.state === 'ok' || l.state === 'partial') && !l.incomplete
378
+ }
379
+
380
+ export function cnpgPendingRestart(live: CNPGInstanceLive[] | undefined): { known: boolean; pods: string[]; forDecrease: boolean } {
381
+ const read = (live ?? []).filter(cnpgLiveRead)
382
+ if (read.length === 0) return { known: false, pods: [], forDecrease: false }
383
+ const pending = read.filter((l) => l.pendingRestart)
384
+ return { known: read.length === (live ?? []).length, pods: pending.map((l) => l.pod), forDecrease: pending.some((l) => l.pendingRestartForDecrease) }
385
+ }
386
+
387
+ /**
388
+ * Why instance-manager facts are not established: the host's reason when
389
+ * there is no runtime read at all (`unavailable`, e.g. the missing grant),
390
+ * otherwise each instance that did not report and why.
391
+ */
392
+ export function cnpgLiveGap(live: CNPGInstanceLive[] | undefined, unavailable?: string): string {
393
+ if (!live) return unavailable ?? 'needs each instance manager’s status (get pods/proxy)'
394
+ if (live.length === 0) return 'no instance was read'
395
+ const unread = live.filter((l) => !cnpgLiveRead(l))
396
+ if (unread.length === 0) return ''
397
+ return unread
398
+ .map((l) => {
399
+ const why = l.reason ?? (l.state === 'denied' ? 'no access' : l.state)
400
+ return l.incomplete ? `${l.pod} reported incompletely (${why})` : `${l.pod} did not report (${why})`
401
+ })
402
+ .join('; ')
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Header dimensions
407
+
408
+ export type CNPGDimensionId = 'serving' | 'replication' | 'protection' | 'storage'
409
+
410
+ export interface CNPGDimension {
411
+ id: CNPGDimensionId
412
+ label: string
413
+ /** unknown = unassessed: its source is not available. */
414
+ tone: HealthLevel
415
+ text: string
416
+ source: string
417
+ }
418
+
419
+ export interface CNPGReplicationLive {
420
+ /** Standbys the primary reports as streaming. */
421
+ streaming: number
422
+ /** Standby Pods the runtime read saw; the verdict compares against spec.instances − 1, not this. */
423
+ standbys: number
424
+ maxReplayLagSeconds?: number
425
+ }
426
+
427
+ export function cnpgDimensions({
428
+ row,
429
+ ha,
430
+ replication,
431
+ replicationGap,
432
+ storage,
433
+ }: {
434
+ row: CNPGFleetRow
435
+ ha?: CNPGClusterHA
436
+ /** From the primary's pg_stat_replication; absent when runtime data is not readable. */
437
+ replication?: CNPGReplicationLive
438
+ /** Why `replication` is absent (e.g. "needs get pods/proxy in db"). */
439
+ replicationGap?: string
440
+ /** Supplied by the host once storage is assessed; unassessed otherwise. */
441
+ storage?: CNPGDimension
442
+ }): CNPGDimension[] {
443
+ return [
444
+ servingDimension(row, ha),
445
+ replicationDimension(row, replication, replicationGap, ha),
446
+ storage ?? storageDimension(row),
447
+ protectionDimension(row),
448
+ ]
449
+ }
450
+
451
+ function storageDimension(row: CNPGFleetRow): CNPGDimension {
452
+ return withSlotRetention(row, volumeDimension(row))
453
+ }
454
+
455
+ function volumeDimension(row: CNPGFleetRow): CNPGDimension {
456
+ const base = { id: 'storage' as const, label: 'Storage' }
457
+ const disk = row.disk
458
+ if (!disk) return { ...base, tone: 'unknown', text: 'Reading…', source: 'Reading volume usage' }
459
+ if (disk.tone === 'unknown') return { ...base, tone: 'unknown', text: disk.source === CNPG_PROMETHEUS_NOT_CONNECTED ? disk.text : [disk.text, disk.source].filter(Boolean).join(': '), source: disk.source === CNPG_PROMETHEUS_NOT_CONNECTED ? '' : disk.detail ?? '' }
460
+ return { ...base, tone: disk.tone, text: disk.text, source: disk.source ?? 'Fullest volume' }
461
+ }
462
+
463
+ // WAL an inactive slot pins is a storage concern even while volume usage is
464
+ // unmeasured; the chip says so instead of reading "unassessed".
465
+ function withSlotRetention(row: CNPGFleetRow, dim: CNPGDimension): CNPGDimension {
466
+ const slot = row.problems.find((p) => p.reason === 'CNPGInactiveSlot')
467
+ if (!slot) return dim
468
+ const held = 'WAL held by an inactive slot'
469
+ return {
470
+ ...dim,
471
+ tone: worseTone(dim.tone, 'degraded'),
472
+ text: dim.tone === 'unknown' ? held : `${dim.text} · ${held}`,
473
+ source: dim.tone === 'unknown' ? `${slot.title}. Volume usage: ${[dim.text, dim.source].filter(Boolean).join(' · ')}` : `${slot.title}. ${dim.source ?? ''}`.trim(),
474
+ }
475
+ }
476
+
477
+ function servingDimension(row: CNPGFleetRow, ha?: CNPGClusterHA): CNPGDimension {
478
+ const base = { id: 'serving' as const, label: 'Serving' }
479
+ if (row.hibernated) return { ...base, tone: 'neutral', text: 'hibernated', source: 'cnpg.io/hibernation annotation' }
480
+ if (ha?.rwEndpoints.state === 'ok' && ha.rwEndpoints.pods.length === 0) return { ...base, tone: 'unhealthy', text: 'not serving: no ready read-write endpoint', source: `EndpointSlices of Service ${ha.rwEndpoints.service}` }
481
+ const primaryName = row.cluster?.status?.currentPrimary as string | undefined
482
+ const primary = row.pods.find((p) => p.name === primaryName)
483
+ if (!primaryName) return { ...base, tone: 'unknown', text: 'unassessed', source: 'No current primary reported' }
484
+ if (!primary || primary.ready === null) return { ...base, tone: 'unknown', text: 'unassessed', source: 'Instance Pods are not readable' }
485
+ if (!primary.ready) return { ...base, tone: 'unhealthy', text: 'primary not ready', source: `Pod ${primaryName} readiness` }
486
+ if (ha?.rwEndpoints.state === 'ok') {
487
+ if (!ha.rwEndpoints.pods.includes(primaryName)) {
488
+ return {
489
+ ...base,
490
+ tone: 'unhealthy',
491
+ text: ha.rwEndpoints.pods.length === 0 ? 'no read-write endpoint' : 'read-write endpoint not on the primary',
492
+ source: `EndpointSlices of Service ${ha.rwEndpoints.service}`,
493
+ }
494
+ }
495
+ return { ...base, tone: 'healthy', text: 'primary ready', source: `Pod ${primaryName} ready and behind Service ${ha.rwEndpoints.service}` }
496
+ }
497
+ return { ...base, tone: 'healthy', text: 'primary ready', source: `Pod ${primaryName} readiness (Service endpoints not readable)` }
498
+ }
499
+
500
+ // A standby measured far behind for the whole window outranks what the live
501
+ // read shows: the chip must not read calmer than the finding below it.
502
+ function replicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string, ha?: CNPGClusterHA): CNPGDimension {
503
+ let dim = withStandbyGaps(row, liveReplicationDimension(row, live, gap))
504
+ if (!row.hibernated && !row.replicaCluster && (row.instances.desired ?? 0) > 1) {
505
+ const primary = row.cluster?.status?.currentPrimary
506
+ const pods = ha?.pods.state === 'ok' ? ha.instances.map((i) => ({ name: i.pod, ready: i.ready })) : row.podReadiness ? row.pods : undefined
507
+ if (pods && primary) {
508
+ const expected = ha?.pods.state === 'ok' ? ha.expectedInstances ?? row.cluster?.status?.instanceNames ?? [] : row.cluster?.status?.instanceNames ?? []
509
+ const missing = expected.filter((name: string) => name !== primary && !pods.some((p) => p.name === name))
510
+ if (!live && missing.length > 0) {
511
+ const otherGaps = row.problems.filter((p) => p.reason === 'CNPGStandbyNotReceiving' && !missing.includes(p.subject.name))
512
+ const measuredGaps = withStandbyGaps({ ...row, problems: otherGaps }, liveReplicationDimension(row, live, gap))
513
+ dim = {
514
+ ...dim, tone: worseTone(dim.tone, 'degraded'),
515
+ text: `Expected standby${missing.length === 1 ? '' : 's'} ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not running${otherGaps.length > 0 ? ` · ${measuredGaps.text}` : ''}`,
516
+ source: `${otherGaps.length > 0 ? `${measuredGaps.source}; ` : ''}Instance Pods; streaming not measured: ${gap ?? 'not read'}`,
517
+ }
518
+ }
519
+ if (pods.some((p) => p.name === primary && p.ready === true) && !pods.some((p) => p.name !== primary && p.ready === true)) dim = {
520
+ ...dim, tone: worseTone(dim.tone, 'degraded'), source: `${dim.source}. No ready standby to fail over to`,
521
+ }
522
+ }
523
+ }
524
+ const sustained = row.problems.find((p) => p.reason === 'CNPGSustainedLag')
525
+ if (!sustained) return dim
526
+ const tone: HealthLevel = sustained.severity === 'critical' ? 'unhealthy' : 'degraded'
527
+ return {
528
+ ...dim,
529
+ tone: worseTone(dim.tone, tone),
530
+ text: dim.tone === 'unknown' ? 'sustained lag' : `${dim.text} · sustained lag`,
531
+ source: sustained.title,
532
+ }
533
+ }
534
+
535
+ // A standby another source saw receiving nothing (Prometheus in the fleet, the
536
+ // instance manager here) keeps the chip from reading calm or unassessed.
537
+ function withStandbyGaps(row: CNPGFleetRow, dim: CNPGDimension): CNPGDimension {
538
+ const gaps = row.problems.filter((p) => p.reason === 'CNPGStandbyNotReceiving')
539
+ if (gaps.length === 0) return dim
540
+ const pods = gaps.map((p) => p.subject.name).join(', ')
541
+ const tone: HealthLevel = gaps.some((p) => p.severity === 'critical') ? 'unhealthy' : 'degraded'
542
+ return {
543
+ ...dim,
544
+ tone: worseTone(dim.tone, tone),
545
+ text: dim.tone === 'unknown' ? `${pods} not receiving WAL` : `${dim.text} · ${pods} not receiving WAL`,
546
+ source: gaps.map((p) => p.title).join('; '),
547
+ }
548
+ }
549
+
550
+ function liveReplicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string): CNPGDimension {
551
+ const dim = localReplicationDimension(row, live, gap)
552
+ return row.replicaCluster && !row.hibernated ? withUpstreamUnassessed(row.replicaCluster, dim) : dim
553
+ }
554
+
555
+ // A replica cluster's designated primary replays WAL from outside the cluster,
556
+ // by streaming or from the archive. Its pg_stat_replication lists only the
557
+ // standbys downstream of it, so a calm local reading says nothing about that
558
+ // feed: a dead one still reads every local standby streaming.
559
+ function withUpstreamUnassessed(replica: { source?: string }, dim: CNPGDimension): CNPGDimension {
560
+ const calm = dim.tone === 'healthy' || dim.tone === 'neutral'
561
+ return {
562
+ ...dim,
563
+ tone: calm ? 'unknown' : dim.tone,
564
+ text: dim.tone === 'unknown' && dim.text === 'unassessed' ? dim.text : `${dim.text} · upstream feed not assessed`,
565
+ source: `${dim.source}. The designated primary replays WAL from ${replica.source ? `external cluster ${replica.source}` : 'an external cluster'}; Radar does not assess that feed`,
566
+ }
567
+ }
568
+
569
+ function localReplicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string): CNPGDimension {
570
+ const base = { id: 'replication' as const, label: 'Replication' }
571
+ if (row.hibernated) return { ...base, tone: 'neutral', text: 'hibernated', source: 'cnpg.io/hibernation annotation' }
572
+ if (row.instances.desired === 1) return { ...base, tone: 'neutral', text: 'no standby', source: 'spec.instances is 1: there is no failover target' }
573
+ if (!live) return { ...base, tone: 'unknown', text: 'unassessed', source: `Needs the primary’s pg_stat_replication: ${gap ?? 'read through get pods/proxy'}` }
574
+ // Standbys whose Pods are gone are missing from the runtime read too, so
575
+ // the denominator is what the Cluster asks for, never what is running.
576
+ const desired = row.instances.desired
577
+ if (desired === null) {
578
+ return { ...base, tone: 'unknown', text: `${live.streaming} streaming`, source: 'spec.instances is not reported, so the expected standbys are unknown' }
579
+ }
580
+ const expected = Math.max(0, desired - 1)
581
+ const source = `Primary’s pg_stat_replication against spec.instances ${desired}`
582
+ const lag = live.maxReplayLagSeconds
583
+ const tone = cnpgReplicationTone(live.streaming, expected, lag)
584
+ // pg_stat_replication's replay_lag is the recent replay delay, not WAL still
585
+ // to replay; it can stay high after a standby caught up, so it is not "behind".
586
+ const lagText = lag !== undefined && cnpgLagTone(lag) !== 'healthy' ? ` · replay delay ${cnpgFormatLag(lag)}` : ''
587
+ if (live.streaming < expected) {
588
+ return { ...base, tone, text: `${live.streaming} of ${expected} expected standbys streaming${lagText}`, source }
589
+ }
590
+ if (lagText) return { ...base, tone, text: `${live.streaming} of ${expected} streaming${lagText}`, source }
591
+ return { ...base, tone: 'healthy', text: `${live.streaming} of ${expected} standbys streaming`, source }
592
+ }
593
+
594
+ function protectionDimension(row: CNPGFleetRow): CNPGDimension {
595
+ const base = { id: 'protection' as const, label: 'Backups' }
596
+ const p = row.protection
597
+ if (p.walArchiving.tone === 'unhealthy') return { ...base, tone: 'unhealthy', text: 'WAL archiving failing', source: 'ContinuousArchiving condition' }
598
+ const blockedSchedule = row.problems.find((problem) => problem.reason === 'CNPGScheduleDestinationMissing')
599
+ if (blockedSchedule) return { ...base, tone: 'degraded', text: blockedSchedule.title, source: blockedSchedule.origin?.detail ?? 'ScheduledBackup method against Cluster spec' }
600
+ if (p.destination.method === 'none') return { ...base, tone: 'neutral', text: 'no backup destination', source: 'Cluster spec' }
601
+ if (p.lastSuccessfulBackup.tone === 'unhealthy' || p.lastSuccessfulBackup.tone === 'degraded') {
602
+ const text = !p.lastSuccessfulBackup.at && p.walArchiving.tone === 'healthy'
603
+ ? `CNPG reports archiving · ${p.lastSuccessfulBackup.text.toLowerCase()}`
604
+ : p.lastSuccessfulBackup.text
605
+ return { ...base, tone: p.lastSuccessfulBackup.tone, text, source: p.lastSuccessfulBackup.source ?? 'Backups' }
606
+ }
607
+ if (p.lastSuccessfulBackup.tone === 'unknown') return { ...base, tone: 'unknown', text: 'unassessed', source: p.lastSuccessfulBackup.source ?? p.lastSuccessfulBackup.text }
608
+ if (p.walArchiving.tone === 'unknown') return { ...base, tone: 'unknown', text: 'unassessed', source: 'WAL archiving not reported' }
609
+ const last = p.lastSuccessfulBackup.at ? ` · last backup ${formatAge(p.lastSuccessfulBackup.at)} ago` : ''
610
+ if (p.walArchiving.tone === 'neutral') return { ...base, tone: 'healthy', text: `Backup completed${last}`, source: p.lastSuccessfulBackup.source ?? 'Backups' }
611
+ return { ...base, tone: 'healthy', text: `CNPG reports archiving${last}`, source: `ContinuousArchiving condition${p.lastSuccessfulBackup.source ? ` · last backup: ${p.lastSuccessfulBackup.source}` : ''}` }
612
+ }
613
+
614
+ /**
615
+ * The Pod a Lease holder names. controller-runtime's leader election records
616
+ * "<pod>_<uuid>"; a CNPG primary Lease records the Pod name alone.
617
+ */
618
+ export function cnpgLeaseHolderPod(holder: string): string {
619
+ const i = holder.indexOf('_')
620
+ return i > 0 ? holder.slice(0, i) : holder
621
+ }
@@ -1,4 +1,7 @@
1
1
  export * from './workspace'
2
+ export * from './databaseRole'
3
+ export * from './ha'
4
+ export * from './CNPGClusterHASection'
2
5
  export * from './primitives'
3
6
  export * from './CNPGClusterSummary'
4
7
  export * from './CNPGBackupSummary'
@@ -6,9 +9,24 @@ export * from './CNPGObjectStoreSummary'
6
9
  export * from './CNPGDeclarativeSummary'
7
10
  export * from './CNPGPoolerSummary'
8
11
  export * from './CNPGImageCatalogSummary'
12
+ export * from './pooler'
13
+ export * from './connect'
14
+ export * from './CNPGConnectSection'
15
+ export * from './schedule'
16
+ export * from './logicalReplication'
17
+ export * from './CNPGLogicalPath'
9
18
  export {
19
+ backupsForScheduledBackup,
20
+ cnpgScheduleDestinationBlocker,
21
+ objectStoreForBackup,
22
+ cnpgBackupMatchesCluster,
23
+ cnpgArchiveMatchesCluster,
24
+ type CNPGArchiveSource,
10
25
  inferredObjectStoreHealth,
26
+ relationUnavailable,
11
27
  usersOfObjectStore,
12
28
  type CNPGObjectStoreHealth,
13
29
  type CNPGObjectStoreUser,
14
30
  } from './relations'
31
+
32
+ export * from './backupRuns'