@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
@@ -12,9 +12,10 @@ import { getArgoApplicationStatus, getArgoApplicationSetStatus, getArgoApplicati
12
12
  import { getPolicyReportStatus as _getPolicyReportStatus, getKyvernoPolicyStatus as _getKyvernoPolicyStatus } from './resource-utils-kyverno'
13
13
  import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDeviceClasses as _getResourceClaimDeviceClasses, getResourceClaimTemplateDeviceClasses as _getResourceClaimTemplateDeviceClasses, getResourceClaimAllocation as _getResourceClaimAllocation, getResourceClaimReservedFor as _getResourceClaimReservedFor } from './resource-utils-dra'
14
14
  import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
15
- import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus } from './resource-utils-velero'
15
+ import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus, getBackupRepositoryStatus as _getBackupRepositoryStatus } from './resource-utils-velero'
16
16
  import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
17
17
  import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
18
+ import { getCNPGClusterStatus as _getCNPGClusterStatus, getCNPGBackupStatus as _getCNPGBackupStatus, getCNPGScheduledBackupStatus as _getCNPGScheduledBackupStatus, getCNPGPoolerStatus as _getCNPGPoolerStatus, isApiGroup as _isApiGroup, CNPG_GROUP as _CNPG_GROUP } from './resource-utils-cnpg'
18
19
 
19
20
  // ============================================================================
20
21
  // STATUS & HEALTH UTILITIES
@@ -291,6 +292,14 @@ export function getPodStatus(pod: any): StatusBadge {
291
292
  if (unsettled > 0) {
292
293
  return { text: `Running (${ready}/${total})`, color: healthColors.degraded, level: 'degraded' }
293
294
  }
295
+ // Batch work in flight is not an all-clear. The Job itself already grades
296
+ // neutral while running; without the same call here its pods would read
297
+ // healthy — one piece of work graded two ways. Last, after every failure
298
+ // check above, so a broken Job pod keeps its real verdict.
299
+ // Mirrors pkg/health.classifyPodLevel.
300
+ if (isOwnedByJob(pod)) {
301
+ return { text: 'Running', color: healthColors.neutral, level: 'neutral' }
302
+ }
294
303
  return { text: 'Running', color: healthColors.healthy, level: 'healthy' }
295
304
  }
296
305
  case 'Succeeded':
@@ -741,6 +750,30 @@ export function getContainerSquareStates(pod: any): ContainerSquareState[] {
741
750
  // WORKLOAD UTILITIES (Deployment, StatefulSet, DaemonSet, ReplicaSet)
742
751
  // ============================================================================
743
752
 
753
+ /**
754
+ * Whether the controller has not yet acted on this workload's current spec, so
755
+ * grading against that spec would report controller lag as an outage. The
756
+ * scale-up case: spec says 10, the controller is still working from the previous
757
+ * generation and has created 3.
758
+ *
759
+ * Deliberately NOT age-based — "created recently" guesses wrong exactly when a
760
+ * deploy is broken on arrival. Mirrors pkg/health.converging; keep in step.
761
+ */
762
+ function isConverging(resource: any): boolean {
763
+ const generation = resource?.metadata?.generation
764
+ const observed = resource?.status?.observedGeneration
765
+ return typeof generation === 'number' && typeof observed === 'number' && observed > 0 && observed < generation
766
+ }
767
+
768
+ /** Whether the pod was created by a batch Job. The owner's API group is checked
769
+ * so a CRD that merely uses Kind "Job" doesn't match. Mirrors
770
+ * pkg/health.isOwnedByJob. */
771
+ function isOwnedByJob(pod: any): boolean {
772
+ const refs = pod?.metadata?.ownerReferences
773
+ if (!Array.isArray(refs)) return false
774
+ return refs.some((r: any) => r?.kind === 'Job' && typeof r?.apiVersion === 'string' && r.apiVersion.startsWith('batch/'))
775
+ }
776
+
744
777
  export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
745
778
  const status = resource.status || {}
746
779
  const spec = resource.spec || {}
@@ -756,6 +789,12 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
756
789
  if (ready === desired && updated === desired) {
757
790
  return { text: `${ready}/${desired}`, color: healthColors.healthy, level: 'healthy' }
758
791
  }
792
+ // Convergence grace, same as the replica kinds below. pkg/health.Workload
793
+ // passes this signal for DaemonSets too, so omitting it here would render a
794
+ // mid-reconcile DaemonSet unhealthy while the backend calls it neutral.
795
+ if (isConverging(resource)) {
796
+ return { text: `${ready}/${desired}`, color: healthColors.neutral, level: 'neutral' }
797
+ }
759
798
  if (ready > 0) {
760
799
  return { text: `${ready}/${desired}`, color: healthColors.degraded, level: 'degraded' }
761
800
  }
@@ -772,6 +811,16 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
772
811
  return { text: 'Scaled to 0', color: healthColors.neutral, level: 'neutral' }
773
812
  }
