@skyhook-io/k8s-ui 1.10.4 → 1.11.0

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 (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -8,49 +8,222 @@ import { parseGoTimeString } from '../../utils/parse-go-time'
8
8
  // CNPG CLUSTER UTILITIES
9
9
  // ============================================================================
10
10
 
11
+ export const CNPG_GROUP = 'postgresql.cnpg.io'
12
+
13
+ /**
14
+ * Exact API-group match for a resource's `apiVersion` ("group/version").
15
+ *
16
+ * Used for the `clusters` and `backups` plurals, where several operators ship
17
+ * the same kind and a wrong guess fabricates a status. A substring test is not
18
+ * a group guard — `extension.postgresql.cnpg.io/v1` contains `postgresql.cnpg.io`
19
+ * — and these are exactly the plurals where that would misfire.
20
+ */
21
+ export function isApiGroup(apiVersion: unknown, group: string): boolean {
22
+ if (typeof apiVersion !== 'string') return false
23
+ const slash = apiVersion.lastIndexOf('/')
24
+ // A core-group resource ("v1") has no slash and belongs to no CRD group.
25
+ return slash > 0 && apiVersion.slice(0, slash) === group
26
+ }
27
+
28
+ // Phase strings are full English sentences copied verbatim from CNPG's
29
+ // api/v1/cluster_types.go — match on equality, never substring. Verified
30
+ // against CNPG 1.27. Radar's Go issue detection mirrors these buckets in
31
+ // internal/issues/source_cnpg.go; the two must not drift, or a cluster gets
32
+ // a red badge and no issue (or the reverse).
33
+
34
+ /** Cluster is reconciled and serving. */
35
+ export const CNPG_CLUSTER_PHASES_HEALTHY = ['Cluster in healthy state'] as const
36
+
37
+ /** Operator is mid-operation. Expected to resolve on its own. */
38
+ export const CNPG_CLUSTER_PHASES_TRANSIENT = [
39
+ 'Setting up primary',
40
+ 'Creating a new replica',
41
+ 'Switchover in progress',
42
+ 'Upgrading cluster',
43
+ 'Upgrading Postgres major version',
44
+ 'Online upgrade in progress',
45
+ 'Applying configuration',
46
+ 'Primary instance is being restarted in-place',
47
+ 'Primary instance is being restarted without a switchover',
48
+ 'Promoting to primary cluster',
49
+ 'Waiting for the instances to become active',
50
+ // Upstream sets this when an upgrade is postponed by the OPERATOR's own
51
+ // rollout-delay config and requeued — nothing is waiting on a human, so it
52
+ // does not belong with 'Waiting for user action'.
53
+ 'Cluster upgrade delayed',
54
+ ] as const
55
+
56
+ /** Losing the primary — degraded now, self-resolving only if a replica can take over. */
57
+ export const CNPG_CLUSTER_PHASES_FAILING = ['Failing over'] as const
58
+
59
+ /** Reconciliation has stopped. These need a human; none of them self-heal. */
60
+ export const CNPG_CLUSTER_PHASES_TERMINAL = [
61
+ 'Cluster is unrecoverable and needs manual intervention',
62
+ // Not in 1.27 or 1.28; added upstream after 1.28 (PhaseDefinitionInvalid).
63
+ // Listed early because matching is by equality — a phase string the running
64
+ // operator never emits is inert, so carrying it costs nothing and stops a
65
+ // future minor from landing in the unknown bucket.
66
+ 'Invalid cluster definition',
67
+ 'Unable to create required cluster objects',
68
+ 'Cluster has incomplete or invalid image catalog',
69
+ 'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
70
+ 'Cluster cannot proceed to reconciliation due to an error while interacting with plugins',
71
+ 'Cluster cannot execute instance online upgrade due to missing architecture binary',
72
+ ] as const
73
+
74
+ /**
75
+ * Stalled waiting on a human. Under `primaryUpdateStrategy: supervised` this is
76
+ * the documented, expected resting state, so it is styled as attention rather
77
+ * than failure and the Go side suppresses the issue entirely.
78
+ */
79
+ export const CNPG_CLUSTER_PHASES_ATTENTION = [
80
+ 'Waiting for user action',
81
+ ] as const
82
+
83
+ /**
84
+ * Short display states for the table badge.
85
+ *
86
+ * CNPG's phases are prose, not tokens — "Cluster is unrecoverable and needs
87
+ * manual intervention" is banner copy. At 315px it fits no defensible column,
88
+ * so this is about badge semantics rather than width: a badge carries a state,
89
+ * the drawer and the `title` carry the operator's exact words.
90
+ *
91
+ * Radar's own labels (Healthy, WAL Archiving Failing, …) are not in here —
92
+ * those are already states and are produced directly by getCNPGClusterStatus.
93
+ *
94
+ * Both in-place and without-a-switchover restarts collapse to "Restarting
95
+ * Primary". The distinction is about the operator's mechanism, not about what a
96
+ * scanning operator needs; the exact phase remains in the drawer. Splitting
97
+ * them honestly would need ~140px and a wider column than the rest earns.
98
+ */
99
+ const CNPG_PHASE_DISPLAY_STATE: Record<string, string> = {
100
+ // Terminal
101
+ 'Cluster is unrecoverable and needs manual intervention': 'Unrecoverable',
102
+ 'Invalid cluster definition': 'Invalid Spec',
103
+ 'Unable to create required cluster objects': 'Setup Failed',
104
+ 'Cluster has incomplete or invalid image catalog': 'Image Catalog Error',
105
+ 'Cluster cannot proceed to reconciliation due to an unknown plugin being required': 'Unknown Plugin',
106
+ 'Cluster cannot proceed to reconciliation due to an error while interacting with plugins': 'Plugin Error',
107
+ 'Cluster cannot execute instance online upgrade due to missing architecture binary': 'Arch Binary Missing',
108
+ // Transient
109
+ 'Setting up primary': 'Setting Up Primary',
110
+ 'Creating a new replica': 'Creating Replica',
111
+ 'Switchover in progress': 'Switchover',
112
+ 'Upgrading cluster': 'Upgrading',
113
+ 'Upgrading Postgres major version': 'Major Upgrade',
114
+ 'Online upgrade in progress': 'Online Upgrade',
115
+ 'Applying configuration': 'Configuring',
116
+ 'Primary instance is being restarted in-place': 'Restarting Primary',
117
+ 'Primary instance is being restarted without a switchover': 'Restarting Primary',
118
+ 'Promoting to primary cluster': 'Promoting',
119
+ 'Waiting for the instances to become active': 'Starting Instances',
120
+ 'Cluster upgrade delayed': 'Upgrade Delayed',
121
+ // Attention
122
+ 'Waiting for user action': 'Needs Action',
123
+ }
124
+
125
+ /**
126
+ * The short state for a phase, or the phase itself when we have no mapping.
127
+ *
128
+ * An unrecognized phase from a newer CNPG minor passes through verbatim —
129
+ * inventing a state for a string we have never seen would be worse than showing
130
+ * the operator's words, and that is the case badge truncation exists for.
131
+ */
132
+ export function getCNPGClusterDisplayState(phase: string): string {
133
+ return CNPG_PHASE_DISPLAY_STATE[phase] || phase
134
+ }
135
+
136
+ export type CNPGPhaseBucket = 'healthy' | 'transient' | 'failing' | 'terminal' | 'attention' | 'unknown'
137
+
138
+ export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
139
+ if (!phase) return 'unknown'
140
+ if ((CNPG_CLUSTER_PHASES_HEALTHY as readonly string[]).includes(phase)) return 'healthy'
141
+ if ((CNPG_CLUSTER_PHASES_TERMINAL as readonly string[]).includes(phase)) return 'terminal'
142
+ if ((CNPG_CLUSTER_PHASES_FAILING as readonly string[]).includes(phase)) return 'failing'
143
+ if ((CNPG_CLUSTER_PHASES_TRANSIENT as readonly string[]).includes(phase)) return 'transient'
144
+ if ((CNPG_CLUSTER_PHASES_ATTENTION as readonly string[]).includes(phase)) return 'attention'
145
+ return 'unknown'
146
+ }
147
+
11
148
  export function getCNPGClusterStatus(resource: any): StatusBadge {
12
149
  const status = resource.status || {}
13
150
  const phase = status.phase || ''
14
151
  const instances = resource.spec?.instances ?? 0
15
- const readyInstances = status.readyInstances ?? 0
16
-
17
- // Check for degraded instances first
18
- if (instances > 0 && readyInstances < instances) {
19
- // Distinguish between total failure and partial degradation
20
- if (readyInstances === 0) {
21
- return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
22
- }
23
- return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
152
+ // Readiness PRESENCE matters. A cluster whose status has no readyInstances yet
153
+ // (freshly created, operator hasn't written status) is unknown, not zero —
154
+ // defaulting to 0 fabricated a red "Not Ready" badge for a cluster the issue
155
+ // detector correctly said nothing about.
156
+ const readyKnown = typeof status.readyInstances === 'number'
157
+ const readyInstances: number = readyKnown ? status.readyInstances : 0
158
+ const countsKnown = instances > 0 && readyKnown
159
+ const bucket = classifyCNPGClusterPhase(phase)
160
+
161
+ // Ordered by descending severity, and mirrored by source_cnpg.go. Terminal
162
+ // phases outrank instance counts: an unrecoverable cluster whose pods happen
163
+ // to still be Ready is still unrecoverable, and reading the counts first is
164
+ // what used to render it neutral.
165
+ if (bucket === 'terminal') {
166
+ return { text: getCNPGClusterDisplayState(phase), color: healthColors.unhealthy, level: 'unhealthy' }
24
167
  }
25
-
26
- // Phase-based status
27
- const healthyPhases = ['Cluster in healthy state', 'Healthy']
28
- const transientPhases = [
29
- 'Setting up primary',
30
- 'Creating replica',
31
- 'Switchover in progress',
32
- 'Upgrading cluster',
33
- 'Online upgrade in progress',
34
- ]
35
- const unhealthyPhases = [
36
- 'Failing over',
37
- 'Failing over (streaming)',
38
- 'Failing over (designated primary)',
39
- ]
40
-
41
- if (healthyPhases.some(p => phase.includes(p))) {
42
- return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
168
+ // Zero ready instances is a hard down that no phase excuses — including a
169
+ // failover, where the phase alone would otherwise downgrade a total outage to
170
+ // orange.
171
+ if (countsKnown && readyInstances === 0) {
172
+ return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
43
173
  }
44
- if (unhealthyPhases.some(p => phase.includes(p))) {
45
- return { text: 'Failing Over', color: healthColors.unhealthy, level: 'unhealthy' }
174
+ if (bucket === 'failing') {
175
+ return { text: 'Failing Over', color: healthColors.alert, level: 'alert' }
46
176
  }
47
- if (transientPhases.some(p => phase.includes(p))) {
48
- return { text: phase, color: healthColors.degraded, level: 'degraded' }
177
+ // Data-durability failures don't move any instance count, so a cluster with a
178
+ // broken recovery point looks perfectly healthy on availability alone — which
179
+ // is precisely how it stays invisible until a restore. The detail drawer has
180
+ // always shown this; the list badge must too, or the headline signal of this
181
+ // integration reads green.
182
+ if (getCNPGWALArchivingFailure(resource)) {
183
+ return { text: 'WAL Archiving Failing', color: healthColors.alert, level: 'alert' }
184
+ }
185
+ // A transient phase explains partial readiness better than a bare "Degraded",
186
+ // so it is checked BEFORE the shortfall — mid-operation the cluster is
187
+ // legitimately below its desired count.
188
+ if (bucket === 'transient' || bucket === 'attention') {
189
+ return { text: getCNPGClusterDisplayState(phase), color: healthColors.degraded, level: 'degraded' }
190
+ }
191
+ // The shortfall must be checked BEFORE the healthy phase, not after. CNPG
192
+ // reports "Cluster in healthy state" the moment reconciliation is settled,
193
+ // which can be true while instances are still missing — returning Healthy
194
+ // there paints a green badge on a cluster the Go detector flags as
195
+ // CNPGClusterDegraded. Mirrors source_cnpg.go's !phaseExplained gate.
196
+ if (countsKnown && readyInstances < instances) {
197
+ return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
198
+ }
199
+ if (getCNPGLastBackupFailure(resource)) {
200
+ return { text: 'Backup Failed', color: healthColors.degraded, level: 'degraded' }
201
+ }
202
+ if (bucket === 'healthy') {
203
+ return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
49
204
  }
50
205
 
51
- // Conditions fallback
52
206
  const conditions = status.conditions || []
53
207
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
208
+
209
+ // Unrecognized phase — surface the operator's own words rather than inventing
210
+ // a health level. A new CNPG minor adding a phase lands here, not in "Healthy".
211
+ //
212
+ // This must outrank the Ready condition below. Ready=True says the instances
213
+ // are serving; it does not say the cluster is fine, and CNPG sets it during
214
+ // phases we have never seen. Reading it first turned an unknown phase into a
215
+ // green "Ready" badge — asserting health from a signal that doesn't establish
216
+ // it, and defeating the pass-through this branch exists for. Ready=False
217
+ // still colors the phase red: showing the operator's words in red loses
218
+ // nothing and keeps a real failure signal.
219
+ if (phase) {
220
+ if (readyCond?.status === 'False') {
221
+ return { text: phase, color: healthColors.unhealthy, level: 'unhealthy' }
222
+ }
223
+ return { text: phase, color: healthColors.unknown, level: 'unknown' }
224
+ }
225
+
226
+ // No phase at all — conditions are the only signal left.
54
227
  if (readyCond?.status === 'True') {
55
228
  return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
56
229
  }
@@ -58,17 +231,17 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
58
231
  return { text: readyCond.reason || 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
59
232
  }
60
233
 
61
- if (phase) {
62
- return { text: phase, color: healthColors.unknown, level: 'unknown' }
63
- }
64
-
65
234
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
66
235
  }
67
236
 
237
+ // Absence is not zero — same rule the badge follows via readyKnown. Between
238
+ // creation and the operator's first status write there is no readyInstances,
239
+ // and defaulting it to 0 rendered "0/3" next to a badge that (correctly) did
240
+ // not say degraded: the cell claimed a total outage the badge denied.
241
+ const countOrDash = (n: unknown): string => (typeof n === 'number' ? String(n) : '-')
242
+
68
243
  export function getCNPGClusterInstances(resource: any): string {
69
- const desired = resource.spec?.instances ?? 0
70
- const ready = resource.status?.readyInstances ?? 0
71
- return `${ready}/${desired}`
244
+ return `${countOrDash(resource.status?.readyInstances)}/${countOrDash(resource.spec?.instances)}`
72
245
  }
73
246
 
74
247
  export function getCNPGClusterPrimary(resource: any): string {
@@ -118,21 +291,66 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
118
291
  return resource.spec?.primaryUpdateStrategy || 'unsupervised'
119
292
  }
120
293
 
294
+ export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
295
+
296
+ export interface CNPGBarmanPlugin {
297
+ name: string
298
+ /** ObjectStore CR (barmancloud.cnpg.io/v1) holding the real destination + recovery window. */
299
+ barmanObjectName?: string
300
+ /** Resolved server key inside ObjectStore.status.serverRecoveryWindow. */
301
+ serverName?: string
302
+ isWALArchiver: boolean
303
+ }
304
+
305
+ /**
306
+ * Detects the barman-cloud CNPG-I plugin. In-tree `spec.backup.barmanObjectStore`
307
+ * is deprecated as of CNPG 1.26 and the migration moves config into an
308
+ * `ObjectStore` CR in a different API group — at which point CNPG stops setting
309
+ * status.lastSuccessfulBackup / firstRecoverabilityPoint entirely.
310
+ */
311
+ export function getCNPGClusterBarmanPlugin(resource: any): CNPGBarmanPlugin | null {
312
+ const plugins = resource.spec?.plugins
313
+ if (!Array.isArray(plugins)) return null
314
+ const plugin = plugins.find(
315
+ (p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.enabled !== false
316
+ )
317
+ if (!plugin) return null
318
+ return {
319
+ name: plugin.name,
320
+ barmanObjectName: plugin.parameters?.barmanObjectName,
321
+ // ObjectStore keys the recovery window by the plugin's serverName when set,
322
+ // otherwise by the cluster name.
323
+ serverName: plugin.parameters?.serverName || resource.metadata?.name,
324
+ isWALArchiver: plugin.isWALArchiver === true,
325
+ }
326
+ }
327
+
121
328
  export function getCNPGClusterBackupConfig(resource: any): {
122
329
  configured: boolean
123
330
  destinationPath?: string
124
331
  retentionPolicy?: string
125
332
  lastSuccessfulBackup?: string
126
333
  firstRecoverabilityPoint?: string
334
+ /** Non-null when backup is managed by the barman-cloud plugin. */
335
+ plugin: CNPGBarmanPlugin | null
336
+ /**
337
+ * True when the status RPO fields are deprecated-and-unset by design (plugin
338
+ * mode) rather than genuinely absent. Distinguishes "tracked elsewhere" from
339
+ * "no backups configured" — the ambiguity that made this section render blank.
340
+ */
341
+ rpoTrackedOnObjectStore: boolean
127
342
  } {
128
343
  const backup = resource.spec?.backup
129
344
  const barman = backup?.barmanObjectStore
345
+ const plugin = getCNPGClusterBarmanPlugin(resource)
130
346
  return {
131
- configured: !!barman,
347
+ configured: !!barman || !!plugin,
132
348
  destinationPath: barman?.destinationPath,
133
349
  retentionPolicy: backup?.retentionPolicy,
134
350
  lastSuccessfulBackup: resource.status?.lastSuccessfulBackup,
135
351
  firstRecoverabilityPoint: resource.status?.firstRecoverabilityPoint,
352
+ plugin,
353
+ rpoTrackedOnObjectStore: !!plugin && !resource.status?.lastSuccessfulBackup,
136
354
  }
137
355
  }
138
356
 
@@ -172,6 +390,7 @@ export interface CNPGInstanceReportedState {
172
390
  podName: string
173
391
  isPrimary: boolean
174
392
  timelineID?: number
393
+ ip?: string
175
394
  }
176
395
 
177
396
  export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanceReportedState[] {
@@ -180,7 +399,9 @@ export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanc
180
399
  return Object.entries(reported).map(([podName, state]: [string, any]) => ({
181
400
  podName,
182
401
  isPrimary: state?.isPrimary === true,
183
- timelineID: state?.timelineID,
402
+ // CNPG serializes this as timeLineID (capital L).
403
+ timelineID: state?.timeLineID,
404
+ ip: state?.ip,
184
405
  }))
185
406
  }
186
407
 
@@ -204,22 +425,98 @@ export function getCNPGClusterCertificateExpirations(resource: any): CNPGCertifi
204
425
  })
205
426
  }
206
427
 
428
+ // ============================================================================
429
+ // CNPG CLUSTER CONDITIONS — WAL ARCHIVING / BACKUP HEALTH
430
+ // ============================================================================
431
+
432
+ export interface CNPGCondition {
433
+ type: string
434
+ status: string
435
+ reason?: string
436
+ message?: string
437
+ lastTransitionTime?: string
438
+ }
439
+
440
+ export function getCNPGClusterCondition(resource: any, type: string): CNPGCondition | null {
441
+ const conditions = resource.status?.conditions
442
+ if (!Array.isArray(conditions)) return null
443
+ const cond = conditions.find((c: any) => c?.type === type)
444
+ if (!cond) return null
445
+ return {
446
+ type: cond.type,
447
+ status: String(cond.status ?? ''),
448
+ reason: cond.reason,
449
+ message: cond.message,
450
+ lastTransitionTime: cond.lastTransitionTime,
451
+ }
452
+ }
453
+
454
+ /**
455
+ * WAL archiving failure — the classic silent CNPG incident. The cluster keeps
456
+ * serving traffic normally while the recovery point stops advancing.
457
+ *
458
+ * The condition proves the LAST ARCHIVE ATTEMPT failed, not an exact RPO, so
459
+ * callers must not turn `since` into a data-loss window.
460
+ */
461
+ export function getCNPGWALArchivingFailure(resource: any): CNPGCondition | null {
462
+ const cond = getCNPGClusterCondition(resource, 'ContinuousArchiving')
463
+ if (!cond || cond.status !== 'False') return null
464
+ return cond
465
+ }
466
+
467
+ /**
468
+ * Last-backup failure. CNPG also sets LastBackupSucceeded=False with reason
469
+ * `BackupStarted` while a backup is merely in flight — treating that as a
470
+ * failure would light the banner on every backup run, so only an explicit
471
+ * LastBackupFailed reason counts.
472
+ */
473
+ export function getCNPGLastBackupFailure(resource: any): CNPGCondition | null {
474
+ const cond = getCNPGClusterCondition(resource, 'LastBackupSucceeded')
475
+ if (!cond || cond.status !== 'False') return null
476
+ if (cond.reason === 'BackupStarted') return null
477
+ return cond
478
+ }
479
+
207
480
  // ============================================================================
208
481
  // CNPG BACKUP UTILITIES
209
482
  // ============================================================================
210
483
 
484
+ // Backup phases are lowercase tokens from CNPG's api/v1/backup_types.go,
485
+ // verified against CNPG 1.27. Unlike cluster phases these are stable enum
486
+ // tokens, but they are still matched exactly — an unrecognized phase must
487
+ // surface as unknown rather than be swallowed by a prefix match.
488
+ export const CNPG_BACKUP_PHASES_TRANSIENT = ['pending', 'started', 'running', 'finalizing'] as const
489
+
490
+ /**
491
+ * WAL archiving is broken upstream of this Backup, so the recovery window for
492
+ * the whole cluster is affected — not just this one object. Kept separate from
493
+ * a plain `failed` so callers can escalate it to the cluster level.
494
+ */
495
+ export const CNPG_BACKUP_PHASE_WAL_ARCHIVING_FAILING = 'walArchivingFailing'
496
+
497
+ export type CNPGBackupPhaseBucket = 'healthy' | 'transient' | 'failed' | 'walArchivingFailing' | 'unknown'
498
+
499
+ export function classifyCNPGBackupPhase(phase: string): CNPGBackupPhaseBucket {
500
+ if (!phase) return 'unknown'
501
+ if (phase === CNPG_BACKUP_PHASE_WAL_ARCHIVING_FAILING) return 'walArchivingFailing'
502
+ if (phase === 'completed') return 'healthy'
503
+ if (phase === 'failed') return 'failed'
504
+ if ((CNPG_BACKUP_PHASES_TRANSIENT as readonly string[]).includes(phase)) return 'transient'
505
+ return 'unknown'
506
+ }
507
+
211
508
  export function getCNPGBackupStatus(resource: any): StatusBadge {
212
509
  const phase = resource.status?.phase || ''
213
510
 
214
- switch (phase.toLowerCase()) {
215
- case 'completed':
511
+ switch (classifyCNPGBackupPhase(phase)) {
512
+ case 'healthy':
216
513
  return { text: 'Completed', color: healthColors.healthy, level: 'healthy' }
217
- case 'running':
218
- return { text: 'Running', color: healthColors.degraded, level: 'degraded' }
219
- case 'pending':
220
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
514
+ case 'transient':
515
+ return { text: phase.charAt(0).toUpperCase() + phase.slice(1), color: healthColors.degraded, level: 'degraded' }
221
516
  case 'failed':
222
517
  return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
518
+ case 'walArchivingFailing':
519
+ return { text: 'WAL Archiving Failing', color: healthColors.unhealthy, level: 'unhealthy' }
223
520
  default:
224
521
  if (phase) return { text: phase, color: healthColors.unknown, level: 'unknown' }
225
522
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
@@ -341,24 +638,46 @@ export function getCNPGScheduledBackupOwnerRef(resource: any): string {
341
638
  // CNPG POOLER UTILITIES
342
639
  // ============================================================================
343
640
 
641
+ /**
642
+ * Pooler.status.instances is "the number of pods trying to be scheduled" — NOT
643
+ * ready pods. A Pooler whose PgBouncer pods are all Pending still reports
644
+ * status.instances == spec.instances, so reading it as readiness renders a
645
+ * broken Pooler green. Readiness lives on the generated Deployment (same name,
646
+ * same namespace), which the Pooler CR alone cannot see — so the positive state
647
+ * is labelled "Scheduled", not "Ready".
648
+ */
344
649
  export function getCNPGPoolerStatus(resource: any): StatusBadge {
345
650
  const desired = resource.spec?.instances ?? 0
346
- const ready = resource.status?.instances ?? 0
651
+ // Presence, not value — the same distinction getCNPGClusterStatus makes. A
652
+ // Pooler the controller has not reconciled yet has no status.instances at
653
+ // all, and reading that as 0 rendered a red "Not Scheduled" on a Pooler that
654
+ // may be perfectly healthy: a fabricated failure, not merely a wrong count.
655
+ const scheduledKnown = typeof resource.status?.instances === 'number'
656
+ const scheduled = scheduledKnown ? resource.status.instances : 0
657
+
658
+ if (!scheduledKnown) {
659
+ return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
660
+ }
347
661
 
348
- if (desired > 0 && ready < desired) {
349
- if (ready === 0) {
350
- return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
662
+ if (desired > 0 && scheduled < desired) {
663
+ if (scheduled === 0) {
664
+ return { text: 'Not Scheduled', color: healthColors.unhealthy, level: 'unhealthy' }
351
665
  }
352
666
  return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
353
667
  }
354
668
 
355
- if (desired > 0 && ready >= desired) {
356
- return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
669
+ if (desired > 0 && scheduled >= desired) {
670
+ return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
357
671
  }
358
672
 
359
673
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
360
674
  }
361
675
 
676
+ /** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
677
+ export function getCNPGPoolerDeploymentName(resource: any): string {
678
+ return resource.metadata?.name || ''
679
+ }
680
+
362
681
  export function getCNPGPoolerCluster(resource: any): string {
363
682
  return resource.spec?.cluster?.name || '-'
364
683
  }
@@ -372,9 +691,7 @@ export function getCNPGPoolerMode(resource: any): string {
372
691
  }
373
692
 
374
693
  export function getCNPGPoolerInstances(resource: any): string {
375
- const desired = resource.spec?.instances ?? 0
376
- const ready = resource.status?.instances ?? 0
377
- return `${ready}/${desired}`
694
+ return `${countOrDash(resource.status?.instances)}/${countOrDash(resource.spec?.instances)}`
378
695
  }
379
696
 
380
697
  export function getCNPGPoolerParameters(resource: any): Record<string, string> {