@skyhook-io/k8s-ui 1.12.0 → 1.13.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 (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. package/src/utils/resource-icons.ts +29 -2
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ getCNPGObjectStoreRecoveryWindows,
4
+ getCNPGObjectStoreStatus,
5
+ getCNPGObjectStoreProvider,
6
+ isCNPGObjectStore,
7
+ } from './resource-utils-cnpg'
8
+
9
+ // Field names verified against the real CRD (plugin-barman-cloud v0.14.0,
10
+ // barmancloud.cnpg.io/v1) on a kind cluster — `lastSuccessfulBackupTime`, not
11
+ // `lastSuccessfulBackup` as the Cluster status uses.
12
+ const store = (status: any, spec: any = {}) => ({
13
+ apiVersion: 'barmancloud.cnpg.io/v1',
14
+ kind: 'ObjectStore',
15
+ metadata: { name: 'pg-store', namespace: 'pg' },
16
+ spec: { configuration: { destinationPath: 's3://b/pg' }, ...spec },
17
+ status,
18
+ })
19
+
20
+ describe('getCNPGObjectStoreRecoveryWindows', () => {
21
+ it('reads the per-server map and sorts by server', () => {
22
+ const w = getCNPGObjectStoreRecoveryWindows(
23
+ store({
24
+ serverRecoveryWindow: {
25
+ zeta: { firstRecoverabilityPoint: '2026-08-01T00:00:00Z', lastSuccessfulBackupTime: '2026-08-10T00:00:00Z' },
26
+ alpha: { firstRecoverabilityPoint: '2026-08-02T00:00:00Z', lastSuccessfulBackupTime: '2026-08-11T00:00:00Z' },
27
+ },
28
+ }),
29
+ )
30
+ expect(w.map((x) => x.server)).toEqual(['alpha', 'zeta'])
31
+ })
32
+
33
+ it('flags a failure newer than the last success', () => {
34
+ const [w] = getCNPGObjectStoreRecoveryWindows(
35
+ store({
36
+ serverRecoveryWindow: {
37
+ pg: {
38
+ lastSuccessfulBackupTime: '2026-08-05T00:00:00Z',
39
+ lastFailedBackupTime: '2026-08-12T00:00:00Z',
40
+ },
41
+ },
42
+ }),
43
+ )
44
+ expect(w.failingSinceLastSuccess).toBe(true)
45
+ })
46
+
47
+ it('does not flag a failure that predates the last success', () => {
48
+ const [w] = getCNPGObjectStoreRecoveryWindows(
49
+ store({
50
+ serverRecoveryWindow: {
51
+ pg: {
52
+ lastFailedBackupTime: '2026-08-05T00:00:00Z',
53
+ lastSuccessfulBackupTime: '2026-08-12T00:00:00Z',
54
+ },
55
+ },
56
+ }),
57
+ )
58
+ expect(w.failingSinceLastSuccess).toBe(false)
59
+ })
60
+
61
+ it('treats a failure with no success at all as failing', () => {
62
+ const [w] = getCNPGObjectStoreRecoveryWindows(
63
+ store({ serverRecoveryWindow: { pg: { lastFailedBackupTime: '2026-08-12T00:00:00Z' } } }),
64
+ )
65
+ expect(w.failingSinceLastSuccess).toBe(true)
66
+ })
67
+
68
+ it('returns nothing for a store that has never reported', () => {
69
+ expect(getCNPGObjectStoreRecoveryWindows(store({}))).toEqual([])
70
+ expect(getCNPGObjectStoreRecoveryWindows(store(undefined))).toEqual([])
71
+ })
72
+ })
73
+
74
+ describe('getCNPGObjectStoreStatus', () => {
75
+ // The load-bearing one: an empty status means nothing is restorable, and a
76
+ // green badge there would assert a recovery point that does not exist.
77
+ it('is unknown, not healthy, when no server has reported', () => {
78
+ const s = getCNPGObjectStoreStatus(store({}))
79
+ expect(s.level).toBe('unknown')
80
+ expect(s.text).toBe('No backups yet')
81
+ })
82
+
83
+ it('is unhealthy when any server is failing since its last success', () => {
84
+ const s = getCNPGObjectStoreStatus(
85
+ store({
86
+ serverRecoveryWindow: {
87
+ ok: { lastSuccessfulBackupTime: '2026-08-12T00:00:00Z' },
88
+ bad: { lastSuccessfulBackupTime: '2026-08-01T00:00:00Z', lastFailedBackupTime: '2026-08-12T00:00:00Z' },
89
+ },
90
+ }),
91
+ )
92
+ expect(s.level).toBe('unhealthy')
93
+ })
94
+
95
+ it('is healthy when every server has a clean latest backup', () => {
96
+ const s = getCNPGObjectStoreStatus(
97
+ store({ serverRecoveryWindow: { pg: { lastSuccessfulBackupTime: '2026-08-12T00:00:00Z' } } }),
98
+ )
99
+ expect(s.level).toBe('healthy')
100
+ })
101
+ })
102
+
103
+ describe('provider + group guards', () => {
104
+ it('names the provider without exposing credentials', () => {
105
+ expect(
106
+ getCNPGObjectStoreProvider(
107
+ store({}, { configuration: { destinationPath: 's3://b', s3Credentials: { accessKeyId: { name: 'x' } } } }),
108
+ ),
109
+ ).toBe('S3')
110
+ expect(
111
+ getCNPGObjectStoreProvider(store({}, { configuration: { azureCredentials: {} } })),
112
+ ).toBe('Azure Blob Storage')
113
+ expect(getCNPGObjectStoreProvider(store({}, { configuration: {} }))).toBeUndefined()
114
+ })
115
+
116
+ // `objectstores` is generic enough for another operator to claim.
117
+ it('does not claim a foreign objectstores CR', () => {
118
+ expect(isCNPGObjectStore(store({}))).toBe(true)
119
+ expect(isCNPGObjectStore({ apiVersion: 'minio.example.io/v1', kind: 'ObjectStore' })).toBe(false)
120
+ expect(isCNPGObjectStore({})).toBe(false)
121
+ })
122
+ })
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
2
  import {
3
+ getCNPGScheduledBackupStatus,
4
+ getCNPGScheduledBackupNextSchedule,
3
5
  getCNPGClusterCertificateExpirations,
4
6
  getCNPGClusterStatus,
5
7
  getCNPGBackupStatus,
@@ -662,3 +664,55 @@ describe('volume health', () => {
662
664
  expect(h?.healthy).toEqual(['ok'])
663
665
  })
664
666
  })
