@skyhook-io/k8s-ui 1.11.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 (123) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
  5. package/src/components/gitops/detail-helpers.ts +4 -1
  6. package/src/components/issues/severity.ts +1 -0
  7. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  8. package/src/components/resources/ResourcesView.tsx +412 -5
  9. package/src/components/resources/index.ts +27 -0
  10. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  11. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  12. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  13. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  14. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  15. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  16. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +71 -4
  17. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  18. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  19. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  20. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
  21. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  22. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  23. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  24. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  25. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  26. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  27. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  28. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  29. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  30. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  31. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  32. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  33. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  34. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  35. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  36. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  37. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  38. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  39. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  40. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  41. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  42. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  43. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  44. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  46. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  48. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  49. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  50. package/src/components/resources/renderers/PolicySection.test.tsx +159 -0
  51. package/src/components/resources/renderers/PolicySection.tsx +197 -0
  52. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  53. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  54. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  55. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  56. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  57. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  58. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  59. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  60. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  61. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  62. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  63. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  64. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  65. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  66. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  67. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  68. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  69. package/src/components/resources/renderers/index.ts +16 -0
  70. package/src/components/resources/renderers/kyverno-cells.tsx +90 -0
  71. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  72. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  73. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  74. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  75. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  76. package/src/components/resources/resource-utils-calico.ts +261 -0
  77. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  78. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  79. package/src/components/resources/resource-utils-cnpg.test.ts +117 -0
  80. package/src/components/resources/resource-utils-cnpg.ts +324 -3
  81. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  82. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  83. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  84. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  85. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  86. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  87. package/src/components/resources/resource-utils-velero.ts +68 -0
  88. package/src/components/resources/resource-utils.ts +98 -0
  89. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  90. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  91. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  92. package/src/components/shared/index.ts +1 -1
  93. package/src/components/shared/revision-role-badges.test.ts +120 -0
  94. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  95. package/src/components/topology/K8sResourceNode.tsx +22 -0
  96. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  97. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  98. package/src/components/topology/TopologyGraph.tsx +6 -6
  99. package/src/components/topology/TopologySearch.tsx +7 -0
  100. package/src/components/topology/layout.ts +3 -0
  101. package/src/components/trace/ReachabilityView.tsx +1 -0
  102. package/src/components/trace/TracePanel.tsx +12 -2
  103. package/src/components/trace/reachFixtures.ts +26 -1
  104. package/src/components/trace/reachGraphModel.test.ts +20 -0
  105. package/src/components/trace/reachGraphModel.ts +8 -1
  106. package/src/components/trace/reachOrigins.test.ts +31 -0
  107. package/src/components/trace/reachOrigins.ts +11 -1
  108. package/src/components/ui/Badge.tsx +10 -0
  109. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  110. package/src/components/ui/ScheduleValue.tsx +85 -0
  111. package/src/components/ui/drawer-components.test.tsx +17 -1
  112. package/src/components/ui/drawer-components.tsx +9 -2
  113. package/src/components/ui/index.ts +2 -0
  114. package/src/components/workload/WorkloadView.tsx +3 -0
  115. package/src/types/core.ts +29 -0
  116. package/src/types/gitops-flux-status.test.ts +163 -0
  117. package/src/types/gitops.ts +80 -24
  118. package/src/types/index.ts +1 -0
  119. package/src/types/policy.ts +193 -0
  120. package/src/utils/navigation.test.ts +5 -0
  121. package/src/utils/navigation.ts +5 -0
  122. package/src/utils/resource-icons.test.ts +49 -2
  123. package/src/utils/resource-icons.ts +29 -2
@@ -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
 
@@ -666,6 +898,12 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
666
898
  return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
667
899
  }
668
900
 
901
+ // Faults outrank intent: a Pooler that is both paused and unscheduled has a
902
+ // problem worth fixing before the pause is worth mentioning.
903
+ if (isCNPGPoolerPaused(resource)) {
904
+ return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
905
+ }
906
+
669
907
  if (desired > 0 && scheduled >= desired) {
670
908
  return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
671
909
  }
@@ -673,6 +911,16 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
673
911
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
674
912
  }
675
913
 