774
813
 
814
+ // Convergence grace — mirrors pkg/health.replicaVerdict. A workload whose
815
+ // spec just changed (or that was created moments ago) has not had a chance to
816
+ // reach its target, so grading against that target reports controller lag as
817
+ // an outage. Neutral, never healthy: declining to call it broken is not the
818
+ // same as calling it fine. Guarded on "not already fully ready" so a
819
+ // converged workload still takes the healthy path below, matching Go's order.
820
+ if (isConverging(resource) && !(ready === desired && available === desired)) {
821
+ return { text: `${ready}/${desired}`, color: healthColors.neutral, level: 'neutral' }
822
+ }
823
+
775
824
  // Check if updating
776
825
  if (updated < desired && updated > 0) {
777
826
  return { text: `Updating ${updated}/${desired}`, color: healthColors.degraded, level: 'degraded' }
@@ -2091,13 +2140,31 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2091
2140
  if (kindLower === 'nvidiadrivers') return _getNvidiaDriverStatus(resource).text
2092
2141
  if (kindLower === 'resourceclaims') return _getResourceClaimStatus(resource).text
2093
2142
  if (kindLower === 'backups') return _getBackupStatus(resource).text
2094
- if (kindLower === 'restores') return _getRestoreStatus(resource).text
2095
- if (kindLower === 'schedules') return _getScheduleStatus(resource).text
2143
+ // Velero's Restore/Schedule keys are group-qualified (see
2144
+ // GROUP_QUALIFIED_COLUMN_KEYS) because those plurals are shared with
2145
+ // rancher/backup-restore-operator and others. The unqualified plural
2146
+ // deliberately does not match: a foreign CRD falls through to the generic
2147
+ // reader below rather than being filtered as though it were Velero.
2148
+ if (kindLower === 'velerorestores') return _getRestoreStatus(resource).text
2149
+ if (kindLower === 'veleroschedules') return _getScheduleStatus(resource).text
2096
2150
  if (kindLower === 'backupstoragelocations') return _getBSLStatus(resource).text
2151
+ if (kindLower === 'backuprepositories') return _getBackupRepositoryStatus(resource).text
2097
2152
  if (kindLower === 'externalsecrets') return _getExternalSecretStatus(resource).text
2098
2153
  if (kindLower === 'clusterexternalsecrets') return _getClusterExternalSecretStatus(resource).text
2099
2154
  if (kindLower === 'secretstores') return _getSecretStoreStatus(resource).text
2100
2155
  if (kindLower === 'clustersecretstores') return _getClusterSecretStoreStatus(resource).text
2156
+ // CNPG. The filter must read the same text the cell renders, or the
2157
+ // dropdown offers strings that appear on no row: CNPG's phase is prose
2158
+ // ("Cluster in healthy state") while the badge is a short state
2159
+ // ("Healthy"). Worse for `cnpgclusters` — a WAL-archiving failure leaves
2160
+ // the phase healthy, so filtering on the raw phase cannot express the
2161
+ // badge at all. `cnpgclusters`/`cnpgbackups` arrive group-qualified;
2162
+ // `scheduledbackups`/`poolers` are bare plurals, so gate those on the
2163
+ // group rather than assume nobody else ships the name.
2164
+ if (kindLower === 'cnpgclusters') return _getCNPGClusterStatus(resource).text
2165
+ if (kindLower === 'cnpgbackups') return _getCNPGBackupStatus(resource).text
2166
+ if (kindLower === 'scheduledbackups' && _isApiGroup(resource.apiVersion, _CNPG_GROUP)) return _getCNPGScheduledBackupStatus(resource).text
2167
+ if (kindLower === 'poolers' && _isApiGroup(resource.apiVersion, _CNPG_GROUP)) return _getCNPGPoolerStatus(resource).text
2101
2168
  // Generic CRDs: try status.phase, then Ready condition
2102
2169
  if (resource.status?.phase) return resource.status.phase
2103
2170
  {
@@ -70,6 +70,7 @@ interface ResourceActionsBarProps {
70
70
  isDeleting?: boolean
71
71
  cascadeDependents?: CascadeDependent[]
72
72
  cascadeLoading?: boolean
73
+ cascadeRootResolved?: boolean
73
74
 
74
75
  // Workload restart
75
76
  onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
@@ -130,7 +131,7 @@ export function ResourceActionsBar({
130
131
  canExec, canViewLogs, canPortForward,
131
132
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
132
133
  renderPortForward,
133
- onDelete, isDeleting, cascadeDependents, cascadeLoading,
134
+ onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved,
134
135
  onRestart, isRestarting,
135
136
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
136
137
  onTriggerCronJob, isTriggeringCronJob,
@@ -608,6 +609,7 @@ export function ResourceActionsBar({
608
609
  isLoading={isDeleting ?? false}
609
610
  cascadeDependents={cascadeDependents}
610
611
  cascadeLoading={cascadeLoading}
612
+ cascadeRootResolved={cascadeRootResolved}
611
613
  />
612
614
 
613
615
  {/* Node cordon confirmation */}
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { ResourceRendererDispatch, type RendererOverrides } from './ResourceRendererDispatch'
3
+ import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
4
4
  import type { ResourceRef } from '../../types'
5
5
 
6
6
  function renderWithScalers(scalers: ResourceRef[]): string {
@@ -146,3 +146,121 @@ describe('DRA renderers dispatch', () => {
146
146
  expect(html).toContain('device.driver')
147
147
  })
148
148
  })
149
+
150
+ // ============================================================================
151
+ // COLLIDING CRD PLURALS — `clusters` (CNPG / CAPI / third parties) and
152
+ // `backups` (CNPG / Velero). Both used to be resolved with a negative guard,
153
+ // so any third operator's CRD inherited whichever branch was the fallback.
154
+ // ============================================================================
155
+
156
+ function renderCollidingKind(kind: string, apiVersion: string): string {
157
+ return renderToString(
158
+ <ResourceRendererDispatch
159
+ resource={{ kind, namespace: 'default', name: 'thing' }}
160
+ data={{ apiVersion, kind: 'Cluster', metadata: { name: 'thing', namespace: 'default' }, spec: {}, status: {} }}
161
+ onCopy={() => {}}
162
+ copied={null}
163
+ showCommonSections={false}
164
+ />,
165
+ )
166
+ }
167
+
168
+ describe('getResourceStatus — colliding plurals', () => {
169
+ it('fabricates no PostgreSQL status for a third-party clusters CRD', () => {
170
+ // KubeBlocks, Redis/Valkey operators and friends all ship `clusters`. With
171
+ // no status of their own they get nothing; with a phase they get the
172
+ // generic phase badge, never a CNPG-derived one.
173
+ expect(getResourceStatus('clusters', { apiVersion: 'apps.kubeblocks.io/v1alpha1', status: {} })).toBeNull()
174
+ expect(getResourceStatus('clusters', { apiVersion: 'apps.kubeblocks.io/v1alpha1', status: { phase: 'Running' } }))
175
+ .toMatchObject({ text: 'Running' })
176
+ // CNPG would have rendered "Not Ready" from the absent instance counts.
177
+ expect(getResourceStatus('clusters', { apiVersion: 'redis.redis.opstreelabs.in/v1beta2', spec: { instances: 3 }, status: {} })).toBeNull()
178
+ })
179
+
180
+ it('still resolves both known clusters engines positively', () => {
181
+ expect(getResourceStatus('clusters', {
182
+ apiVersion: 'postgresql.cnpg.io/v1',
183
+ spec: { instances: 2 },
184
+ status: { phase: 'Cluster in healthy state', readyInstances: 2 },
185
+ })).toMatchObject({ text: 'Healthy' })
186
+
187
+ expect(getResourceStatus('clusters', {
188
+ apiVersion: 'cluster.x-k8s.io/v1beta1',
189
+ status: { phase: 'Provisioned' },
190
+ })).not.toBeNull()
191
+ })
192
+
193
+ it('fabricates no engine status for a third-party backups CRD', () => {
194
+ expect(getResourceStatus('backups', { apiVersion: 'kubevirt.io/v1', status: {} })).toBeNull()
195
+ expect(getResourceStatus('backups', { apiVersion: 'kubevirt.io/v1', status: { phase: 'Running' } }))
196
+ .toMatchObject({ text: 'Running' })
197
+ })
198
+
199
+ it('still resolves both known backups engines positively', () => {
200
+ expect(getResourceStatus('backups', {
201
+ apiVersion: 'postgresql.cnpg.io/v1',
202
+ status: { phase: 'completed' },
203
+ })).toMatchObject({ text: 'Completed' })
204
+
205
+ expect(getResourceStatus('backups', {
206
+ apiVersion: 'velero.io/v1',
207
+ status: { phase: 'Completed' },
208
+ })).not.toBeNull()
209
+ })
210
+
211
+ it('gives no CNPG badge to third-party scheduledbackups and poolers', () => {
212
+ // CNPG's Pooler getter would have said "Not Scheduled" from spec.instances.
213
+ expect(getResourceStatus('poolers', { apiVersion: 'other.io/v1', spec: { instances: 2 }, status: {} })).toBeNull()
214
+ expect(getResourceStatus('scheduledbackups', { apiVersion: 'other.io/v1', spec: {}, status: {} })).toBeNull()
215
+ })
216
+ })
217
+
218
+ describe('ResourceRendererDispatch — colliding plurals fall through', () => {
219
+ // These plurals are in KNOWN_KINDS, which suppresses the generic renderer.
220
+ // Making every render line apiVersion-gated without an explicit fall-through
221
+ // renders a blank drawer for a foreign CRD — the trap the Crossplane
222
+ // collision block documents.
223
+ it.each([
224
+ ['clusters', 'apps.kubeblocks.io/v1alpha1'],
225
+ ['backups', 'kubevirt.io/v1'],
226
+ ['scheduledbackups', 'other.io/v1'],
227
+ ['poolers', 'other.io/v1'],
228
+ ])('renders something for a foreign %s CRD', (kind, apiVersion) => {
229
+ expect(renderCollidingKind(kind, apiVersion).trim()).not.toBe('')
230
+ })
231
+
232
+ it('does not double-render when a known engine matches', () => {
233
+ const html = renderCollidingKind('clusters', 'postgresql.cnpg.io/v1')
234
+ expect(html).toContain('Cluster Overview')
235
+ // The generic renderer's raw-spec dump must not appear alongside it.
236
+ expect(html.match(/Cluster Overview/g)).toHaveLength(1)
237
+ })
238
+ })
239
+
240
+ describe('colliding plurals — near-match API groups', () => {
241
+ // A substring guard would hand these to CNPG/Velero. They are different groups.
242
+ it.each([
243
+ ['clusters', 'extension.postgresql.cnpg.io/v1'],
244
+ ['clusters', 'barmancloud.cnpg.io/v1'],
245
+ ['backups', 'extension.postgresql.cnpg.io/v1'],
246
+ ['backups', 'backup.velero.io/v1'],
247
+ ['poolers', 'sub.postgresql.cnpg.io/v1'],
248
+ ['scheduledbackups', 'sub.postgresql.cnpg.io/v1'],
249
+ ])('does not give %s/%s an engine-specific status', (kind, apiVersion) => {
250
+ const s = getResourceStatus(kind, { apiVersion, spec: { instances: 2 }, status: { phase: 'completed' } })
251
+ // The generic fallback echoes the phase verbatim; the CNPG/Velero getters
252
+ // would have mapped it to "Completed" or read the instance counts.
253
+ expect(s?.text).not.toBe('Completed')
254
+ expect(s?.text).not.toBe('Not Scheduled')
255
+ expect(s?.text).not.toBe('Not Ready')
256
+ })
257
+
258
+ it.each([
259
+ ['clusters', 'extension.postgresql.cnpg.io/v1'],
260
+ ['backups', 'backup.velero.io/v1'],
261
+ ])('falls %s/%s through to the generic renderer', (kind, apiVersion) => {
262
+ const html = renderCollidingKind(kind, apiVersion)
263
+ expect(html.trim()).not.toBe('')
264
+ expect(html).not.toContain('Cluster Overview')
265
+ })
266
+ })
@@ -51,9 +51,19 @@ import { getNodePoolStatus, getNodeClaimStatus, getEC2NodeClassStatus } from '..
51
51
  import { getScaledObjectStatus, getScaledJobStatus } from '../resources/resource-utils-keda'
52
52
  import { getServiceMonitorStatus, getPrometheusRuleStatus, getPodMonitorStatus } from '../resources/resource-utils-prometheus'
53
53
  import { getPolicyReportStatus, getKyvernoPolicyStatus } from '../resources/resource-utils-kyverno'
54
+ import {
55
+ KYVERNO_MODERN_PLURALS,
56
+ getModernKyvernoPolicyStatus,
57
+ isModernKyvernoPolicy,
58
+ } from '../resources/resource-utils-kyverno-modern'
59
+ import {
60
+ getKyvernoCleanupPolicyStatus,
61
+ getKyvernoPolicyExceptionStatus,
62
+ isAnyKyvernoPolicyException,
63
+ } from '../resources/resource-utils-kyverno-exceptions'
54
64
  import { getResourceClaimStatus, getResourceClaimTemplateStatus, getDeviceClassStatus, getResourceSliceStatus } from '../resources/resource-utils-dra'
55
65
  import { getNvidiaClusterPolicyStatus, getNvidiaDriverStatus } from '../resources/resource-utils-nvidia'
56
- import { getBackupStatus, getRestoreStatus, getScheduleStatus, getBSLStatus } from '../resources/resource-utils-velero'
66
+ import { getBackupStatus, getRestoreStatus, getScheduleStatus, getBSLStatus, isVeleroResource } from '../resources/resource-utils-velero'
57
67
  import {
58
68
  getVirtualServiceStatus,
59
69
  getDestinationRuleStatus,
@@ -62,7 +72,7 @@ import {
62
72
  getPeerAuthenticationStatus,
63
73
  getAuthorizationPolicyStatus,
64
74
  } from '../resources/resource-utils-istio'
65
- import { getCNPGClusterStatus, getCNPGBackupStatus, getCNPGScheduledBackupStatus, getCNPGPoolerStatus } from '../resources/resource-utils-cnpg'
75
+ import { getCNPGClusterStatus, getCNPGBackupStatus, getCNPGScheduledBackupStatus, getCNPGPoolerStatus, isApiGroup, CNPG_GROUP } from '../resources/resource-utils-cnpg'
66
76
  import { getExternalSecretStatus, getClusterExternalSecretStatus, getSecretStoreStatus, getClusterSecretStoreStatus } from '../resources/resource-utils-eso'
67
77
  import {
68
78
  getKnativeConditionStatus,
@@ -140,6 +150,13 @@ import {
140
150
  PodMonitorRenderer,
141
151
  PolicyReportRenderer,
142
152
  KyvernoPolicyRenderer,
153
+ KyvernoValidatingPolicyRenderer,
154
+ KyvernoImageValidatingPolicyRenderer,
155
+ KyvernoMutatingPolicyRenderer,
156
+ KyvernoGeneratingPolicyRenderer,
157
+ KyvernoDeletingPolicyRenderer,
158
+ KyvernoPolicyExceptionRenderer,
159
+ KyvernoCleanupPolicyRenderer,
143
160
  VeleroBackupRenderer,
144
161
  VeleroRestoreRenderer,
145
162
  VeleroScheduleRenderer,
@@ -339,6 +356,13 @@ const KNOWN_KINDS = new Set([
339
356
  'triggerauthentications', 'clustertriggerauthentications',
340
357
  'servicemonitors', 'prometheusrules', 'podmonitors',
341
358
  'policyreports', 'clusterpolicyreports', 'kyvernopolicies', 'clusterpolicies',
359
+ // Kyverno modern CEL family (policies.kyverno.io) + its namespaced twins.
360
+ 'validatingpolicies', 'namespacedvalidatingpolicies',
361
+ 'imagevalidatingpolicies', 'namespacedimagevalidatingpolicies',
362
+ 'mutatingpolicies', 'namespacedmutatingpolicies',
363
+ 'generatingpolicies', 'namespacedgeneratingpolicies',
364
+ 'deletingpolicies', 'namespaceddeletingpolicies',
365
+ 'policyexceptions', 'cleanuppolicies', 'clustercleanuppolicies',
342
366
  'resourceclaims', 'resourceclaimtemplates', 'deviceclasses', 'resourceslices',
343
367
  'nvidiadrivers',
344
368
  'vulnerabilityreports', 'configauditreports', 'exposedsecretreports',
@@ -488,6 +512,54 @@ export function ResourceRendererDispatch({
488
512
  )
489
513
  const crossplaneCollisionFallthrough = isCollisionGatedKind && !crossplaneApiVersionMatched
490
514
 
515
+ // Kyverno's modern CEL family uses plurals generic enough that another
516
+ // vendor could ship the same ones (`validatingpolicies`, `mutatingpolicies`,
517
+ // `generatingpolicies`, ...), and `policyexceptions` collides with Kyverno
518
+ // ITSELF — the same Kind and plural exists in both kyverno.io and
519
+ // policies.kyverno.io with different spec shapes. Every render line below is
520
+ // therefore group-gated, which means each needs the same fall-through the
521
+ // Crossplane block documents above: without it a foreign CR with a colliding
522
+ // plural matches no renderer and, being in KNOWN_KINDS, renders blank.
523
+ const isKyvernoModernPlural = KYVERNO_MODERN_PLURALS.has(kind)
524
+ const kyvernoModernMatched = isKyvernoModernPlural && isModernKyvernoPolicy(data)
525
+ const isKyvernoLegacyExtraPlural = kind === 'cleanuppolicies' || kind === 'clustercleanuppolicies'
526
+ const kyvernoLegacyExtraMatched =
527
+ isKyvernoLegacyExtraPlural && data?.apiVersion?.startsWith('kyverno.io/')
528
+ // PolicyException is served by both families; either group is a match.
529
+ const isPolicyExceptionPlural = kind === 'policyexceptions'
530
+ const policyExceptionMatched = isPolicyExceptionPlural && isAnyKyvernoPolicyException(data)
531
+ const kyvernoCollisionFallthrough =
532
+ (isKyvernoModernPlural && !kyvernoModernMatched) ||
533
+ (isKyvernoLegacyExtraPlural && !kyvernoLegacyExtraMatched) ||
534
+ (isPolicyExceptionPlural && !policyExceptionMatched)
535
+
536
+ // Same shape as the Crossplane fall-through above: these Velero plurals are
537
+ // in KNOWN_KINDS but their render lines are gated on the velero.io group, so
538
+ // a foreign CR with the same plural (rancher/backup-restore-operator ships
539
+ // restores.resources.cattle.io; several operators ship `schedules`) matches
540
+ // no renderer AND is suppressed from GenericRenderer — i.e. renders blank.
541
+ // `backups` is deliberately absent here — it is shared with CNPG, so both of
542
+ // its render lines are positively group-gated and it falls through via
543
+ // isGroupGatedKind below rather than through this Velero-only check.
544
+ const isVeleroCollisionGatedKind =
545
+ kind === 'restores' || kind === 'schedules'
546
+ || kind === 'backupstoragelocations' || kind === 'volumesnapshotlocations'
547
+ const veleroCollisionFallthrough = isVeleroCollisionGatedKind && !isVeleroResource(data)
548
+
549
+ // Same rule as the Crossplane block above, for the plurals shared by two
550
+ // named operators: `clusters` (CNPG / CAPI — plus KubeBlocks, Redis/Valkey
551
+ // and friends in the wild) and `backups` (CNPG / Velero). Both render lines
552
+ // are positively apiVersion-gated, so a third CRD with the plural matches
553
+ // neither and needs an explicit fall-through or the drawer renders blank.
554
+ const isGroupGatedKind =
555
+ kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
556
+ const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
557
+ const groupGatedMatched =
558
+ (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
559
+ || (kind === 'backups' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'velero.io')))
560
+ || ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
561
+ const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
562
+
491
563
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
492
564
 
493
565
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
@@ -594,25 +666,34 @@ export function ResourceRendererDispatch({
594
666
  {(kind === 'policyreports' || kind === 'clusterpolicyreports') && <PolicyReportRenderer data={data} />}
595
667
  {(kind === 'kyvernopolicies' || (kind === 'clusterpolicies' && !data?.apiVersion?.startsWith('nvidia.com/'))) && <KyvernoPolicyRenderer data={data} />}
596
668
  {(kind === 'clusterpolicies' && data?.apiVersion?.startsWith('nvidia.com/')) && <NvidiaClusterPolicyRenderer data={data} />}
669
+ {/* Kyverno modern CEL family. Each Namespaced* twin shares its
670
+ cluster-scoped counterpart's renderer — same spec, narrower scope. */}
671
+ {kyvernoModernMatched && (kind === 'validatingpolicies' || kind === 'namespacedvalidatingpolicies') && <KyvernoValidatingPolicyRenderer data={data} />}
672
+ {kyvernoModernMatched && (kind === 'imagevalidatingpolicies' || kind === 'namespacedimagevalidatingpolicies') && <KyvernoImageValidatingPolicyRenderer data={data} />}
673
+ {kyvernoModernMatched && (kind === 'mutatingpolicies' || kind === 'namespacedmutatingpolicies') && <KyvernoMutatingPolicyRenderer data={data} />}
674
+ {kyvernoModernMatched && (kind === 'generatingpolicies' || kind === 'namespacedgeneratingpolicies') && <KyvernoGeneratingPolicyRenderer data={data} />}
675
+ {kyvernoModernMatched && (kind === 'deletingpolicies' || kind === 'namespaceddeletingpolicies') && <KyvernoDeletingPolicyRenderer data={data} />}
676
+ {policyExceptionMatched && <KyvernoPolicyExceptionRenderer data={data} />}
677
+ {kyvernoLegacyExtraMatched && <KyvernoCleanupPolicyRenderer data={data} />}
597
678
  {kind === 'nvidiadrivers' && <NvidiaDriverRenderer data={data} />}
598
679
  {/* DRA (resource.k8s.io) */}
599
680
  {kind === 'resourceclaims' && <ResourceClaimRenderer data={data} onNavigate={onNavigate} />}
600
681
  {kind === 'resourceclaimtemplates' && <ResourceClaimTemplateRenderer data={data} />}
601
682
  {kind === 'deviceclasses' && <DeviceClassRenderer data={data} />}
602
683
  {kind === 'resourceslices' && <ResourceSliceRenderer data={data} onNavigate={onNavigate} />}
603
- {kind === 'backups' && data.apiVersion?.includes('cnpg.io') && <CNPGBackupRenderer data={data} onNavigate={onNavigate} />}
604
- {kind === 'backups' && !data.apiVersion?.includes('cnpg.io') && <VeleroBackupRenderer data={data} />}
605
- {kind === 'restores' && <VeleroRestoreRenderer data={data} />}
606
- {kind === 'schedules' && <VeleroScheduleRenderer data={data} />}
607
- {kind === 'backupstoragelocations' && <VeleroBSLRenderer data={data} />}
608
- {kind === 'volumesnapshotlocations' && <VeleroVSLRenderer data={data} />}
684
+ {kind === 'backups' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGBackupRenderer data={data} onNavigate={onNavigate} />}
685
+ {kind === 'backups' && isApiGroup(data.apiVersion, 'velero.io') && <VeleroBackupRenderer data={data} />}
686
+ {kind === 'restores' && isVeleroResource(data) && <VeleroRestoreRenderer data={data} />}
687
+ {kind === 'schedules' && isVeleroResource(data) && <VeleroScheduleRenderer data={data} />}
688
+ {kind === 'backupstoragelocations' && isVeleroResource(data) && <VeleroBSLRenderer data={data} />}
689
+ {kind === 'volumesnapshotlocations' && isVeleroResource(data) && <VeleroVSLRenderer data={data} />}
609
690
  {kind === 'externalsecrets' && <ExternalSecretRenderer data={data} onNavigate={onNavigate} />}
610
691
  {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} onNavigate={onNavigate} />}
611
692
  {(kind === 'secretstores' || kind === 'clustersecretstores') && <SecretStoreRenderer data={data} />}
612
- {kind === 'clusters' && !data?.apiVersion?.includes('cluster.x-k8s.io') && <CNPGClusterRenderer data={data} onNavigate={onNavigate} />}
613
- {kind === 'clusters' && data?.apiVersion?.includes('cluster.x-k8s.io') && <CAPIClusterRenderer data={data} onNavigate={onNavigate} />}
614
- {kind === 'scheduledbackups' && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
615
- {kind === 'poolers' && <CNPGPoolerRenderer data={data} onNavigate={onNavigate} />}
693
+ {kind === 'clusters' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGClusterRenderer data={data} onNavigate={onNavigate} />}
694
+ {kind === 'clusters' && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io') && <CAPIClusterRenderer data={data} onNavigate={onNavigate} />}
695
+ {kind === 'scheduledbackups' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
696
+ {kind === 'poolers' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGPoolerRenderer data={data} onNavigate={onNavigate} />}
616
697
  {/* Cluster API (CAPI) */}
617
698
  {'topology.cluster.x-k8s.io/owned' in (data?.metadata?.labels ?? {}) && data?.apiVersion?.includes('cluster.x-k8s.io') && (
618
699
  <AlertBanner
@@ -706,7 +787,7 @@ export function ResourceRendererDispatch({
706
787
  for known-plural collisions where no apiVersion-gated renderer
707
788
  matched (e.g. a Knative Configuration sharing the `configurations`
708
789
  plural with Crossplane Configuration). */}
709
- {(!isKnownKind || crossplaneCollisionFallthrough) && <GenericRenderer data={data} />}
790
+ {(!isKnownKind || crossplaneCollisionFallthrough || kyvernoCollisionFallthrough || veleroCollisionFallthrough || groupGatedFallthrough) && <GenericRenderer data={data} />}
710
791
 
711
792
  {/* Common sections - can be disabled when parent handles them separately */}
712
793
  {showCommonSections && (
@@ -833,25 +914,38 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
833
914
  if (data?.apiVersion?.startsWith('nvidia.com/')) return getNvidiaClusterPolicyStatus(data)
834
915
  return getKyvernoPolicyStatus(data)
835
916
  }
917
+ // Modern CEL family — group-gated so a foreign CRD sharing one of these
918
+ // generic plurals falls through to the default status rather than being
919
+ // described in Kyverno's vocabulary.
920
+ if (KYVERNO_MODERN_PLURALS.has(k) && isModernKyvernoPolicy(data)) return getModernKyvernoPolicyStatus(data)
921
+ if (k === 'policyexceptions' && isAnyKyvernoPolicyException(data)) return getKyvernoPolicyExceptionStatus(data)
922
+ if ((k === 'cleanuppolicies' || k === 'clustercleanuppolicies') && data?.apiVersion?.startsWith('kyverno.io/')) {
923
+ return getKyvernoCleanupPolicyStatus(data)
924
+ }
836
925
  if (k === 'nvidiadrivers') return getNvidiaDriverStatus(data)
837
926
  if (k === 'resourceclaims') return getResourceClaimStatus(data)
838
927
  if (k === 'resourceclaimtemplates') return getResourceClaimTemplateStatus(data)
839
928
  if (k === 'deviceclasses') return getDeviceClassStatus(data)
840
929
  if (k === 'resourceslices') return getResourceSliceStatus(data)
930
+ // Positive guards both ways. A third `backups` CRD matches neither and falls
931
+ // through to the generic phase/conditions handling at the end of this
932
+ // function, rather than borrowing whichever engine used to be the fallback.
841
933
  if (k === 'backups') {
842
- if (data.apiVersion?.includes('cnpg.io')) return getCNPGBackupStatus(data)
843
- return getBackupStatus(data)
934
+ if (isApiGroup(data.apiVersion, CNPG_GROUP)) return getCNPGBackupStatus(data)
935
+ if (isApiGroup(data.apiVersion, 'velero.io')) return getBackupStatus(data)
844
936
  }
845
- if (k === 'restores') return getRestoreStatus(data)
846
- if (k === 'schedules') return getScheduleStatus(data)
847
- if (k === 'backupstoragelocations') return getBSLStatus(data)
937
+ if (k === 'restores' && isVeleroResource(data)) return getRestoreStatus(data)
938
+ if (k === 'schedules' && isVeleroResource(data)) return getScheduleStatus(data)
939
+ if (k === 'backupstoragelocations' && isVeleroResource(data)) return getBSLStatus(data)
848
940
  if (k === 'externalsecrets') return getExternalSecretStatus(data)
849
941
  if (k === 'clusterexternalsecrets') return getClusterExternalSecretStatus(data)
850
942
  if (k === 'secretstores') return getSecretStoreStatus(data)
851
943
  if (k === 'clustersecretstores') return getClusterSecretStoreStatus(data)
852
944
  if (k === 'clusters') {
853
- if (data.apiVersion?.includes('cluster.x-k8s.io')) return getCAPIClusterStatus(data)
854
- return getCNPGClusterStatus(data)
945
+ if (isApiGroup(data.apiVersion, 'cluster.x-k8s.io')) return getCAPIClusterStatus(data)
946
+ if (isApiGroup(data.apiVersion, CNPG_GROUP)) return getCNPGClusterStatus(data)
947
+ // Third-party `clusters` CRDs (KubeBlocks, Redis/Valkey) fall through to the
948
+ // generic handling below instead of getting a fabricated PostgreSQL status.
855
949
  }
856
950
  if (k === 'machines' && data.apiVersion?.includes('cluster.x-k8s.io')) return getMachineStatus(data)
857
951
  if (k === 'machinedeployments') return getMachineDeploymentStatus(data)
@@ -875,8 +969,8 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
875
969
  if (k === 'azuremanagedmachinepools') return getAzureMMPStatus(data)
876
970
  if (k === 'azuremachines') return getAzureMachineStatus(data)
877
971
  if (k === 'azuremanagedclusters') return getAzureManagedClusterStatus(data)
878
- if (k === 'scheduledbackups') return getCNPGScheduledBackupStatus(data)
879
- if (k === 'poolers') return getCNPGPoolerStatus(data)
972
+ if (k === 'scheduledbackups' && isApiGroup(data.apiVersion, CNPG_GROUP)) return getCNPGScheduledBackupStatus(data)
973
+ if (k === 'poolers' && isApiGroup(data.apiVersion, CNPG_GROUP)) return getCNPGPoolerStatus(data)
880
974
  if (k === 'virtualservices') return getVirtualServiceStatus(data)
881
975
  if (k === 'destinationrules') return getDestinationRuleStatus(data)
882
976
  if (k === 'serviceentries') return getServiceEntryStatus(data)
@@ -128,6 +128,12 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
128
128
  "A validating/mutating admission webhook rejected pod creation.",
129
129
  action: "Check the webhook policy that denied the request.",
130
130
  },
131
+ WebhookUnavailable: {
132
+ title: "Admission Webhook Unavailable",
133
+ description:
134
+ "Pod creation could not reach a required admission webhook backend.",
135
+ action: "Restore the webhook Service and its ready endpoints.",
136
+ },
131
137
  Evicted: {
132
138
  title: "Pod Evicted",
133
139
  description:
@@ -282,6 +288,11 @@ function getSubtitle(
282
288
  kind: NodeKind,
283
289
  nodeData: Record<string, unknown>,
284
290
  ): string {
291
+ // An explicit override wins over the per-kind computed subtitle: the Reachability
292
+ // diagram precomputes a functional one-liner (port→targetPort, readiness) the
293
+ // generic per-kind logic can't know. '' is a deliberate "no subtitle", honored as
294
+ // such. App-topology nodes never set this, so their behavior is unchanged.
295
+ if (typeof nodeData.subtitleOverride === 'string') return nodeData.subtitleOverride;
285
296
  const base = baseSubtitle(kind, nodeData);
286
297
  const ps = nodeData.podSummary as PodSummary | undefined;
287
298
  if (ps && SUMMARY_POD_KINDS.has(kind)) {