@skyhook-io/k8s-ui 1.14.9 → 1.14.11

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 (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. package/src/utils/topology-neighborhood.ts +40 -18
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getRayCronJobTimeZone, getRayCronJobStatus } from './resource-utils-ray'
2
+ import { getRayCronJobTimeZone, getRayCronJobStatus, getRayServiceStatus } from './resource-utils-ray'
3
3
  import { getCellFilterValue } from './resource-utils'
4
4
 
5
5
  describe('getRayCronJobTimeZone', () => {
@@ -34,3 +34,60 @@ describe('getCellFilterValue for raycronjobs timeZone', () => {
34
34
  .toBe('Europe/Berlin')
35
35
  })
36
36
  })
37
+
38
+
39
+ describe('RayService observed lifecycle', () => {
40
+ const data = (conditions: any[], spec = {}) => ({ metadata: { generation: 3 }, spec, status: { observedGeneration: 3, conditions } })
41
+ it('prioritizes observed rollback over upgrade without hiding readiness in the detail', () => {
42
+ expect(getRayServiceStatus(data(['Ready', 'UpgradeInProgress', 'RollbackInProgress'].map(type => ({ type, status: 'True' })))).text).toBe('RollingBack')
43
+ })
44
+ it('does not treat requested suspension as completed teardown', () => {
45
+ expect(getRayServiceStatus(data([], { suspend: true })).text).toBe('Suspension requested')
46
+ expect(getRayServiceStatus(data([{ type: 'Ready', status: 'True' }], { suspend: true })).text).toBe('Ready')
47
+ expect(getRayServiceStatus(data([{ type: 'Suspending', status: 'True' }], { suspend: false })).text).toBe('Suspending')
48
+ expect(getRayServiceStatus(data([{ type: 'Suspended', status: 'True' }])).text).toBe('Suspended')
49
+ })
50
+ it('retains stale and unknown observations without using deprecated Running as proof', () => {
51
+ expect(getRayServiceStatus(data([{ type: 'Ready', status: 'False', observedGeneration: 2 }])).text).toBe('NotReady')
52
+ for (const [status, label] of [['True', 'Ready'], ['False', 'NotReady'], ['Unknown', 'Unknown']]) {
53
+ const resource = data([{ type: 'Ready', status }])
54
+ resource.status.observedGeneration = 2
55
+ expect(getRayServiceStatus(resource).text).toBe(status === 'True' ? `${label} (stale)` : label)
56
+ }
57
+ for (const condition of [{ type: 'Suspending', status: 'True' }, { type: 'Ready', status: 'False', reason: 'ValidationFailed' }, { type: 'Ready', status: 'False', reason: 'InitializingTimeout' }]) {
58
+ const resource = data([condition], { suspend: false })
59
+ resource.status.observedGeneration = 2
60
+ expect(getRayServiceStatus(resource).text).toBe(condition.reason || condition.type)
61
+ }
62
+ const suspended = data([{ type: 'Suspended', status: 'True' }], { suspend: true })
63
+ suspended.status.observedGeneration = 2
64
+ expect(getRayServiceStatus(suspended).text).toBe('Suspended')
65
+ expect(getRayServiceStatus({ status: { serviceStatus: 'Running', conditions: [{ type: 'Ready', status: 'Unknown' }] } }).text).toBe('Unknown')
66
+ expect(getRayServiceStatus({ status: { serviceStatus: 'Running' } }).text).toBe('Unknown')
67
+ })
68
+ })
69
+
70
+ import { getRayClusterStatus, getRayClusterWorkers, rayClusterWorkerCounts } from './resource-utils-ray'
71
+
72
+ describe('RayCluster readiness', () => {
73
+ const root = (status: any = {}, spec = {}) => ({ metadata: { generation: 2 }, spec, status: { observedGeneration: 2, conditions: [{ type: 'HeadPodReady', status: 'True' }], ...status } })
74
+ it('distinguishes running from ready and does not bless unready excess workers', () => {
75
+ expect(getRayClusterStatus(root({ readyWorkerReplicas: 1, availableWorkerReplicas: 2, desiredWorkerReplicas: 2 })).level).toBe('degraded')
76
+ expect(getRayClusterStatus(root({ readyWorkerReplicas: 1, availableWorkerReplicas: 2, desiredWorkerReplicas: 1 })).level).toBe('degraded')
77
+ expect(getRayClusterStatus(root({ readyWorkerReplicas: 2, availableWorkerReplicas: 2, desiredWorkerReplicas: 2 })).text).toBe('Ready')
78
+ expect(getRayClusterWorkers(root({ readyWorkerReplicas: 1, availableWorkerReplicas: 2, desiredWorkerReplicas: 2 }))).toBe('1/2')
79
+ })
80
+ it('decodes native omitted zeros only after a witnessed reconciliation', () => {
81
+ expect(rayClusterWorkerCounts(root())).toEqual({ ready: 0, running: 0, desired: 0 })
82
+ expect(getRayClusterStatus(root()).text).toBe('Ready')
83
+ expect(getRayClusterWorkers({})).toBe('?/?')
84
+ expect(getRayClusterStatus(root({ observedGeneration: undefined })).text).toBe('Head ready')
85
+ })
86
+ it('separates suspension intent and historical observations from health', () => {
87
+ expect(getRayClusterStatus(root({}, { suspend: true })).text).toBe('Suspension requested')
88
+ expect(getRayClusterStatus(root({ conditions: [{ type: 'RayClusterSuspended', status: 'True' }] })).text).toBe('Suspended')
89
+ expect(getRayClusterStatus(root({ observedGeneration: 1 })).level).toBe('unknown')
90
+ expect(getRayClusterStatus(root({ state: 'ready', conditions: [] })).level).toBe('unknown')
91
+ expect(getRayClusterStatus(root({ conditions: [{ type: 'RayClusterProvisioned', status: 'True' }] })).level).toBe('neutral')
92
+ })
93
+ })
@@ -7,60 +7,35 @@ import { healthColors, formatAge } from './resource-utils'
7
7
  // RAYCLUSTER UTILITIES