667
+
668
+ describe('CNPG scheduled backup lateness', () => {
669
+ const at = (ms: number) => new Date(Date.now() + ms).toISOString()
670
+ const MIN = 60 * 1000
671
+
672
+ const sb = (suspend: boolean, next?: string, lastScheduleTime?: string) => ({
673
+ spec: { schedule: '0 0 0 * * *', suspend },
674
+ status: { ...(next ? { nextScheduleTime: next } : {}), ...(lastScheduleTime ? { lastScheduleTime } : {}) },
675
+ })
676
+
677
+ it('reports a schedule that missed its own next-run time', () => {
678
+ // Ran once, then stopped. Both lastScheduleTime and nextScheduleTime are
679
+ // present, which is exactly the shape that used to read healthy.
680
+ const badge = getCNPGScheduledBackupStatus(sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN)))
681
+ expect(badge.level).toBe('degraded')
682
+ expect(badge.text).toBe('Overdue')
683
+ })
684
+
685
+ it('stays healthy inside the operator reconcile window', () => {
686
+ const badge = getCNPGScheduledBackupStatus(sb(false, at(-1 * MIN), at(-24 * 60 * MIN)))
687
+ expect(badge.level).toBe('healthy')
688
+ })
689
+
690
+ it('keeps suspended as a deliberate state, not a missed backup', () => {
691
+ // The operator stops maintaining nextScheduleTime once suspended, so the
692
+ // stale value must not be read as a missed run.
693
+ const badge = getCNPGScheduledBackupStatus(sb(true, at(-200 * 24 * 60 * MIN)))
694
+ expect(badge.text).toBe('Suspended')
695
+ })
696
+
697
+ it('never says overdue while the badge still says healthy', () => {
698
+ // The two read from one test. Before this they disagreed: the badge said
699
+ // Active while this field said overdue on the same row.
700
+ const justPastDue = sb(false, at(-1 * MIN), at(-24 * 60 * MIN))
701
+ expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('healthy')
702
+ expect(getCNPGScheduledBackupNextSchedule(justPastDue)).toBe('due now')
703
+
704
+ const longPastDue = sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN))
705
+ expect(getCNPGScheduledBackupStatus(longPastDue).level).toBe('degraded')
706
+ expect(getCNPGScheduledBackupNextSchedule(longPastDue)).toContain('overdue by')
707
+ })
708
+
709
+ it('does not count down to a run that is not coming', () => {
710
+ expect(getCNPGScheduledBackupNextSchedule(sb(true, at(60 * MIN)))).toBe('suspended')
711
+ expect(getCNPGScheduledBackupNextSchedule(sb(true, at(-60 * MIN)))).toBe('suspended')
712
+ })
713
+
714
+ it('says nothing when the operator published no next-run time', () => {
715
+ expect(getCNPGScheduledBackupNextSchedule(sb(false))).toBe('-')
716
+ expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('healthy')
717
+ })
718
+ })
@@ -252,12 +252,19 @@ export function getCNPGClusterPhase(resource: any): string {
252
252
  return resource.status?.phase || '-'
253
253
  }
