@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
@@ -1,5 +1,30 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
- import { getCNPGClusterCertificateExpirations } from './resource-utils-cnpg'
2
+ import {
3
+ getCNPGClusterCertificateExpirations,
4
+ getCNPGClusterStatus,
5
+ getCNPGBackupStatus,
6
+ getCNPGPoolerStatus,
7
+ getCNPGClusterInstancesReportedState,
8
+ getCNPGClusterBackupConfig,
9
+ getCNPGClusterBarmanPlugin,
10
+ getCNPGWALArchivingFailure,
11
+ getCNPGLastBackupFailure,
12
+ classifyCNPGClusterPhase,
13
+ classifyCNPGBackupPhase,
14
+ CNPG_CLUSTER_PHASES_HEALTHY,
15
+ CNPG_CLUSTER_PHASES_TRANSIENT,
16
+ CNPG_CLUSTER_PHASES_FAILING,
17
+ CNPG_CLUSTER_PHASES_TERMINAL,
18
+ CNPG_CLUSTER_PHASES_ATTENTION,
19
+ CNPG_BACKUP_PHASES_TRANSIENT,
20
+ CNPG_BARMAN_PLUGIN_NAME,
21
+ CNPG_GROUP,
22
+ isApiGroup,
23
+ getCNPGClusterDisplayState,
24
+ getCNPGClusterInstances,
25
+ getCNPGPoolerInstances,
26
+ } from './resource-utils-cnpg'
27
+ import { getCellFilterValue } from './resource-utils'
3
28
 