8
8
  // ============================================================================
9
9
 
10
- export function getRayClusterStatus(resource: any): StatusBadge {
11
- const state = resource.status?.state
12
- const conditions = resource.status?.conditions || []
13
-
14
- const suspendingCond = conditions.find((c: any) => c.type === 'RayClusterSuspending')
15
- if (suspendingCond?.status === 'True') {
16
- return { text: 'Suspending', color: healthColors.degraded, level: 'degraded' }
17
- }
18
-
19
- const suspendedCond = conditions.find((c: any) => c.type === 'RayClusterSuspended')
20
- if (suspendedCond?.status === 'True') {
21
- return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
22
- }
23
-
24
- if (resource.spec?.suspend === true || state === 'suspended') {
25
- return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
26
- }
27
-
28
- const replicaFailureCond = conditions.find((c: any) => c.type === 'RayClusterReplicaFailure')
29
- if (replicaFailureCond?.status === 'True') {
30
- return { text: replicaFailureCond.reason || 'ReplicaFailure', color: healthColors.unhealthy, level: 'unhealthy' }
31
- }
32
-
33
- if (state === 'failed') {
34
- return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
35
- }
36
-
37
- const headReadyCond = conditions.find((c: any) => c.type === 'HeadPodReady')
38
- if (headReadyCond?.status === 'False') {
39
- return { text: headReadyCond.reason || 'HeadNotReady', color: healthColors.degraded, level: 'degraded' }
40
- }
41
- if (headReadyCond?.status === 'True') {
42
- const availableWorkers = resource.status?.availableWorkerReplicas ?? 0
43
- const desiredWorkers = resource.status?.desiredWorkerReplicas ?? 0
44
- if (availableWorkers >= desiredWorkers) {
45
- return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
46
- }
47
- return { text: `Workers ${availableWorkers}/${desiredWorkers}`, color: healthColors.degraded, level: 'degraded' }
48
- }
49
-
50
- if (conditions.length === 0 && state === 'ready') {
51
- return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
52
- }
53
-
54
- const provisionedCond = conditions.find((c: any) => c.type === 'RayClusterProvisioned')
55
- if (provisionedCond?.status === 'True') {
56
- return { text: 'Provisioned', color: healthColors.neutral, level: 'neutral' }
57
- }
58
-
59
- if (resource.status) {
60
- return { text: 'Provisioning', color: healthColors.degraded, level: 'degraded' }
61
- }
10
+ export function rayClusterWorkerCounts(resource: any): { ready?: number; running?: number; desired?: number } {
11
+ const status = resource.status ?? {}
12
+ // Native integer counters omit zero; observedGeneration witnesses their shared status write.
13
+ const count = (key: string) => status[key] ?? (status.observedGeneration > 0 ? 0 : undefined)
14
+ return { ready: count('readyWorkerReplicas'), running: count('availableWorkerReplicas'), desired: count('desiredWorkerReplicas') }
15
+ }
62
16
 
63
- return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
17
+ export function getRayClusterStatus(resource: any): StatusBadge {
18
+ const conditions = resource.status?.conditions ?? []
19
+ const condition = (type: string) => conditions.find((c: any) => c.type === type)
20
+ const badge = (text: string, level: StatusBadge['level']): StatusBadge => ({ text, level, color: healthColors[level!] })
21
+ if (isRayObservationStale(resource.metadata?.generation, resource.status?.observedGeneration)) return badge('Status stale', 'unknown')
22
+ if (condition('RayClusterSuspending')?.status === 'True') return badge('Suspending', 'degraded')
23
+ if (condition('RayClusterSuspended')?.status === 'True') return badge('Suspended', 'neutral')
24
+ if (resource.spec?.suspend === true) return badge('Suspension requested', 'neutral')
25
+ const failure = condition('RayClusterReplicaFailure')
26
+ if (failure?.status === 'True') return badge(failure.reason || 'Replica failure', 'unhealthy')
27
+ if (resource.status?.state === 'failed') return badge('Failed', 'unhealthy')
28
+ const head = condition('HeadPodReady')
29
+ if (head?.status === 'False') return badge(head.reason || 'Head not ready', 'degraded')
30
+ if (head?.status === 'True') {
31
+ const { ready, running, desired } = rayClusterWorkerCounts(resource)
32
+ if (ready == null || desired == null) return badge('Head ready', 'neutral')
33
+ if (ready >= desired && (running == null || ready >= running)) return badge('Ready', 'healthy')
34
+ if (ready >= desired && running != null && running > ready) return badge(`${running - ready} workers unready`, 'degraded')
35
+ return badge(`Workers ${ready}/${desired} ready`, 'degraded')
36
+ }
37
+ if (condition('RayClusterProvisioned')?.status === 'True') return badge('Provisioned', 'neutral')
38
+ return badge('Unknown', 'unknown')
64
39
  }
65
40
 
66
41
  export function getRayClusterVersion(resource: any): string {
@@ -68,11 +43,8 @@ export function getRayClusterVersion(resource: any): string {
68
43
  }
69
44
 
70
45
  export function getRayClusterWorkers(resource: any): string {
71
- const status = resource.status
72
- if (!status) return '-'
73
- const available = status.availableWorkerReplicas ?? 0
74
- const desired = status.desiredWorkerReplicas ?? 0
75
- return `${available}/${desired}`
46
+ const { ready, desired } = rayClusterWorkerCounts(resource)
47
+ return `${ready ?? '?'}/${desired ?? '?'}`
76
48
  }
77
49
 
78
50
  export function getRayClusterHeadService(resource: any): string {
@@ -137,41 +109,35 @@ export function getRayJobClusterName(resource: any): string {
137
109
  // RAYSERVICE UTILITIES
138
110
  // ============================================================================
139
111
 
140
- export function getRayServiceStatus(resource: any): StatusBadge {
141
- const conditions = resource.status?.conditions || []
142
-
143
- const suspendingCond = conditions.find((c: any) => c.type === 'Suspending')
144
- if (suspendingCond?.status === 'True') {
145
- return { text: 'Suspending', color: healthColors.degraded, level: 'degraded' }
146
- }
147
-
148
- const suspendedCond = conditions.find((c: any) => c.type === 'Suspended')
149
- if (suspendedCond?.status === 'True' || resource.spec?.suspend === true) {
150
- return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
151
- }
152
-
153
- const upgradeCond = conditions.find((c: any) => c.type === 'UpgradeInProgress')
154
- if (upgradeCond?.status === 'True') {
155
- return { text: 'Upgrading', color: healthColors.degraded, level: 'degraded' }
156
- }
157
-
158
- const rollbackCond = conditions.find((c: any) => c.type === 'RollbackInProgress')
159
- if (rollbackCond?.status === 'True') {
160
- return { text: 'RollingBack', color: healthColors.degraded, level: 'degraded' }
161
- }
112
+ export function isRayObservationStale(generation?: number, observedGeneration?: number): boolean {
113
+ return generation != null && observedGeneration != null && observedGeneration < generation
114
+ }
162
115
 
163
- const readyCond = conditions.find((c: any) => c.type === 'Ready')
164
- if (readyCond?.status === 'True') {
165
- return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
166
- }
167
- if (readyCond?.status === 'False') {
168
- return { text: readyCond.reason || 'NotReady', color: healthColors.degraded, level: 'degraded' }
169
- }
116
+ // KubeRay can freeze observedGeneration during validation failures and suspension.
117
+ export function isRayServiceConditionStale(resource: any, condition: any): boolean {
118
+ return condition?.status === 'True' && ['Ready', 'UpgradeInProgress', 'RollbackInProgress'].includes(condition.type)
119
+ && isRayObservationStale(resource.metadata?.generation, resource.status?.observedGeneration)
120
+ }
170
121
 
171
- if (resource.status?.serviceStatus === 'Running') {
172
- return { text: 'Running', color: healthColors.healthy, level: 'healthy' }
122
+ export function getRayServiceStatus(resource: any): StatusBadge {
123
+ const conditions = resource.status?.conditions ?? []
124
+ const labels: Record<string, string> = { Suspending: 'Suspending', Suspended: 'Suspended', RollbackInProgress: 'RollingBack', UpgradeInProgress: 'Upgrading' }
125
+ for (const type of ['Suspending', 'Suspended', 'RollbackInProgress', 'UpgradeInProgress', 'Ready']) {
126
+ const condition = conditions.find((c: any) => c.type === type)
127
+ if (!condition || (type !== 'Ready' && condition.status !== 'True')) continue
128
+ const label = type === 'Ready' ? (condition.status === 'True' ? 'Ready' : condition.status === 'False' ? condition.reason || 'NotReady' : 'Unknown') : labels[type]
129
+ if (isRayServiceConditionStale(resource, condition)) {
130
+ return { text: `${label} (stale)`, color: healthColors.unknown, level: 'unknown' }
131
+ }
132
+ if (type === 'Ready') {
133
+ if (condition.status === 'True') return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
134
+ if (condition.status === 'False') return { text: condition.reason || 'NotReady', color: healthColors.degraded, level: 'degraded' }
135
+ return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
136
+ }
137
+ const level = type === 'Suspended' ? 'neutral' : 'degraded'
138
+ return { text: labels[type], color: healthColors[level], level }
173
139
  }
174
-
140
+ if (resource.spec?.suspend === true) return { text: 'Suspension requested', color: healthColors.neutral, level: 'neutral' }
175
141
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
176
142
  }
177
143
 
@@ -944,17 +944,64 @@ describe('GPU ecosystem kind collisions', () => {
944
944
  })
945
945
 
946
946
  describe('GPU ecosystem status edge cases', () => {
947
- it('JobSet with minimal status is Pending, not Running', () => {
947
+ it('dispatches the current JobSet API to typed detail', () => {
948
+ const html = renderKind('jobsets', {
949
+ apiVersion: 'jobset.x-k8s.io/v1alpha2',
950
+ kind: 'JobSet',
951
+ metadata: { name: 'training', namespace: 'ml' },
952
+ spec: { replicatedJobs: [{ name: 'workers', replicas: 2, template: { spec: {} } }] },
953
+ status: { replicatedJobsStatus: [{ name: 'workers', active: 1, ready: 1, succeeded: 0, failed: 0, suspended: 0 }] },
954
+ }, 'ml')
955
+
956
+ expect(html).toContain('JobSet status')
957
+ expect(html).toContain('Replicated jobs (1)')
958
+ expect(html).not.toContain('Specification')
959
+ })
960
+
961
+ it.each(['jobset.x-k8s.io/v1alpha1', 'batch.example.io/v1'])(
962
+ 'keeps unsupported or foreign JobSet collisions generic for %s',
963
+ (apiVersion) => {
964
+ const data = {
965
+ apiVersion,
966
+ kind: 'JobSet',
967
+ metadata: { name: 'foreign', namespace: 'ml' },
968
+ spec: { collisionProbe: COLLISION_PROBE },
969
+ }
970
+ const html = renderKind('jobsets', data, 'ml')
971
+
972
+ expect(html).toContain(COLLISION_PROBE)
973
+ expect(html).not.toContain('JobSet status')
974
+ expect(getResourceStatus('jobsets', data)).toBeNull()
975
+ },
976
+ )
977
+
978
+ it('JobSet distinguishes observed inactivity from active child Jobs', () => {
948
979
  const fresh = getResourceStatus('jobsets', {
949
980
  apiVersion: 'jobset.x-k8s.io/v1alpha2',
981
+ kind: 'JobSet',
950
982
  status: { replicatedJobsStatus: [{ name: 'w', active: 0, ready: 0 }] },
951
983
  })
952
984
  const live = getResourceStatus('jobsets', {
953
985
  apiVersion: 'jobset.x-k8s.io/v1alpha2',
986
+ kind: 'JobSet',
954
987
  status: { replicatedJobsStatus: [{ name: 'w', active: 1, ready: 1 }] },
955
988
  })
956
989
  expect(fresh?.text).toBe('Pending')
957
- expect(live?.text).toBe('Running')
990
+ expect(live?.text).toBe('Active')
991
+ })
992
+
993
+ it('keeps a wrong-kind object generic even at the supported JobSet API version', () => {
994
+ const data = {
995
+ apiVersion: 'jobset.x-k8s.io/v1alpha2',
996
+ kind: 'OtherSet',
997
+ metadata: { name: 'collision', namespace: 'ml' },
998
+ spec: { collisionProbe: COLLISION_PROBE },
999
+ }
1000
+ const html = renderKind('jobsets', data, 'ml')
1001
+
1002
+ expect(html).toContain(COLLISION_PROBE)
1003
+ expect(html).not.toContain('JobSet status')
1004
+ expect(getResourceStatus('jobsets', data)).toBeNull()
958
1005
  })
959
1006
 
960
1007
  it('InferencePool with only an empty-parentRef default entry reads Not referenced', () => {
@@ -965,3 +1012,71 @@ describe('GPU ecosystem status edge cases', () => {
965
1012
  expect(pool?.text).toBe('Not referenced')
966
1013
  })
967
1014
  })
1015
+
1016
+ describe('Kueue Workload detail dispatch', () => {
1017
+ it.each(['kueue.x-k8s.io/v1beta1', 'kueue.x-k8s.io/v1beta2'])('renders %s with curated admission detail', (apiVersion) => {
1018
+ expect(renderKind('workloads', { apiVersion, kind: 'Workload', spec: { podSets: [] } })).toContain('No PodSets declared')
1019
+ })
1020
+ it.each(['example.com/v1', 'fake.kueue.x-k8s.io/v1', undefined])('leaves %s generic', (apiVersion) => {
1021
+ const data = { apiVersion, kind: 'Workload', spec: { uniqueForeignField: 'visible' } }
1022
+ const html = renderKind('workloads', data)
1023
+ expect(html).not.toContain('No PodSets declared')
1024
+ expect(html).toContain('visible')
1025
+ expect(getResourceStatus('workloads', data)).toBeNull()
1026
+ })
1027
+ })
1028
+
1029
+ describe('Kueue queue detail dispatch', () => {
1030
+ it.each(['localqueues', 'clusterqueues'])('curates supported %s and preserves foreign and future versions', (kind) => {
1031
+ for (const version of ['v1beta1', 'v1beta2']) {
1032
+ expect(renderKind(kind, { apiVersion: `kueue.x-k8s.io/${version}`, spec: {} })).toContain('Queue Status')
1033
+ }
1034
+ for (const apiVersion of ['example.com/v1', 'fake.kueue.x-k8s.io/v1', 'kueue.x-k8s.io/v99']) {
1035
+ const html = renderKind(kind, { apiVersion, spec: { foreignField: 'preserved-value' } })
1036
+ expect(html).not.toContain('Queue Status')
1037
+ expect(html).toContain('preserved-value')
1038
+ }
1039
+ })
1040
+ })
1041
+
1042
+ describe('Kueue provisioning detail identity', () => {
1043
+ it('dispatches supported versions only, preserving foreign same-kind generic rendering', () => {
1044
+ for (const [kind, group, versions, title] of [
1045
+ ['admissionchecks', 'kueue.x-k8s.io', ['v1beta1', 'v1beta2'], 'Check Controller'],
1046
+ ['provisioningrequests', 'autoscaling.x-k8s.io', ['v1beta1', 'v1'], 'Requested Pod Sets'],
1047
+ ] as const) {
1048
+ for (const version of versions) {
1049
+ const html = renderKind(kind, { apiVersion: `${group}/${version}`, spec: { controllerName: 'test-controller', provisioningClassName: 'test-class' } })
1050
+ expect(html).toContain(title)
1051
+ expect(html).not.toContain('This resource type doesn')
1052
+ expect(html).not.toContain('Configuration')
1053
+ }
1054
+ expect(renderKind(kind, { apiVersion: 'foreign.io/v1', spec: {} })).not.toContain(title)
1055
+ expect(renderKind(kind, { apiVersion: `${group}/v99`, spec: {} })).not.toContain(title)
1056
+ }
1057
+ })
1058
+ })
1059
+
1060
+ describe('RayService native detail identity', () => {
1061
+ it('renders the exact supported version once and leaves foreign/future resources generic', () => {
1062
+ const data = { apiVersion: 'ray.io/v1', kind: 'RayService', spec: { customField: 'preserved-value' } }
1063
+ const html = renderKind('rayservices', data)
1064
+ expect(html).toContain('Serving and Rollout')
1065
+ expect(html).not.toContain('preserved-value')
1066
+ expect(html).not.toContain('No specialized view')
1067
+ for (const apiVersion of ['foreign.io/v1', 'fake.ray.io/v1', 'ray.io/v99', 'ray.io/v1alpha1']) {
1068
+ const foreign = { ...data, apiVersion }
1069
+ expect(renderKind('rayservices', foreign)).not.toContain('Serving and Rollout')
1070
+ expect(renderKind('rayservices', foreign)).toContain('preserved-value')
1071
+ expect(getResourceStatus('rayservices', foreign)).toBeNull()
1072
+ }
1073
+ })
1074
+ })
1075
+
1076
+ it('uses native RayCluster detail only for ray.io/v1', () => {
1077
+ for (const apiVersion of ['ray.io/v1', 'foreign.io/v1', 'ray.io/v9']) {
1078
+ const html = renderKind('rayclusters', { apiVersion, kind: 'RayCluster', metadata: { name: 'cluster', namespace: 'ml' }, spec: { rayVersion: '2.55.0' } }, 'ml')
1079
+ expect(html.includes('Runtime Health')).toBe(apiVersion === 'ray.io/v1')
1080
+ if (apiVersion !== 'ray.io/v1') expect(html).toContain('2.55.0')
1081
+ }
1082
+ })
@@ -68,9 +68,9 @@ import {
68
68
  } from '../resources/resource-utils-kyverno-exceptions'
69
69
  import { getResourceClaimStatus, getResourceClaimTemplateStatus, getDeviceClassStatus, getResourceSliceStatus } from '../resources/resource-utils-dra'
70
70
  import { getNvidiaClusterPolicyStatus, getNvidiaDriverStatus } from '../resources/resource-utils-nvidia'
71
- import { getClusterQueueStatus, getLocalQueueStatus, getKueueWorkloadStatus, getResourceFlavorStatus, getAdmissionCheckStatus, getProvisioningRequestStatus } from '../resources/resource-utils-kueue'
71
+ import { isKueueQueueResource, getClusterQueueStatus, getLocalQueueStatus, getKueueWorkloadStatus, getResourceFlavorStatus, getAdmissionCheckStatus, getProvisioningRequestStatus } from '../resources/resource-utils-kueue'
72
72
  import { getRayClusterStatus, getRayJobStatus, getRayServiceStatus, getRayCronJobStatus } from '../resources/resource-utils-ray'
73
- import { getLeaderWorkerSetStatus, getJobSetStatus } from '../resources/resource-utils-jobset-lws'
73
+ import { getLeaderWorkerSetStatus, getJobSetStatus, isJobSetV1Alpha2 } from '../resources/resource-utils-jobset-lws'
74
74
  import { getInferenceServiceStatus, getServingRuntimeStatus, getInferenceGraphStatus, getTrainedModelStatus, getLLMInferenceServiceStatus } from '../resources/resource-utils-kserve'
75
75
  import { getInferencePoolStatus, getInferenceObjectiveStatus } from '../resources/resource-utils-inference-gateway'
76
76
  import { getVolcanoJobStatus, getVolcanoQueueStatus, getVolcanoPodGroupStatus, getJobFlowStatus, getJobTemplateStatus } from '../resources/resource-utils-volcano'
@@ -114,6 +114,14 @@ import {
114
114
  ConfigMapRenderer,
115
115
  SecretRenderer,
116
116
  JobRenderer,
117
+ JobSetRenderer,
118
+ KueueWorkloadRenderer,
119
+ RayServiceRenderer,
120
+ RayClusterRenderer,
121
+ AdmissionCheckRenderer,
122
+ ProvisioningRequestRenderer,
123
+ LocalQueueRenderer,
124
+ ClusterQueueRenderer,
117
125
  CronJobRenderer,
118
126
  CronWorkflowRenderer,
119
127
  HPARenderer,
@@ -304,6 +312,9 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
304
312
  * When an override is not provided, the base (shared) renderer is used.
305
313
  */
306
314
  export interface RendererOverrides {
315
+ RayClusterRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
316
+ RayServiceRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
317
+ KueueWorkloadRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
307
318
  PodRenderer?: React.ComponentType<{
308
319
  data: any; onCopy: CopyHandler; copied: string | null
309
320
  onNavigate?: (ref: ResourceRef) => void
@@ -400,6 +411,10 @@ export interface RendererOverrides {
400
411
  // Namespace RBAC summary: host fetches /api/rbac/namespace/{ns} so the
401
412
  // namespace page can show bindings configured here without falling
402
413
  // through to GenericRenderer.
414
+ CAPIClusterRenderer?: React.ComponentType<{
415
+ data: any
416
+ onNavigate?: (ref: ResourceRef) => void
417
+ }>
403
418
  NamespaceRenderer?: React.ComponentType<{
404
419
  data: any
405
420
  onNavigate?: (ref: ResourceRef) => void
@@ -429,6 +444,8 @@ export interface RendererOverrides {
429
444
  const KNOWN_KINDS = new Set([
430
445
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
431
446
  'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
447
+ 'jobsets',
448
+ 'rayclusters', 'rayservices', 'workloads', 'localqueues', 'clusterqueues', 'admissionchecks', 'provisioningrequests',
432
449
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
433
450
  'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
434
451
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
@@ -681,9 +698,10 @@ export function ResourceRendererDispatch({
681
698
  const isGroupGatedKind =
682
699
  kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
683
700
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
684
- || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs'
701
+ || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'jobsets'
685
702
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
686
- || kind === 'machines' || kind === 'machinesets'
703
+ || kind === 'rayclusters' || kind === 'rayservices' || kind === 'admissionchecks' || kind === 'provisioningrequests'
704
+ || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
687
705
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
688
706
  const groupGatedMatched =
689
707
  (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
@@ -703,6 +721,13 @@ export function ResourceRendererDispatch({
703
721
  || (kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io'))
704
722
  || ((kind === 'machines' || kind === 'machinesets')
705
723
  && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
724
+ || (kind === 'rayclusters' && data?.apiVersion === 'ray.io/v1')
725
+ || (kind === 'rayservices' && data?.apiVersion === 'ray.io/v1')
726
+ || (kind === 'jobsets' && isJobSetV1Alpha2(data))
727
+ || (kind === 'workloads' && isApiGroup(data?.apiVersion, 'kueue.x-k8s.io'))
728
+ || (kind === 'admissionchecks' && isKueueQueueResource(data))
729
+ || (kind === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion))
730
+ || ((kind === 'localqueues' || kind === 'clusterqueues') && isKueueQueueResource(data))
706
731
  const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
707
732
 
708
733
  const calicoApiVersionMatched = isCalicoApiVersion(data?.apiVersion)
@@ -723,6 +748,9 @@ export function ResourceRendererDispatch({
723
748
 
724
749
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
725
750
 
751
+ const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
752
+ const RayServiceComp = rendererOverrides?.RayServiceRenderer ?? RayServiceRenderer
753
+ const KueueWorkloadComp = rendererOverrides?.KueueWorkloadRenderer ?? KueueWorkloadRenderer
726
754
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
727
755
  const KarpenterNodePoolComp = rendererOverrides?.KarpenterNodePoolRenderer ?? KarpenterNodePoolRenderer
728
756
  const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
@@ -748,6 +776,7 @@ export function ResourceRendererDispatch({
748
776
  const RoleComp = rendererOverrides?.RoleRenderer ?? RoleRenderer
749
777
  const RoleBindingComp = rendererOverrides?.RoleBindingRenderer ?? RoleBindingRenderer
750
778
  const NamespaceComp = rendererOverrides?.NamespaceRenderer ?? NamespaceRenderer
779
+ const CAPIClusterComp = rendererOverrides?.CAPIClusterRenderer ?? CAPIClusterRenderer
751
780
  const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
752
781
  const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
753
782
  const RolloutComp = rendererOverrides?.RolloutRenderer ?? RolloutRenderer
@@ -790,6 +819,14 @@ export function ResourceRendererDispatch({
790
819
  {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
791
820
  {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
792
821
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobRenderer data={data} />}
822
+ {kind === 'rayclusters' && data?.apiVersion === 'ray.io/v1' && <RayClusterComp data={data} onNavigate={onNavigate} />}
823
+ {kind === 'rayservices' && data?.apiVersion === 'ray.io/v1' && <RayServiceComp data={data} onNavigate={onNavigate} />}
824
+ {kind === 'workloads' && isApiGroup(data?.apiVersion, 'kueue.x-k8s.io') && <KueueWorkloadComp data={data} onNavigate={onNavigate} />}
825
+ {kind === 'admissionchecks' && isKueueQueueResource(data) && <AdmissionCheckRenderer data={data} onNavigate={onNavigate} />}
826
+ {kind === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion) && <ProvisioningRequestRenderer data={data} onNavigate={onNavigate} />}
827
+ {kind === 'localqueues' && isKueueQueueResource(data) && <LocalQueueRenderer data={data} onNavigate={onNavigate} />}
828
+ {kind === 'clusterqueues' && isKueueQueueResource(data) && <ClusterQueueRenderer data={data} onNavigate={onNavigate} />}
829
+ {kind === 'jobsets' && isJobSetV1Alpha2(data) && <JobSetRenderer data={data} />}
793
830
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
794
831
  {kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
795
832
  {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} hpaDiagnosis={hpaDiagnosis} />}
@@ -892,7 +929,7 @@ export function ResourceRendererDispatch({
892
929
  {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} onNavigate={onNavigate} />}
893
930
  {(kind === 'secretstores' || kind === 'clustersecretstores') && <SecretStoreRenderer data={data} />}
894
931
  {kind === 'clusters' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGClusterComp data={data} onNavigate={onNavigate} />}
895
- {kind === 'clusters' && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io') && <CAPIClusterRenderer data={data} onNavigate={onNavigate} />}
932
+ {kind === 'clusters' && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io') && <CAPIClusterComp data={data} onNavigate={onNavigate} />}
896
933
  {kind === 'scheduledbackups' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
897
934
  {kind === 'poolers' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGPoolerRenderer data={data} onNavigate={onNavigate} />}
898
935
  {/* Cluster API (CAPI) */}
@@ -1083,14 +1120,14 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1083
1120
  if (k === 'clusterqueues' && data?.apiVersion?.startsWith('kueue.x-k8s.io/')) return getClusterQueueStatus(data)
1084
1121
  if (k === 'localqueues' && data?.apiVersion?.startsWith('kueue.x-k8s.io/')) return getLocalQueueStatus(data)
1085
1122
  if (k === 'resourceflavors' && data?.apiVersion?.startsWith('kueue.x-k8s.io/')) return getResourceFlavorStatus(data)
1086
- if (k === 'admissionchecks' && data?.apiVersion?.startsWith('kueue.x-k8s.io/')) return getAdmissionCheckStatus(data)
1087
- if (k === 'provisioningrequests' && data?.apiVersion?.startsWith('autoscaling.x-k8s.io/')) return getProvisioningRequestStatus(data)
1088
- if (k === 'rayclusters' && data?.apiVersion?.startsWith('ray.io/')) return getRayClusterStatus(data)
1123
+ if (k === 'admissionchecks' && isKueueQueueResource(data)) return getAdmissionCheckStatus(data)
1124
+ if (k === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion)) return getProvisioningRequestStatus(data)
1125
+ if (k === 'rayclusters' && data?.apiVersion === 'ray.io/v1') return getRayClusterStatus(data)
1089
1126
  if (k === 'rayjobs' && data?.apiVersion?.startsWith('ray.io/')) return getRayJobStatus(data)
1090
- if (k === 'rayservices' && data?.apiVersion?.startsWith('ray.io/')) return getRayServiceStatus(data)
1127
+ if (k === 'rayservices' && data?.apiVersion === 'ray.io/v1') return getRayServiceStatus(data)
1091
1128
  if (k === 'raycronjobs' && data?.apiVersion?.startsWith('ray.io/')) return getRayCronJobStatus(data)
1092
1129
  if (k === 'leaderworkersets' && data?.apiVersion?.startsWith('leaderworkerset.x-k8s.io/')) return getLeaderWorkerSetStatus(data)
1093
- if (k === 'jobsets' && data?.apiVersion?.startsWith('jobset.x-k8s.io/')) return getJobSetStatus(data)
1130
+ if (k === 'jobsets' && isJobSetV1Alpha2(data)) return getJobSetStatus(data)
1094
1131
  if (k === 'inferenceservices' && data?.apiVersion?.startsWith('serving.kserve.io/')) return getInferenceServiceStatus(data)
1095
1132
  if ((k === 'servingruntimes' || k === 'clusterservingruntimes') && data?.apiVersion?.startsWith('serving.kserve.io/')) return getServingRuntimeStatus(data)
1096
1133
  if (k === 'inferencegraphs' && data?.apiVersion?.startsWith('serving.kserve.io/')) return getInferenceGraphStatus(data)
@@ -30,6 +30,7 @@ import { isChangeEvent, isHistoricalEvent } from '../../types'
30
30
  import { getHealthBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
31
31
  import { ResourceRefBadge } from '../ui/drawer-components'
32
32
  import type { NavigateToResource } from '../../utils/navigation'
33
+ import { builtinGroupForKind } from '../../utils/api-resources'
33
34
  import { kindToPluralWithGroup, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
34
35
  import { pluralize } from '../../utils/pluralize'
35
36
 
@@ -602,8 +603,10 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
602
603
  {/* Resource info */}
603
604
  <div className="flex items-center gap-2 flex-wrap">
604
605
  <button
606
+ disabled={!item.apiVersion && builtinGroupForKind(item.kind) === undefined}
605
607
  onClick={(e) => {
606
608
  e.stopPropagation()
609
+ if (!item.apiVersion && builtinGroupForKind(item.kind) === undefined) return
607
610
  onResourceClick?.({
608
611
  kind: kindToPluralWithGroup(
609
612
  item.kind,
@@ -786,8 +789,10 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
786
789
  {/* Resource info */}
787
790
  <div className="flex items-center gap-2 flex-wrap">
788
791
  <button
792
+ disabled={!first.apiVersion && builtinGroupForKind(first.kind) === undefined}
789
793
  onClick={(e) => {
790
794
  e.stopPropagation()
795
+ if (!first.apiVersion && builtinGroupForKind(first.kind) === undefined) return
791
796
  onResourceClick?.({
792
797
  kind: kindToPluralWithGroup(
793
798
  first.kind,
@@ -324,6 +324,21 @@ describe('EventDetailPanel cluster mode (a ×N pill exposes every member)', () =
324
324
  // Ordered most-severe-first, as the drawer opens it (Warning → delete → add → update).
325
325
  const ordered = [...clusterEvents].sort((a, b) => eventSeverityRank(b) - eventSeverityRank(a))
326
326
 
327
+ it('does not link an ambiguous event through the detail badge', () => {
328
+ const event = mk('ambiguous', 'update', T0, {kind: 'Cluster', name: 'shared'});
329
+ const lanes = [{id: 'unresolved', kind: 'Cluster', name: 'shared', namespace: 'default', events: [event], isWorkload: false, identityAmbiguous: true}];
330
+ const html = renderToString(<EventDetailPanel events={[event]} selectedId={event.id} onSelectId={() => {}} onClose={() => {}} onResourceClick={() => {}} resourceLanes={lanes} />);
331
+ expect(html).not.toMatch(/<button[^>]*class="badge[^>]*>/);
332
+ expect(html).toContain('shared');
333
+ })
334
+
335
+ it('keeps an unambiguous resolved historical event link', () => {
336
+ const event = mk('resolved', 'update', T0, {kind: 'Cluster', name: 'shared'});
337
+ const lanes = [{id: 'exact', kind: 'Cluster', name: 'shared', namespace: 'default', group: 'postgresql.cnpg.io', events: [event], isWorkload: false, identityResolved: true}];
338
+ const html = renderToString(<EventDetailPanel events={[event]} selectedId={event.id} onSelectId={() => {}} onClose={() => {}} onResourceClick={() => {}} resourceLanes={lanes} />);
339
+ expect(html).toMatch(/<button[^>]*class="badge[^>]*>/);
340
+ })
341
+
327
342
  it('renders a row for every clustered event plus the summary header', () => {
328
343
  const html = renderToString(
329
344
  <EventDetailPanel events={ordered} selectedId={ordered[0].id} onSelectId={() => {}} onClose={() => {}} />,