914
+ /**
915
+ * PgBouncer holds client connections instead of serving them while paused, and
916
+ * every pod stays scheduled and Ready throughout - the one Pooler state that
917
+ * pod counts cannot express. CRD-defaulted to false, so only an explicit true
918
+ * pauses.
919
+ */
920
+ export function isCNPGPoolerPaused(resource: any): boolean {
921
+ return resource.spec?.pgbouncer?.paused === true
922
+ }
923
+
676
924
  /** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
677
925
  export function getCNPGPoolerDeploymentName(resource: any): string {
678
926
  return resource.metadata?.name || ''
@@ -707,3 +955,76 @@ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } |
707
955
  if (!secret?.name) return undefined
708
956
  return { name: secret.name }
709
957
  }
958
+
959
+ /**
960
+ * Volume health for a CNPG cluster, read from the arrays the operator publishes
961
+ * on status. Radar previously showed only the REQUESTED size and storage class,
962
+ * which are spec-side intentions — a cluster whose volumes had failed rendered
963
+ * a cheerful "20Gi" with nothing to suggest otherwise.
964
+ *
965
+ * `unusablePVC` is the sharp one. Upstream defines it as a volume that cannot be
966
+ * used because a paired volume is missing, which is why a database instance can
967
+ * be permanently unable to start while every other field looks ordinary.
968
+ *
969
+ * Returns null when the operator has published nothing, so the section can stay
970
+ * silent rather than claim zero problems it never checked for.
971
+ */
972
+ export interface CNPGVolumeHealth {
973
+ total?: number
974
+ healthy: string[]
975
+ unusable: string[]
976
+ dangling: string[]
977
+ resizing: string[]
978
+ initializing: string[]
979
+ }
980
+
981
+ export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
982
+ const s = resource?.status
983
+ if (!s) return null
984
+ const list = (v: any): string[] => (Array.isArray(v) ? v.filter((x: any) => typeof x === 'string') : [])
985
+ const health: CNPGVolumeHealth = {
986
+ total: typeof s.pvcCount === 'number' ? s.pvcCount : undefined,
987
+ healthy: list(s.healthyPVC),
988
+ unusable: list(s.unusablePVC),
989
+ dangling: list(s.danglingPVC),
990
+ resizing: list(s.resizingPVC),
991
+ initializing: list(s.initializingPVC),
992
+ }
993
+ const reportedAnything =
994
+ health.total !== undefined ||
995
+ health.healthy.length > 0 ||
996
+ health.unusable.length > 0 ||
997
+ health.dangling.length > 0 ||
998
+ health.resizing.length > 0 ||
999
+ health.initializing.length > 0
1000
+ return reportedAnything ? health : null
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
+ }
@@ -9,6 +9,7 @@ import {
9
9
  getProviderConfigStatus,
10
10
  getProviderConfigRef,
11
11
  getCrossplaneResourceRefs,
12
+ getBoundXRRef,
12
13
  getExternalName,
13
14
  getComposingXRRef,
14
15
  isCrossplanePaused,
@@ -512,6 +513,73 @@ describe('getCrossplaneResourceRefs', () => {
512
513
  })
513
514
  })
514
515
 