4
29
  describe('getCNPGClusterCertificateExpirations', () => {
5
30
  beforeEach(() => {
@@ -73,3 +98,504 @@ describe('getCNPGClusterCertificateExpirations', () => {
73
98
  expect(getCNPGClusterCertificateExpirations({ status: {} })).toEqual([])
74
99
  })
75
100
  })
101
+
102
+ // ============================================================================
103
+ // PHASE TAXONOMY
104
+ // ============================================================================
105
+
106
+ const cluster = (status: any = {}, spec: any = {}) => ({ spec, status })
107
+
108
+ describe('classifyCNPGClusterPhase', () => {
109
+ it('buckets every phase constant shipped by CNPG 1.27', () => {
110
+ for (const p of CNPG_CLUSTER_PHASES_HEALTHY) expect(classifyCNPGClusterPhase(p)).toBe('healthy')
111
+ for (const p of CNPG_CLUSTER_PHASES_TRANSIENT) expect(classifyCNPGClusterPhase(p)).toBe('transient')
112
+ for (const p of CNPG_CLUSTER_PHASES_FAILING) expect(classifyCNPGClusterPhase(p)).toBe('failing')
113
+ for (const p of CNPG_CLUSTER_PHASES_TERMINAL) expect(classifyCNPGClusterPhase(p)).toBe('terminal')
114
+ for (const p of CNPG_CLUSTER_PHASES_ATTENTION) expect(classifyCNPGClusterPhase(p)).toBe('attention')
115
+ })
116
+
117
+ it('matches on equality, not substring', () => {
118
+ // The bug this pins: "Upgrading Postgres major version" contains neither
119
+ // "Upgrading cluster" nor vice versa, but a sloppy includes() over the
120
+ // transient list would still have to enumerate it. Both must classify.
121
+ expect(classifyCNPGClusterPhase('Upgrading cluster')).toBe('transient')
122
+ expect(classifyCNPGClusterPhase('Upgrading Postgres major version')).toBe('transient')
123
+ // A phase that merely CONTAINS a known one must not inherit its bucket.
124
+ expect(classifyCNPGClusterPhase('Cluster in healthy state (probably)')).toBe('unknown')
125
+ })
126
+
127
+ it('classifies an unknown phase from a future CNPG minor as unknown', () => {
128
+ expect(classifyCNPGClusterPhase('Reticulating splines')).toBe('unknown')
129
+ expect(classifyCNPGClusterPhase('')).toBe('unknown')
130
+ })
131
+
132
+ it('has no invented entries — every listed phase is a real CNPG constant', () => {
133
+ // "Creating replica" was the shipped typo; CNPG emits "Creating a new replica".
134
+ expect(CNPG_CLUSTER_PHASES_TRANSIENT).toContain('Creating a new replica')
135
+ expect(CNPG_CLUSTER_PHASES_TRANSIENT).not.toContain('Creating replica')
136
+ // PhaseDefinitionInvalid is absent from 1.27 and 1.28 but present upstream
137
+ // after 1.28. Matching is by equality, so carrying it is inert against an
138
+ // operator that never emits it, and correct once one does.
139
+ expect(CNPG_CLUSTER_PHASES_TERMINAL).toContain('Invalid cluster definition')
140
+ expect(classifyCNPGClusterPhase('Invalid cluster definition')).toBe('terminal')
141
+ })
142
+ })
143
+
144
+ describe('getCNPGClusterStatus', () => {
145
+ it('renders an unrecoverable cluster red even when all pods are ready', () => {
146
+ // The shipped bug: readiness was checked first, so this fell through every
147
+ // phase list and landed on the neutral-grey fallback.
148
+ const s = getCNPGClusterStatus(cluster(
149
+ { phase: 'Cluster is unrecoverable and needs manual intervention', readyInstances: 3 },
150
+ { instances: 3 },
151
+ ))
152
+ expect(s.level).toBe('unhealthy')
153
+ expect(s.text).toBe('Unrecoverable')
154
+ })
155
+
156
+ it('renders both plugin-failure phases red', () => {
157
+ for (const phase of [
158
+ 'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
159
+ 'Cluster cannot proceed to reconciliation due to an error while interacting with plugins',
160
+ ]) {
161
+ expect(getCNPGClusterStatus(cluster({ phase, readyInstances: 2 }, { instances: 2 })).level).toBe('unhealthy')
162
+ }
163
+ })
164
+
165
+ it('renders a major-version upgrade as transient, not unknown', () => {
166
+ const s = getCNPGClusterStatus(cluster({ phase: 'Upgrading Postgres major version', readyInstances: 1 }, { instances: 3 }))
167
+ expect(s.level).toBe('degraded')
168
+ expect(s.text).toBe('Major Upgrade')
169
+ })
170
+
171
+ it('uses the alert tier for failover, between degraded and unhealthy', () => {
172
+ expect(getCNPGClusterStatus(cluster({ phase: 'Failing over', readyInstances: 2 }, { instances: 3 })).level).toBe('alert')
173
+ })
174
+
175
+ it('treats zero ready instances as down regardless of phase', () => {
176
+ const s = getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', readyInstances: 0 }, { instances: 3 }))
177
+ expect(s.level).toBe('unhealthy')
178
+ expect(s.text).toBe('Not Ready')
179
+ })
180
+
181
+ it('is healthy when the phase is healthy and all instances are ready', () => {
182
+ expect(getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', readyInstances: 2 }, { instances: 2 })).level).toBe('healthy')
183
+ })
184
+
185
+ it('surfaces an unrecognized phase verbatim at unknown rather than guessing', () => {
186
+ const s = getCNPGClusterStatus(cluster({ phase: 'Some future phase', readyInstances: 2 }, { instances: 2 }))
187
+ expect(s.level).toBe('unknown')
188
+ expect(s.text).toBe('Some future phase')
189
+ })
190
+ })
191
+
192
+ describe('getCNPGBackupStatus', () => {
193
+ it('escalates walArchivingFailing instead of rendering it neutral', () => {
194
+ // The shipped bug: this phase hit the default branch and rendered grey,
195
+ // while it is CNPG telling you the recovery point is drifting.
196
+ const s = getCNPGBackupStatus({ status: { phase: 'walArchivingFailing' } })
197
+ expect(s.level).toBe('unhealthy')
198
+ expect(s.text).toBe('WAL Archiving Failing')
199
+ })
200
+
201
+ it('classifies every backup phase constant shipped by CNPG 1.27', () => {
202
+ expect(classifyCNPGBackupPhase('completed')).toBe('healthy')
203
+ expect(classifyCNPGBackupPhase('failed')).toBe('failed')
204
+ expect(classifyCNPGBackupPhase('walArchivingFailing')).toBe('walArchivingFailing')
205
+ for (const p of CNPG_BACKUP_PHASES_TRANSIENT) expect(classifyCNPGBackupPhase(p)).toBe('transient')
206
+ })
207
+
208
+ it('handles started and finalizing, which previously fell through to unknown', () => {
209
+ expect(getCNPGBackupStatus({ status: { phase: 'started' } }).level).toBe('degraded')
210
+ expect(getCNPGBackupStatus({ status: { phase: 'finalizing' } }).level).toBe('degraded')
211
+ })
212
+
213
+ it('leaves an unrecognized phase at unknown', () => {
214
+ expect(getCNPGBackupStatus({ status: { phase: 'wat' } }).level).toBe('unknown')
215
+ expect(getCNPGBackupStatus({}).level).toBe('unknown')
216
+ })
217
+ })
218
+
219
+ // ============================================================================
220
+ // CONDITIONS — WAL ARCHIVING / LAST BACKUP
221
+ // ============================================================================
222
+
223
+ const withConditions = (...conds: any[]) => ({ status: { conditions: conds } })
224
+
225
+ describe('getCNPGWALArchivingFailure', () => {
226
+ it('fires only when ContinuousArchiving is False', () => {
227
+ expect(getCNPGWALArchivingFailure(withConditions(
228
+ { type: 'ContinuousArchiving', status: 'True', reason: 'ContinuousArchivingSuccess' },
229
+ ))).toBeNull()
230
+
231
+ const f = getCNPGWALArchivingFailure(withConditions(
232
+ { type: 'ContinuousArchiving', status: 'False', reason: 'ContinuousArchivingFailing', message: 'boom', lastTransitionTime: '2026-04-28T10:00:00Z' },
233
+ ))
234
+ expect(f?.reason).toBe('ContinuousArchivingFailing')
235
+ expect(f?.lastTransitionTime).toBe('2026-04-28T10:00:00Z')
236
+ })
237
+
238
+ it('returns null when the condition is absent', () => {
239
+ expect(getCNPGWALArchivingFailure({})).toBeNull()
240
+ expect(getCNPGWALArchivingFailure(withConditions({ type: 'Ready', status: 'True' }))).toBeNull()
241
+ })
242
+ })
243
+
244
+ describe('getCNPGLastBackupFailure', () => {
245
+ it('ignores the in-flight BackupStarted state', () => {
246
+ // CNPG sets LastBackupSucceeded=False/BackupStarted while a backup is merely
247
+ // starting. Alerting on it would light the banner on every backup run.
248
+ expect(getCNPGLastBackupFailure(withConditions(
249
+ { type: 'LastBackupSucceeded', status: 'False', reason: 'BackupStarted' },
250
+ ))).toBeNull()
251
+ })
252
+
253
+ it('fires on a real LastBackupFailed', () => {
254
+ const f = getCNPGLastBackupFailure(withConditions(
255
+ { type: 'LastBackupSucceeded', status: 'False', reason: 'LastBackupFailed', message: 'no credentials' },
256
+ ))
257
+ expect(f?.reason).toBe('LastBackupFailed')
258
+ expect(f?.message).toBe('no credentials')
259
+ })
260
+ })
261
+
262
+ // ============================================================================
263
+ // BUG 7 — timeLineID
264
+ // ============================================================================
265
+
266
+ describe('getCNPGClusterInstancesReportedState', () => {
267
+ it('reads CNPG\'s timeLineID spelling (capital L)', () => {
268
+ const state = getCNPGClusterInstancesReportedState({
269
+ status: {
270
+ instancesReportedState: {
271
+ 'pg-main-1': { ip: '10.244.0.9', isPrimary: true, timeLineID: 3 },
272
+ 'pg-main-2': { ip: '10.244.0.12', isPrimary: false, timeLineID: 3 },
273
+ },
274
+ },
275
+ })
276
+ expect(state).toHaveLength(2)
277
+ expect(state[0]).toMatchObject({ podName: 'pg-main-1', isPrimary: true, timelineID: 3, ip: '10.244.0.9' })
278
+ expect(state[1].timelineID).toBe(3)
279
+ })
280
+
281
+ it('does not read the lowercase timelineID spelling, which CNPG never emits', () => {
282
+ const state = getCNPGClusterInstancesReportedState({
283
+ status: { instancesReportedState: { 'pg-1': { isPrimary: true, timelineID: 9 } } },
284
+ })
285
+ expect(state[0].timelineID).toBeUndefined()
286
+ })
287
+ })
288
+
289
+ // ============================================================================
290
+ // POOLER
291
+ // ============================================================================
292
+
293
+ describe('getCNPGPoolerStatus', () => {
294
+ it('does not claim readiness from status.instances, which counts scheduled pods', () => {
295
+ // Verified live: a Pooler whose 2 PgBouncer pods are both Pending (0/1
296
+ // ready) still reports status.instances=2. Calling that "Ready" renders a
297
+ // broken Pooler green.
298
+ const s = getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } })
299
+ expect(s.level).toBe('healthy')
300
+ expect(s.text).toBe('Scheduled')
301
+ expect(s.text).not.toBe('Ready')
302
+ })
303
+
304
+ it('flags a shortfall in scheduled pods', () => {
305
+ expect(getCNPGPoolerStatus({ spec: { instances: 3 }, status: { instances: 1 } })).toMatchObject({ level: 'degraded' })
306
+ expect(getCNPGPoolerStatus({ spec: { instances: 3 }, status: { instances: 0 } })).toMatchObject({ level: 'unhealthy', text: 'Not Scheduled' })
307
+ })
308
+ })
309
+
310
+ // ============================================================================
311
+ // BUG 6 — PLUGIN-AWARE BACKUP
312
+ // ============================================================================
313
+
314
+ describe('getCNPGClusterBarmanPlugin', () => {
315
+ it('detects the barman-cloud plugin and resolves the ObjectStore + server key', () => {
316
+ const p = getCNPGClusterBarmanPlugin({
317
+ metadata: { name: 'pg-main' },
318
+ spec: { plugins: [{ name: CNPG_BARMAN_PLUGIN_NAME, isWALArchiver: true, parameters: { barmanObjectName: 'store-a' } }] },
319
+ })
320
+ expect(p).toMatchObject({ barmanObjectName: 'store-a', isWALArchiver: true, serverName: 'pg-main' })
321
+ })
322
+
323
+ it('prefers an explicit serverName parameter over the cluster name', () => {
324
+ const p = getCNPGClusterBarmanPlugin({
325
+ metadata: { name: 'pg-main' },
326
+ spec: { plugins: [{ name: CNPG_BARMAN_PLUGIN_NAME, parameters: { barmanObjectName: 'store-a', serverName: 'custom' } }] },
327
+ })
328
+ expect(p?.serverName).toBe('custom')
329
+ })
330
+
331
+ it('ignores an explicitly disabled plugin and unrelated plugins', () => {
332
+ expect(getCNPGClusterBarmanPlugin({
333
+ spec: { plugins: [{ name: CNPG_BARMAN_PLUGIN_NAME, enabled: false }] },
334
+ })).toBeNull()
335
+ expect(getCNPGClusterBarmanPlugin({
336
+ spec: { plugins: [{ name: 'some-other.plugin.io' }] },
337
+ })).toBeNull()
338
+ expect(getCNPGClusterBarmanPlugin({ spec: {} })).toBeNull()
339
+ })
340
+ })
341
+
342
+ describe('getCNPGClusterBackupConfig', () => {
343
+ it('reports configured for a plugin-managed cluster with no in-tree stanza', () => {
344
+ // The shipped bug: plugin-migrated clusters render the whole backup section
345
+ // blank, indistinguishable from "no backups configured".
346
+ const cfg = getCNPGClusterBackupConfig({
347
+ metadata: { name: 'pg-main' },
348
+ spec: { plugins: [{ name: CNPG_BARMAN_PLUGIN_NAME, parameters: { barmanObjectName: 'store-a' } }] },
349
+ status: {},
350
+ })
351
+ expect(cfg.configured).toBe(true)
352
+ expect(cfg.rpoTrackedOnObjectStore).toBe(true)
353
+ expect(cfg.plugin?.barmanObjectName).toBe('store-a')
354
+ })
355
+
356
+ it('still reports in-tree config the old way', () => {
357
+ const cfg = getCNPGClusterBackupConfig({
358
+ spec: { backup: { barmanObjectStore: { destinationPath: 's3://b' }, retentionPolicy: '30d' } },
359
+ status: { lastSuccessfulBackup: '2026-04-28T00:00:00Z' },
360
+ })
361
+ expect(cfg).toMatchObject({ configured: true, destinationPath: 's3://b', retentionPolicy: '30d', plugin: null, rpoTrackedOnObjectStore: false })
362
+ })
363
+
364
+ it('reports not-configured when neither path is present', () => {
365
+ expect(getCNPGClusterBackupConfig({ spec: {}, status: {} }).configured).toBe(false)
366
+ })
367
+ })
368
+
369
+ describe('isApiGroup', () => {
370
+ it('matches the exact group, not a substring of it', () => {
371
+ expect(isApiGroup('postgresql.cnpg.io/v1', CNPG_GROUP)).toBe(true)
372
+ // A vendor sub-group contains the CNPG group as a substring but is not it.
373
+ expect(isApiGroup('extension.postgresql.cnpg.io/v1', CNPG_GROUP)).toBe(false)
374
+ expect(isApiGroup('barmancloud.cnpg.io/v1', CNPG_GROUP)).toBe(false)
375
+ expect(isApiGroup('backup.velero.io/v1', 'velero.io')).toBe(false)
376
+ expect(isApiGroup('velero.io/v1', 'velero.io')).toBe(true)
377
+ })
378
+
379
+ it('rejects core-group and malformed apiVersions', () => {
380
+ expect(isApiGroup('v1', CNPG_GROUP)).toBe(false)
381
+ expect(isApiGroup('/v1', CNPG_GROUP)).toBe(false)
382
+ expect(isApiGroup(undefined, CNPG_GROUP)).toBe(false)
383
+ expect(isApiGroup(null, CNPG_GROUP)).toBe(false)
384
+ })
385
+ })
386
+
387
+ describe('CNPG_CLUSTER_PHASES_ATTENTION', () => {
388
+ it('excludes the operator-driven rollout delay', () => {
389
+ // "Cluster upgrade delayed" is postponed by the operator's own config and
390
+ // requeued — nothing waits on a human.
391
+ expect(CNPG_CLUSTER_PHASES_ATTENTION).not.toContain('Cluster upgrade delayed')
392
+ expect(CNPG_CLUSTER_PHASES_TRANSIENT).toContain('Cluster upgrade delayed')
393
+ expect(classifyCNPGClusterPhase('Cluster upgrade delayed')).toBe('transient')
394
+ })
395
+ })
396
+
397
+ // The badge and the Go issue detector must agree on the same object. These
398
+ // fixtures are mirrored in internal/issues/source_cnpg_test.go
399
+ // (TestCNPGBadgeAndIssueAgreeOnReadiness) — change one, change both.
400
+ describe('badge/issue agreement on instance readiness', () => {
401
+ it('does not render Healthy while the detector reports a shortfall', () => {
402
+ // CNPG sets "Cluster in healthy state" once reconciliation settles, which
403
+ // can be true while instances are still missing. Returning Healthy here put
404
+ // a green badge on a cluster the Go side flags CNPGClusterDegraded.
405
+ const s = getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', readyInstances: 1 }, { instances: 3 }))
406
+ expect(s.level).toBe('degraded')
407
+ expect(s.text).toBe('Degraded')
408
+ })
409
+
410
+ it('still renders Healthy only when the count is actually met', () => {
411
+ expect(getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', readyInstances: 3 }, { instances: 3 })).level).toBe('healthy')
412
+ })
413
+
414
+ it('lets a transient phase explain a legitimate shortfall', () => {
415
+ // Go marks these phaseExplained and raises no issue, so the badge must not
416
+ // say "Degraded" either — it shows the phase, at the same amber tier.
417
+ const s = getCNPGClusterStatus(cluster({ phase: 'Creating a new replica', readyInstances: 1 }, { instances: 3 }))
418
+ expect(s.level).toBe('degraded')
419
+ expect(s.text).toBe('Creating Replica')
420
+ })
421
+
422
+ it('lets an attention phase explain a shortfall, matching the supervised Go path', () => {
423
+ // Go marks attention phases phaseExplained regardless of update strategy,
424
+ // so a supervised cluster waiting on a human raises no shortfall issue —
425
+ // the badge must show the phase, not "Degraded".
426
+ const s = getCNPGClusterStatus(cluster({ phase: 'Waiting for user action', readyInstances: 1 }, { instances: 3 }))
427
+ expect(s.level).toBe('degraded')
428
+ expect(s.text).toBe('Needs Action')
429
+ })
430
+
431
+ it('flags a shortfall under an unrecognized phase, matching the Go fallthrough', () => {
432
+ expect(getCNPGClusterStatus(cluster({ phase: 'Some future phase', readyInstances: 1 }, { instances: 3 })).level).toBe('degraded')
433
+ })
434
+ })
435
+
436
+ // ============================================================================
437
+ // DISPLAY STATES — a badge carries a state, the drawer carries the prose
438
+ // ============================================================================
439
+
440
+ describe('getCNPGClusterDisplayState', () => {
441
+ it('maps every mapped phase to something a badge can hold', () => {
442
+ // 127px is the label budget at w-44. The longest state must clear it with
443
+ // real headroom — not the 0.2px kind that has bitten three times.
444
+ const all = [
445
+ ...CNPG_CLUSTER_PHASES_TERMINAL,
446
+ ...CNPG_CLUSTER_PHASES_TRANSIENT,
447
+ ...CNPG_CLUSTER_PHASES_ATTENTION,
448
+ ]
449
+ for (const phase of all) {
450
+ const state = getCNPGClusterDisplayState(phase)
451
+ expect(state, `${phase} has no display state`).not.toBe(phase)
452
+ // Rough proxy for width in a unit test; the real measurement is in the
453
+ // PR. 22 chars ≈ 120px at this typography.
454
+ expect(state.length, `${state} is too long for a badge`).toBeLessThanOrEqual(22)
455
+ }
456
+ })
457
+
458
+ it('passes an unmapped phase through verbatim rather than inventing one', () => {
459
+ // A phase from a newer CNPG minor. Inventing a state for a string we have
460
+ // never seen would be worse than showing the operator's words.
461
+ expect(getCNPGClusterDisplayState('Reticulating splines')).toBe('Reticulating splines')
462
+ expect(getCNPGClusterDisplayState('')).toBe('')
463
+ })
464
+
465
+ it('collapses both restart phases to one state — a documented loss', () => {
466
+ // You cannot tell from the table which restart mechanism is in play. The
467
+ // exact phase stays in the drawer; splitting them needs ~140px.
468
+ expect(getCNPGClusterDisplayState('Primary instance is being restarted in-place')).toBe('Restarting Primary')
469
+ expect(getCNPGClusterDisplayState('Primary instance is being restarted without a switchover')).toBe('Restarting Primary')
470
+ })
471
+
472
+ it('renders the state, not the sentence, on the badge', () => {
473
+ const s = getCNPGClusterStatus(cluster(
474
+ { phase: 'Cluster is unrecoverable and needs manual intervention', readyInstances: 2 },
475
+ { instances: 2 },
476
+ ))
477
+ expect(s.text).toBe('Unrecoverable')
478
+ expect(s.level).toBe('unhealthy')
479
+ })
480
+ })
481
+
482
+ describe('status column filter reads the badge, not the raw phase', () => {
483
+ // Deliberately asserts LITERAL strings rather than comparing against
484
+ // getCNPGClusterStatus(...).text — both sides would call the same reader and
485
+ // the test would pass while both were wrong. These are the strings a user
486
+ // sees on a badge, so the dropdown must offer exactly them.
487
+ const CLUSTER = 'cnpgclusters'
488
+
489
+ it('offers the short state, not CNPG prose', () => {
490
+ expect(getCellFilterValue(
491
+ cluster({ phase: 'Cluster is unrecoverable and needs manual intervention', readyInstances: 2 }, { instances: 2 }),
492
+ 'status', CLUSTER,
493
+ )).toBe('Unrecoverable')
494
+
495
+ expect(getCellFilterValue(
496
+ cluster({ phase: 'Cluster in healthy state', readyInstances: 2 }, { instances: 2 }),
497
+ 'status', CLUSTER,
498
+ )).toBe('Healthy')
499
+ })
500
+
501
+ it('separates a WAL-archiving failure from healthy — they share a phase', () => {
502
+ // The defect this pins: both clusters report `Cluster in healthy state`,
503
+ // so filtering on the raw phase collapsed them into one option and the
504
+ // headline state of this integration could not be filtered for at all.
505
+ const healthy = cluster({ phase: 'Cluster in healthy state', readyInstances: 2 }, { instances: 2 })
506
+ const walFailing = cluster({
507
+ phase: 'Cluster in healthy state',
508
+ readyInstances: 2,
509
+ conditions: [{ type: 'ContinuousArchiving', status: 'False', reason: 'ContinuousArchivingFailing' }],
510
+ }, { instances: 2 })
511
+
512
+ expect(healthy.status.phase).toBe(walFailing.status.phase)
513
+ expect(getCellFilterValue(healthy, 'status', CLUSTER)).toBe('Healthy')
514
+ expect(getCellFilterValue(walFailing, 'status', CLUSTER)).toBe('WAL Archiving Failing')
515
+ })
516
+
517
+ it('covers the other three CNPG kinds', () => {
518
+ expect(getCellFilterValue({ status: { phase: 'completed' } }, 'status', 'cnpgbackups')).toBe('Completed')
519
+ expect(getCellFilterValue(
520
+ { apiVersion: 'postgresql.cnpg.io/v1', spec: { suspend: true } }, 'status', 'scheduledbackups',
521
+ )).toBe('Suspended')
522
+ expect(getCellFilterValue(
523
+ { apiVersion: 'postgresql.cnpg.io/v1', spec: { instances: 2 }, status: { instances: 2 } }, 'status', 'poolers',
524
+ )).toBe('Scheduled')
525
+ })
526
+
527
+ it('leaves a foreign CRD sharing the plural on the generic path', () => {
528
+ // `poolers`/`scheduledbackups` are bare plurals — another operator could
529
+ // ship them, and it must not be read through a CNPG accessor.
530
+ expect(getCellFilterValue(
531
+ { apiVersion: 'pooling.example.com/v1', status: { phase: 'Running' } }, 'status', 'poolers',
532
+ )).toBe('Running')
533
+ })
534
+ })
535
+
536
+ describe('an unknown phase outranks the Ready condition', () => {
537
+ // The decision this pins: a phase from a newer CNPG minor renders verbatim.
538
+ // A Ready=True condition read first would turn it into a green "Ready" badge
539
+ // — health asserted from a signal that does not establish it.
540
+ const withPhase = (phase: string, ready?: string) => ({
541
+ spec: { instances: 2 },
542
+ status: {
543
+ phase,
544
+ readyInstances: 2,
545
+ ...(ready ? { conditions: [{ type: 'Ready', status: ready, reason: 'ClusterIsReady' }] } : {}),
546
+ },
547
+ })
548
+
549
+ it('shows the operator words, not Ready, when Ready=True', () => {
550
+ const s = getCNPGClusterStatus(withPhase('Rebalancing shards across zones', 'True'))
551
+ expect(s.text).toBe('Rebalancing shards across zones')
552
+ expect(s.level).toBe('unknown')
553
+ })
554
+
555
+ it('keeps the red when Ready=False, still showing the phase', () => {
556
+ const s = getCNPGClusterStatus(withPhase('Rebalancing shards across zones', 'False'))
557
+ expect(s.text).toBe('Rebalancing shards across zones')
558
+ expect(s.level).toBe('unhealthy')
559
+ })
560
+
561
+ it('falls back to the condition only when there is no phase at all', () => {
562
+ expect(getCNPGClusterStatus(withPhase('', 'True')).text).toBe('Ready')
563
+ expect(getCNPGClusterStatus(withPhase('', 'False')).text).toBe('ClusterIsReady')
564
+ expect(getCNPGClusterStatus({ spec: {}, status: {} }).text).toBe('Unknown')
565
+ })
566
+ })
567
+
568
+ describe('an absent count is unknown, never zero', () => {
569
+ // The window this covers is ordinary: between creation and the operator's
570
+ // first status write. Brief, common, and exactly when someone is watching.
571
+ it('Cluster instances cell renders a dash, not 0', () => {
572
+ expect(getCNPGClusterInstances({ spec: { instances: 3 }, status: {} })).toBe('-/3')
573
+ expect(getCNPGClusterInstances({ spec: {}, status: {} })).toBe('-/-')
574
+ expect(getCNPGClusterInstances({ spec: { instances: 3 }, status: { readyInstances: 0 } })).toBe('0/3')
575
+ expect(getCNPGClusterInstances({ spec: { instances: 3 }, status: { readyInstances: 3 } })).toBe('3/3')
576
+ })
577
+
578
+ it('does not contradict the badge during that window', () => {
579
+ // The pairing IS the claim: a cell reading 0/3 beside a badge that does not
580
+ // say degraded is the contradiction this integration exists to remove.
581
+ const fresh = { spec: { instances: 3 }, status: { phase: 'Setting up primary' } }
582
+ expect(getCNPGClusterInstances(fresh)).toBe('-/3')
583
+ expect(getCNPGClusterStatus(fresh).level).not.toBe('unhealthy')
584
+ })
585
+
586
+ it('Pooler instances cell renders a dash, not 0', () => {
587
+ expect(getCNPGPoolerInstances({ spec: { instances: 2 }, status: {} })).toBe('-/2')
588
+ expect(getCNPGPoolerInstances({ spec: { instances: 2 }, status: { instances: 0 } })).toBe('0/2')
589
+ })
590
+
591
+ it('Pooler badge says Unknown, not a red Not Scheduled', () => {
592
+ // Sharper than the cell: reading absence as 0 fabricated a FAILURE, so an
593
+ // unreconciled Pooler went red on a state nobody had reported yet.
594
+ const fresh = { spec: { instances: 2 }, status: {} }
595
+ expect(getCNPGPoolerStatus(fresh)).toMatchObject({ text: 'Unknown', level: 'unknown' })
596
+ expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 0 } }))
597
+ .toMatchObject({ text: 'Not Scheduled', level: 'unhealthy' })
598
+ expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } }))
599
+ .toMatchObject({ text: 'Scheduled', level: 'healthy' })
600
+ })
601
+ })