254
254
 
255
+ // A cluster pinned to an ImageCatalog carries no `spec.imageName` at all — the
256
+ // operator resolves the image from the catalog and records it in `status.image`.
257
+ // Reading spec alone leaves those clusters showing a dash where an image runs.
258
+ function cnpgImage(resource: any): string {
259
+ return resource.spec?.imageName || resource.status?.image || ''
260
+ }
261
+
255
262
  export function getCNPGClusterImage(resource: any): string {
256
- return resource.spec?.imageName || '-'
263
+ return cnpgImage(resource) || '-'
257
264
  }
258
265
 
259
266
  export function getCNPGClusterImageTag(resource: any): string {
260
- const image = resource.spec?.imageName || ''
267
+ const image = cnpgImage(resource)
261
268
  if (!image) return '-'
262
269
  const parts = image.split(':')
263
270
  if (parts.length > 1) return parts[parts.length - 1]
@@ -293,6 +300,12 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
293
300
 
294
301
  export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
295
302
 
303
+ /** The API group ObjectStore is served under. `objectstores` is a generic
304
+ * plural several operators ship, so every link to one has to be qualified —
305
+ * an unqualified link resolves by plural alone and can land on a foreign CRD's
306
+ * object of the same name. */
307
+ export const CNPG_BARMAN_OBJECTSTORE_GROUP = 'barmancloud.cnpg.io'
308
+
296
309
  export interface CNPGBarmanPlugin {
297
310
  name: string
298
311
  /** ObjectStore CR (barmancloud.cnpg.io/v1) holding the real destination + recovery window. */
@@ -354,6 +367,180 @@ export function getCNPGClusterBackupConfig(resource: any): {
354
367
  }
355
368
  }
356
369
 
370
+ // ============================================================================
371
+ // DECLARATIVE OBJECTS — Database, Publication, Subscription
372
+ // ============================================================================
373
+
374
+ /**
375
+ * The three declarative kinds share one status shape: `applied` plus a
376
+ * `message`. The field has three meanings, not two — true, false, and absent —
377
+ * and absent is "the operator has not reconciled this yet", which is a
378
+ * different thing from failed. Collapsing them would report a freshly created
379
+ * object as broken.
380
+ */
381
+ export function getCNPGDeclarativeStatus(resource: any): StatusBadge {
382
+ const applied = resource?.status?.applied
383
+ if (applied === true) return { text: 'Applied', color: healthColors.healthy, level: 'healthy' }
384
+ if (applied === false) return { text: 'Not Applied', color: healthColors.unhealthy, level: 'unhealthy' }
385
+ return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
386
+ }
387
+
388
+ /** The operator's own explanation for a failure. Never paraphrased. */
389
+ export function getCNPGDeclarativeMessage(resource: any): string | undefined {
390
+ const m = resource?.status?.message
391
+ return typeof m === 'string' && m !== '' ? m : undefined
392
+ }
393
+
394
+ /**
395
+ * What happens in PostgreSQL when this CR is deleted.
396
+ *
397
+ * `delete` drops the real database, publication or subscription; `retain`
398
+ * leaves it. This is the difference between removing a manifest and losing
399
+ * data, so it is surfaced rather than left in the YAML. CNPG defaults to
400
+ * `retain` when unset.
401
+ */
402
+ export function getCNPGReclaimPolicy(resource: any): { value: string; destructive: boolean } {
403
+ const spec = resource?.spec ?? {}
404
+ const raw =
405
+ spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? 'retain'
406
+ return { value: raw, destructive: String(raw).toLowerCase() === 'delete' }
407
+ }
408
+
409
+ /** The Cluster this object is declared against. */
410
+ export function getCNPGDeclarativeCluster(resource: any): string | undefined {
411
+ const n = resource?.spec?.cluster?.name
412
+ return typeof n === 'string' && n !== '' ? n : undefined
413
+ }
414
+
415
+ export interface CNPGImageCatalogEntry {
416
+ major: number
417
+ image: string
418
+ }
419
+
420
+ /** Major-version pins, sorted so the list does not reshuffle between reads. */
421
+ export function getCNPGImageCatalogEntries(resource: any): CNPGImageCatalogEntry[] {
422
+ const raw = resource?.spec?.images
423
+ if (!Array.isArray(raw)) return []
424
+ return raw
425
+ .filter((e: any) => typeof e?.image === 'string')
426
+ .map((e: any) => ({ major: Number(e.major), image: e.image as string }))
427
+ .sort((a, b) => a.major - b.major)
428
+ }
429
+
430
+ // ============================================================================
431
+ // OBJECTSTORE (barmancloud.cnpg.io/v1) — the plugin-path backup destination
432
+ // ============================================================================
433
+
434
+ export const CNPG_BARMAN_GROUP = 'barmancloud.cnpg.io'
435
+
436
+ /**
437
+ * The plugin that took (or will take) a Backup / ScheduledBackup, from
438
+ * `spec.pluginConfiguration`. Present only when `spec.method` is `plugin`; the
439
+ * in-tree path leaves it unset, so absence is meaningful rather than missing.
440
+ */
441
+ export function getCNPGBackupPlugin(resource: any): { name: string; parameters?: Record<string, any> } | null {
442
+ if (resource?.spec?.method !== 'plugin') return null
443
+ const cfg = resource?.spec?.pluginConfiguration
444
+ if (!cfg?.name) return null
445
+ return { name: cfg.name, parameters: cfg.parameters }
446
+ }
447
+
448
+ /** True for a barman-cloud ObjectStore. The `objectstores` plural is generic
449
+ * enough to belong to another operator, so kind alone is not enough. */
450
+ export function isCNPGObjectStore(resource: any): boolean {
451
+ return typeof resource?.apiVersion === 'string' && resource.apiVersion.startsWith(`${CNPG_BARMAN_GROUP}/`)
452
+ }
453
+
454
+ /** Which cloud the credentials point at. Only the provider is reported — the
455
+ * values are Secret references and never belong on screen. */
456
+ export function getCNPGObjectStoreProvider(resource: any): string | undefined {
457
+ const c = resource?.spec?.configuration
458
+ if (!c) return undefined
459
+ if (c.s3Credentials) return 'S3'
460
+ if (c.azureCredentials) return 'Azure Blob Storage'
461
+ if (c.googleCredentials) return 'Google Cloud Storage'
462
+ return undefined
463
+ }
464
+
465
+ /**
466
+ * The Secret holding the destination's credentials, by name only.
467
+ *
468
+ * Named rather than read: the values never belong on screen, but "which Secret"
469
+ * is the first question when a backup fails on authentication, and a page that
470
+ * says only "S3" leaves nowhere to go.
471
+ */
472
+ export function getCNPGObjectStoreCredentialSecret(resource: any): string | undefined {
473
+ const c = resource?.spec?.configuration
474
+ const ref =
475
+ c?.s3Credentials?.accessKeyId ??
476
+ c?.azureCredentials?.storageAccount ??
477
+ c?.azureCredentials?.connectionString ??
478
+ c?.googleCredentials?.applicationCredentials
479
+ return typeof ref?.name === 'string' && ref.name !== '' ? ref.name : undefined
480
+ }
481
+
482
+ export interface CNPGObjectStoreRecoveryWindow {
483
+ server: string
484
+ firstRecoverabilityPoint?: string
485
+ lastSuccessfulBackupTime?: string
486
+ lastFailedBackupTime?: string
487
+ /** A failure newer than the last success — the window has stopped advancing. */
488
+ failingSinceLastSuccess: boolean
489
+ }
490
+
491
+ /**
492
+ * The recovery window per PostgreSQL server, from
493
+ * `status.serverRecoveryWindow` — a map keyed by server name.
494
+ *
495
+ * This is the payload the Cluster page promises and cannot show: a
496
+ * plugin-backed Cluster publishes no recovery-point fields of its own, so this
497
+ * is the only place the restorable range exists.
498
+ */
499
+ export function getCNPGObjectStoreRecoveryWindows(resource: any): CNPGObjectStoreRecoveryWindow[] {
500
+ const raw = resource?.status?.serverRecoveryWindow
501
+ if (!raw || typeof raw !== 'object') return []
502
+ return Object.entries(raw)
503
+ .map(([server, w]: [string, any]) => {
504
+ const lastSuccess = w?.lastSuccessfulBackupTime
505
+ const lastFailed = w?.lastFailedBackupTime
506
+ return {
507
+ server,
508
+ firstRecoverabilityPoint: w?.firstRecoverabilityPoint,
509
+ lastSuccessfulBackupTime: lastSuccess,
510
+ lastFailedBackupTime: lastFailed,
511
+ failingSinceLastSuccess:
512
+ !!lastFailed && (!lastSuccess || new Date(lastFailed) > new Date(lastSuccess)),
513
+ }
514
+ })
515
+ .sort((a, b) => a.server.localeCompare(b.server))
516
+ }
517
+
518
+ /**
519
+ * Health for the list badge.
520
+ *
521
+ * `unknown` rather than healthy when no server has reported: an ObjectStore
522
+ * with an empty status is configured but holds nothing restorable, and green
523
+ * there would assert a recovery point that does not exist.
524
+ */
525
+ export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
526
+ const windows = getCNPGObjectStoreRecoveryWindows(resource)
527
+ if (windows.length === 0) {
528
+ return { text: 'No backups yet', color: healthColors.unknown, level: 'unknown' }
529
+ }
530
+ if (windows.some((w) => w.failingSinceLastSuccess)) {
531
+ return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
532
+ }
533
+ return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
534
+ }
535
+
536
+ export function getCNPGObjectStoreDestination(resource: any): string {
537
+ return resource?.spec?.configuration?.destinationPath || '-'
538
+ }
539
+
540
+ export function getCNPGObjectStoreRetention(resource: any): string | undefined {
541
+ return resource?.spec?.retentionPolicy
542
+ }
543
+
357
544
  export function getCNPGClusterMonitoring(resource: any): {
358
545
  podMonitorEnabled: boolean
359
546
  customQueriesConfigMap?: string[]
@@ -576,6 +763,33 @@ export function getCNPGBackupTarget(resource: any): string {
576
763
  // CNPG SCHEDULED BACKUP UTILITIES
577
764
  // ============================================================================
578
765
 
766
+ /**
767
+ * Reconcile latency between a backup coming due and the operator starting it.
768
+ * Not a backup policy — nothing here decides how often backups should run.
769
+ * Kept in step with the detector that raises the matching issue.
770
+ */
771
+ const CNPG_SCHEDULED_BACKUP_GRACE_MS = 10 * 60 * 1000
772
+
773
+ /**
774
+ * How far past its own next-run time this schedule is, or null when it is not
775
+ * meaningfully overdue.
776
+ *
777
+ * The expectation belongs to the operator: CNPG publishes `nextScheduleTime`
778
+ * and updates it whenever it runs a backup. A value still in the past therefore
779
+ * means no backup happened. Suspended schedules are excluded because the
780
+ * operator stops maintaining the field, so the stale value would read as missed
781
+ * on every deliberately paused schedule.
782
+ */
783
+ export function getCNPGScheduledBackupOverdueMs(resource: any): number | null {
784
+ if (resource?.spec?.suspend === true) return null
785
+ const next = resource?.status?.nextScheduleTime
786
+ if (!next) return null
787
+ const dueAt = new Date(next).getTime()
788
+ if (Number.isNaN(dueAt)) return null
789
+ const overdue = Date.now() - dueAt
790
+ return overdue > CNPG_SCHEDULED_BACKUP_GRACE_MS ? overdue : null
791
+ }
792
+
579
793
  export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
580
794
  const isSuspended = resource.spec?.suspend === true
581
795
 
@@ -583,6 +797,14 @@ export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
583
797
  return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
584
798
  }
585
799
 
800
+ // The operator said when the next backup was due and it has not happened.
801
+ // Checked before lastScheduleTime: a schedule that ran once and then stopped
802
+ // has both, and reporting it healthy contradicted the next-run figure sitting
803
+ // on the same row.
804
+ if (getCNPGScheduledBackupOverdueMs(resource) !== null) {
805
+ return { text: 'Overdue', color: healthColors.degraded, level: 'degraded' }
806
+ }
807
+
586
808
  // If we have a last schedule time, it's active
587
809
  if (resource.status?.lastScheduleTime) {
588
810
  return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
@@ -615,10 +837,20 @@ export function getCNPGScheduledBackupLastSchedule(resource: any): string {
615
837
  export function getCNPGScheduledBackupNextSchedule(resource: any): string {
616
838
  const next = resource.status?.nextScheduleTime
617
839
  if (!next) return '-'
840
+ // A suspended schedule keeps whatever next-run time it had when it was
841
+ // paused, and the operator stops maintaining it. Counting down to it, or
842
+ // calling it due, would both describe a run that is not coming.
843
+ if (resource.spec?.suspend === true) return 'suspended'
618
844
  const nextDate = new Date(next)
619
845
  const now = new Date()
620
846
  const diffMs = nextDate.getTime() - now.getTime()
621
- if (diffMs <= 0) return 'overdue'
847
+ // Past due, but inside the window where the operator has simply not picked it
848
+ // up yet. Calling that "overdue" while the badge still reads healthy is the
849
+ // contradiction this pair is meant to avoid.
850
+ if (diffMs <= 0) {
851
+ const overdue = getCNPGScheduledBackupOverdueMs(resource)
852
+ return overdue === null ? 'due now' : `overdue by ${formatDuration(overdue)}`
853
+ }
622
854
  return `in ${formatDuration(diffMs)}`
623
855
  }
624
856
 
@@ -767,3 +999,32 @@ export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
767
999
  health.initializing.length > 0
768
1000
  return reportedAnything ? health : null
769
1001
  }
1002
+
1003
+ /**
1004
+ * Splits declarative objects by what the operator has actually said about them.
1005
+ *
1006
+ * Three buckets, not two. `applied: true` is live. `applied: false` is a failure
1007
+ * the operator can explain. ABSENT is not yet reconciled — and folding that in
1008
+ * with the failures tells the reader an object "exists in Kubernetes and not in
1009
+ * PostgreSQL" during the seconds before anything has looked at it.
1010
+ */
1011
+ export function splitCNPGDeclarativeByApplied<T>(objects: T[]): {
1012
+ applied: T[]
1013
+ notApplied: T[]
1014
+ pending: T[]
1015
+ } {
1016
+ const out = { applied: [] as T[], notApplied: [] as T[], pending: [] as T[] }
1017
+ for (const o of objects) {
1018
+ switch (getCNPGDeclarativeStatus(o).level) {
1019
+ case 'healthy':
1020
+ out.applied.push(o)
1021
+ break
1022
+ case 'unhealthy':
1023
+ out.notApplied.push(o)
1024
+ break
1025
+ default:
1026
+ out.pending.push(o)
1027
+ }
1028
+ }
1029
+ return out
1030
+ }
@@ -143,6 +143,30 @@ export function getKyvernoFailurePolicy(resource: any): string {
143
143
  return resource?.spec?.failurePolicy || ''
144
144
  }
145
145
 
146
+ /**
147
+ * `spec.failurePolicy` with the CRD's own default applied.
148
+ *
149
+ * An ABSENT failurePolicy means Fail — "Allowed values are Ignore or Fail.
150
+ * Defaults to Fail", per the published schema, and most policies never set it.
151
+ * Reading absent as anything else inverts the consequence of an engine error:
152
+ * the screen would say those admissions are "allowed through unchecked" when the
153
+ * cluster is in fact rejecting them. Same defaulting trap as validationActions
154
+ * above, in the other direction.
155
+ *
156
+ * Kept separate from the raw reader so the Evaluation section can still show
157
+ * only what the policy actually declares.
158
+ */
159
+ export function getKyvernoEffectiveFailurePolicy(resource: any): string {
160
+ return (
161
+ resource?.spec?.failurePolicy ||
162
+ // Where the legacy family keeps it since 1.13. Reading only the modern
163
+ // location would default a policy that explicitly set Ignore to Fail, and
164
+ // announce a rejection the cluster is not making.
165
+ resource?.spec?.webhookConfiguration?.failurePolicy ||
166
+ 'Fail'
167
+ )
168
+ }
169
+
146
170
  /**
147
171
  * Computes what the policy actually does, rather than echoing a single field.
148
172
  *