516
+ describe('getBoundXRRef', () => {
517
+ it('returns the singular spec.resourceRef of a v1 claim', () => {
518
+ const claim = {
519
+ spec: {
520
+ compositionRef: { name: 'database' },
521
+ resourceRef: {
522
+ apiVersion: 'platform.example.org/v1alpha1',
523
+ kind: 'Database',
524
+ name: 'example-database-x7k2m',
525
+ },
526
+ },
527
+ } as const
528
+ const ref = getBoundXRRef(claim)
529
+ expect(ref?.kind).toBe('Database')
530
+ expect(ref?.name).toBe('example-database-x7k2m')
531
+ expect(ref?.apiVersion).toBe('platform.example.org/v1alpha1')
532
+ })
533
+
534
+ it('returns null when resourceRef is absent or malformed', () => {
535
+ expect(getBoundXRRef({ spec: {} } as const)).toBeNull()
536
+ expect(getBoundXRRef({ spec: { resourceRef: { name: 'no-kind' } } } as const)).toBeNull()
537
+ expect(getBoundXRRef({ spec: { resourceRef: { kind: 'X' } } } as const)).toBeNull()
538
+ expect(getBoundXRRef({} as const)).toBeNull()
539
+ })
540
+ })
541
+
542
+ describe('claim vs XR composed-refs split', () => {
543
+ // A v1 claim carries NO resourceRefs of its own — only a singular resourceRef
544
+ // to its bound XR. The composed refs live on that XR. The claim panel must
545
+ // follow the ref rather than read the claim, or it shows "No composed
546
+ // resources" for a claim that has composed plenty.
547
+ const claim = {
548
+ spec: {
549
+ compositionRef: { name: 'database' },
550
+ resourceRef: {
551
+ apiVersion: 'platform.example.org/v1alpha1',
552
+ kind: 'Database',
553
+ name: 'example-database-x7k2m',
554
+ },
555
+ },
556
+ } as const
557
+ const boundXR = {
558
+ spec: {
559
+ resourceRefs: [
560
+ { apiVersion: 'database.example.org/v1beta1', kind: 'Instance', name: 'example-database-x7k2m' },
561
+ { apiVersion: 'identity.example.org/v1beta1', kind: 'Role', name: 'example-database-access' },
562
+ { apiVersion: 'kubernetes.crossplane.io/v1alpha2', kind: 'Object', name: 'example-database-connection' },
563
+ ],
564
+ },
565
+ } as const
566
+
567
+ it('reads nothing off the claim itself', () => {
568
+ expect(getCrossplaneResourceRefs(claim)).toEqual([])
569
+ })
570
+
571
+ it('is detected as a claim with a bound XR', () => {
572
+ expect(isClaim(claim)).toBe(true)
573
+ expect(getBoundXRRef(claim)?.name).toBe('example-database-x7k2m')
574
+ })
575
+
576
+ it('reads the composed refs off the bound XR', () => {
577
+ const refs = getCrossplaneResourceRefs(boundXR)
578
+ expect(refs).toHaveLength(3)
579
+ expect(refs.map(r => r.kind)).toEqual(['Instance', 'Role', 'Object'])
580
+ })
581
+ })
582
+
515
583
  describe('getExternalName', () => {
516
584
  it('reads the crossplane.io/external-name annotation', () => {
517
585
  const resource = {
@@ -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
  *
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ getKyvernoRequestType,
4
+ getKyvernoRequestTriggers,
5
+ getKyvernoRequestGenerated,
6
+ getKyvernoRequestState,
7
+ getKyvernoReportSubject,
8
+ getKyvernoReportSummary,
9
+ getKyvernoReportResults,
10
+ getKyvernoReportStatus,
11
+ kyvernoReportTimestamp,
12
+ } from './resource-utils-kyverno-queue'
13
+
14
+ /**
15
+ * The fixtures below are trimmed copies of objects captured with a watch on a
16
+ * live Kyverno 1.18.2 while a generateExisting rule and a mutate-existing rule
17
+ * ran: 251 UpdateRequests and 3322 EphemeralReports. Every assertion here
18
+ * exists because the obvious reading of the CRD produces a blank screen.
19
+ */
20
+
21
+ const GENERATE_REQUEST = {
22
+ metadata: { name: 'ur-j8qjv', namespace: 'kyverno', labels: { 'generate.kyverno.io/policy-name': 'probe-generate-existing' } },
23
+ spec: {
24
+ policy: 'probe-generate-existing',
25
+ requestType: 'generate',
26
+ deleteDownstream: false,
27
+ // Both empty on every generate request observed.
28
+ resource: {},
29
+ rule: '',
30
+ ruleContext: [
31
+ { rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' } },
32
+ { rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-002', namespace: 'policy-demo', uid: 'b' } },
33
+ ],
34
+ },
35
+ status: {
36
+ state: 'Completed',
37
+ generatedResources: [{ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', name: 'bulk-250-probe', namespace: 'policy-demo' }],
38
+ },
39
+ }
40
+
41
+ const MUTATE_REQUEST = {
42
+ metadata: { name: 'ur-2mhmq', namespace: 'kyverno' },
43
+ spec: {
44
+ policy: 'probe-mutate-existing',
45
+ requestType: 'mutate',
46
+ rule: 'label-existing-configmaps',
47
+ deleteDownstream: false,
48
+ resource: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' },
49
+ },
50
+ status: { state: 'Completed' },
51
+ }
52
+
53
+ describe('UpdateRequest', () => {
54
+ it('reads triggers off ruleContext for a generate request', () => {
55
+ // spec.resource is {} here — reading it alone renders an empty page.
56
+ const t = getKyvernoRequestTriggers(GENERATE_REQUEST)
57
+ expect(t).toHaveLength(2)
58
+ expect(t[0].name).toBe('bulk-001')
59
+ expect(t[0].kind).toBe('ConfigMap')
60
+ expect(t[0].rule).toBe('companion-for-existing')
61
+ })
62
+
63
+ it('reads the single trigger off spec.resource for a mutate request', () => {
64
+ const t = getKyvernoRequestTriggers(MUTATE_REQUEST)
65
+ expect(t).toHaveLength(1)
66
+ expect(t[0].name).toBe('bulk-001')
67
+ })
68
+
69
+ it('reports no triggers rather than a blank row when neither is present', () => {
70
+ expect(getKyvernoRequestTriggers({ spec: { requestType: 'generate', resource: {}, ruleContext: [] } })).toEqual([])
71
+ })
72
+
73
+ it('distinguishes the two request types', () => {
74
+ expect(getKyvernoRequestType(GENERATE_REQUEST)).toBe('generate')
75
+ expect(getKyvernoRequestType(MUTATE_REQUEST)).toBe('mutate')
76
+ expect(getKyvernoRequestType({ spec: {} })).toBe('unknown')
77
+ })
78
+
79
+ it('lists what was created, and only on the request that creates', () => {
80
+ expect(getKyvernoRequestGenerated(GENERATE_REQUEST)).toHaveLength(1)
81
+ expect(getKyvernoRequestGenerated(MUTATE_REQUEST)).toEqual([])
82
+ })
83
+
84
+ // Pending is the state worth surfacing: work queued and never done.
85
+ it('separates queued from done from failed', () => {
86
+ expect(getKyvernoRequestState({ status: { state: 'Pending' } }).level).toBe('degraded')
87
+ expect(getKyvernoRequestState({ status: { state: 'Completed' } }).level).toBe('healthy')
88
+ expect(getKyvernoRequestState({ status: { state: 'Failed' } }).level).toBe('unhealthy')
89
+ expect(getKyvernoRequestState({}).level).toBe('unknown')
90
+ })
91
+ })
92
+
93
+ const REPORT = {
94
+ metadata: {
95
+ name: 'eae46cfc-2733-466c-b20a-f1a354b6f73b-7b47h',
96
+ namespace: 'kube-system',
97
+ labels: {
98
+ 'audit.kyverno.io/resource.kind': 'Pod',
99
+ 'audit.kyverno.io/resource.group': '',
100
+ 'audit.kyverno.io/resource.version': 'v1',
101
+ 'audit.kyverno.io/resource.uid': 'eae46cfc-2733-466c-b20a-f1a354b6f73b',
102
+ 'audit.kyverno.io/source': 'background-scan',
103
+ },
104
+ ownerReferences: [{ apiVersion: 'v1', kind: 'Pod', name: 'coredns-7d764666f9-dl4p6', uid: 'eae46cfc' }],
105
+ },
106
+ spec: {
107
+ // Present and blank on every report observed.
108
+ owner: { apiVersion: '', kind: '', name: '', uid: '' },
109
+ summary: { error: 0, fail: 1, pass: 2, skip: 0, warn: 0 },
110
+ results: [
111
+ { message: 'mutation is not applied', policy: 'add-default-labels', result: 'fail', source: 'KyvernoMutatingPolicy', timestamp: { seconds: 1786622539, nanos: 0 } },
112
+ ],
113
+ },
114
+ }
115
+
116
+ describe('EphemeralReport', () => {
117
+ // spec.owner is the documented field and is useless; the owner reference is
118
+ // where the subject actually is.
119
+ it('takes the subject from the owner reference when spec.owner is blank', () => {
120
+ const s = getKyvernoReportSubject(REPORT)
121
+ expect(s?.kind).toBe('Pod')
122
+ expect(s?.name).toBe('coredns-7d764666f9-dl4p6')
123
+ expect(s?.namespace).toBe('kube-system')
124
+ })
125
+
126
+ it('falls back to the labels when the subject is already gone', () => {
127
+ const { ownerReferences, ...meta } = REPORT.metadata as any
128
+ const s = getKyvernoReportSubject({ ...REPORT, metadata: meta })
129
+ expect(s?.kind).toBe('Pod')
130
+ expect(s?.name).toBeUndefined()
131
+ expect(s?.apiVersion).toBe('v1')
132
+ })
133
+
134
+ it('prefers spec.owner when a future version starts filling it in', () => {
135
+ const s = getKyvernoReportSubject({
136
+ ...REPORT,
137
+ spec: { ...REPORT.spec, owner: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'web', uid: 'x' } },
138
+ })
139
+ expect(s?.kind).toBe('Deployment')
140
+ })
141
+
142
+ // The findings live in spec, not status, which is the reverse of every other
143
+ // report-shaped object here.
144
+ it('reads results and summary out of spec', () => {
145
+ expect(getKyvernoReportResults(REPORT)).toHaveLength(1)
146
+ expect(getKyvernoReportResults({ status: { results: [{}] } })).toEqual([])
147
+ const s = getKyvernoReportSummary(REPORT)
148
+ expect(s).toMatchObject({ fail: 1, pass: 2, total: 3 })
149
+ })
150
+
151
+ it('reports the worst outcome for the list badge', () => {
152
+ expect(getKyvernoReportStatus(REPORT).level).toBe('unhealthy')
153
+ expect(getKyvernoReportStatus({ spec: { summary: { pass: 4 } } }).text).toBe('4 pass')
154
+ expect(getKyvernoReportStatus({ spec: {} }).text).toBe('No results')
155
+ })
156
+
157
+ // `new Date({seconds, nanos})` is Invalid Date, not an error.
158
+ it('converts the seconds/nanos timestamp rather than passing it to Date', () => {
159
+ expect(kyvernoReportTimestamp({ seconds: 1786622539, nanos: 0 })).toBe('2026-08-13T12:02:19.000Z')
160
+ expect(kyvernoReportTimestamp({ seconds: 0 })).toBeUndefined()
161
+ expect(kyvernoReportTimestamp(undefined)).toBeUndefined()
162
+ expect(kyvernoReportTimestamp('2026-08-13T09:22:19Z')).toBe('2026-08-13T09:22:19Z')
163
+ })
164
+ })