@skyhook-io/k8s-ui 1.19.0 → 1.20.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 (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. package/src/utils/workload-history-notes.ts +93 -0
@@ -84,6 +84,123 @@ describe('Kueue admission investigation', () => {
84
84
  expect(html.includes('Admission status; execution is shown separately.')).toBe(phase === 'admitted' || phase === 'quota_reserved')
85
85
  })
86
86
 
87
+ it('shows the server-classified admission state instead of the generic qualifier', () => {
88
+ const html = render(response({ decision: 'unsatisfied', kueue: { phase: 'pending', admission: { state: 'local_queue_missing', attention: 'action_needed', blocker: { code: 'local_queue_missing', source: 'condition', name: 'QuotaReserved', reason: 'Inadmissible', message: "LocalQueue gpu doesn't exist" } } } }))
89
+ expect(html).toContain('Pending admission')
90
+ expect(html).toContain('LocalQueue missing')
91
+ })
92
+
93
+ it('names an unrecognized reason verbatim and renders unknown codes as sent', () => {
94
+ expect(render(response({ kueue: { phase: 'pending', admission: { state: 'unrecognized', attention: 'unknown', blocker: { code: 'unrecognized', source: 'condition', name: 'QuotaReserved', reason: 'SomethingNew' } } } })))
95
+ .toContain('Kueue: <code>SomethingNew</code>')
96
+ // A coarse reason the primary condition already shows is not repeated.
97
+ const coarse = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'failed to assign flavors to pod set main: boom' }, kueue: { phase: 'pending', admission: { state: 'unrecognized', attention: 'unknown', blocker: { code: 'unrecognized', source: 'condition', name: 'QuotaReserved', reason: 'Pending' } } } }))
98
+ expect(coarse).not.toContain('Unclassified reason')
99
+ expect(coarse).not.toContain('Kueue: <code>Pending</code>')
100
+ expect(coarse).toContain('failed to assign flavors to pod set main: boom')
101
+ expect(render(response({ kueue: { phase: 'pending', admission: { state: 'a_future_code', attention: 'a_future_class' } } }))).toContain('a_future_code')
102
+ })
103
+
104
+ it('does not repeat the phase, and leads with the state for a withdrawn Workload', () => {
105
+ const admitted = render(response({ decision: 'satisfied', kueue: { phase: 'admitted', admission: { state: 'admitted', attention: 'none' } } }))
106
+ expect(admitted.match(/>Admitted</g)).toHaveLength(1)
107
+ const deactivated = render(response({ decision: 'held', kueue: { phase: 'pending', active: false, admission: { state: 'deactivated', attention: 'paused', inactive: { code: 'deactivated', source: 'spec', name: 'active' } } } }))
108
+ expect(deactivated).toContain('>Deactivated<')
109
+ expect(deactivated).not.toContain('Pending admission')
110
+ expect(deactivated).not.toContain('Workload inactive')
111
+ expect(deactivated).not.toContain('Admission held')
112
+ })
113
+
114
+ it('takes staleness from the server classification, once', () => {
115
+ const stale = render(response({ decision: 'satisfied', kueue: { phase: 'admitted', admission: { state: 'admitted', attention: 'none', stale: true } } }))
116
+ expect(stale.match(/>Admitted</g)).toHaveLength(1)
117
+ expect(stale).toContain('Reported before the Workload')
118
+ // A stale primary condition does not contradict a current classification.
119
+ const current = render(response({ decision: 'held', primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', observedGeneration: 1 }, kueue: { phase: 'pending', active: false, admission: { state: 'deactivated', attention: 'paused', inactive: { code: 'deactivated', source: 'spec', name: 'active' } } } }))
120
+ expect(current).not.toContain('Stale evidence')
121
+ expect(current).not.toContain('Reported before')
122
+ })
123
+
124
+ it('shows the condition behind the state only when nothing else on the card says it', () => {
125
+ const backoff = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'couldn\'t assign flavors' }, kueue: { phase: 'pending', admission: { state: 'requeue_backoff', attention: 'waiting', blocker: { code: 'requeue_backoff', source: 'condition', name: 'Requeued', reason: 'PodsReadyTimeout', message: 'Exceeded the PodsReady timeout' } } } }))
126
+ expect(backoff).toContain('Requeued · PodsReadyTimeout: Exceeded the PodsReady timeout')
127
+ const sameMessage = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'The workload is deactivated due to a rejected check' }, kueue: { phase: 'pending', active: false, admission: { state: 'deactivated_by_kueue', attention: 'action_needed', inactive: { code: 'deactivated_by_kueue', source: 'condition', name: 'Evicted', reason: 'DeactivatedDueToAdmissionCheck', message: 'The workload is deactivated due to a rejected check' } } } }))
128
+ expect(sameMessage.match(/deactivated due to a rejected check/g)).toHaveLength(1)
129
+ const check = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'True', reason: 'QuotaReserved' }, kueue: { phase: 'quota_reserved', admission: { state: 'admission_check_rejected', attention: 'action_needed', blocker: { code: 'admission_check_rejected', source: 'admission_check', name: 'capacity', reason: 'Rejected', message: 'Provisioning request cannot satisfy this workload' } } } }))
130
+ expect(check).toContain('Admission check rejected')
131
+ expect(check).not.toContain('capacity · Rejected')
132
+ })
133
+
134
+ it('leads with the deciding evidence and its transition time while retaining the earlier condition in Technical details', () => {
135
+ const since = '2026-09-26T10:00:00Z'
136
+ const data = response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'Earlier quota shortage', observedGeneration: 1 }, kueue: { phase: 'pending', admission: { state: 'requeue_backoff', attention: 'waiting', blocker: { code: 'requeue_backoff', source: 'condition', name: 'Requeued', reason: 'PodsReadyTimeout', message: 'Waiting for the retry delay', since } } } })
137
+ const html = render(data)
138
+ expect(html.indexOf('Waiting for the retry delay')).toBeLessThan(html.indexOf('Technical details<'))
139
+ expect(html.indexOf('Earlier quota shortage')).toBeGreaterThan(html.indexOf('Technical details<'))
140
+ expect(html).toContain(`Requeued since <time dateTime="${since}"`)
141
+ expect(html).toContain('condition in Technical details was reported before')
142
+ data.workloads[0].scheduling!.observations![0].primaryCondition!.message = 'Waiting for the retry delay'
143
+ const repeated = render(data)
144
+ expect(repeated).toContain(`Requeued since <time dateTime="${since}"`)
145
+ expect(repeated.match(/Waiting for the retry delay/g)).toHaveLength(1)
146
+ delete data.workloads[0].scheduling!.observations![0].kueue!.admission!.blocker!.since
147
+ expect(render(data)).not.toContain('Requeued since')
148
+ })
149
+
150
+ it('does not repeat an eviction the supporting evidence already lists', () => {
151
+ const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'True', reason: 'QuotaReserved', message: 'Quota reserved' }, disruptions: [{ type: 'Evicted', status: 'True', reason: 'Preempted', message: 'Preempted to accommodate a workload' }], kueue: { phase: 'quota_reserved', admission: { state: 'preempted', attention: 'waiting', disruption: { code: 'preempted', source: 'condition', name: 'Evicted', reason: 'Preempted', message: 'Preempted to accommodate a workload' } } } }))
152
+ expect(html).not.toContain('Evicted · Preempted')
153
+ })
154
+
155
+ it('lets the classified state carry the tone instead of a warning phase badge', () => {
156
+ const waiting = render(response({ decision: 'unsatisfied', kueue: { phase: 'pending', admission: { state: 'insufficient_quota', attention: 'waiting' } } }))
157
+ expect(waiting).not.toContain('bg-amber')
158
+ expect(render(response({ decision: 'unsatisfied', kueue: { phase: 'pending' } }))).toMatch(/Pending admission/)
159
+ })
160
+
161
+ it('shows a fresh eviction next to the pending phase without repeating it', () => {
162
+ const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'Preempted to accommodate a workload' }, disruptions: [{ type: 'Evicted', status: 'True', reason: 'Preempted', message: 'Preempted to accommodate a workload' }], kueue: { phase: 'pending', admission: { state: 'preempted', attention: 'waiting', blocker: { code: 'no_decision' }, disruption: { code: 'preempted', source: 'condition', name: 'Evicted', reason: 'Preempted', message: 'Preempted to accommodate a workload' } } } }))
163
+ expect(html).toContain('Pending admission')
164
+ expect(html).toContain('>Preempted<')
165
+ expect(html.match(/Preempted to accommodate a workload/g)?.length).toBeLessThanOrEqual(2)
166
+ })
167
+
168
+ it('marks a stale withdrawn state without its hint', () => {
169
+ const html = render(response({ kueue: { phase: 'pending', admission: { state: 'deactivation_pending', attention: 'action_needed', stale: true, inactive: { code: 'deactivation_pending', source: 'condition', name: 'DeactivationTarget', reason: 'AdmissionCheck', stale: true } } } }))
170
+ expect(html).not.toContain('Pending admission')
171
+ expect(html).toContain('Deactivating')
172
+ expect(html).toContain('Reported before the Workload')
173
+ expect(html).not.toContain('Kueue is deactivating the Workload')
174
+ })
175
+
176
+ it('keeps the admitted phase for a Workload deactivated while still admitted', () => {
177
+ const html = render(response({ decision: 'satisfied', kueue: { phase: 'admitted', active: false, admission: { state: 'deactivated', attention: 'paused', inactive: { code: 'deactivated', source: 'spec', name: 'active' } } } }))
178
+ expect(html).toContain('>Admitted<')
179
+ expect(html).toContain('>Deactivated<')
180
+ })
181
+
182
+ it('flags a deactivation whose cause Kueue did not record', () => {
183
+ const html = render(response({ decision: 'held', primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending' }, disruptions: [{ type: 'Evicted', status: 'True', reason: 'PodsReadyTimeout', message: 'Exceeded the PodsReady timeout' }], kueue: { phase: 'pending', active: false, admission: { state: 'deactivated', attention: 'unknown', inactive: { code: 'deactivated', source: 'condition', name: 'Evicted', reason: 'PodsReadyTimeout', message: 'Exceeded the PodsReady timeout' } } } }))
184
+ expect(html).toContain('>Deactivated<')
185
+ expect(html).toContain('doesn&#x27;t say whether Kueue or a person deactivated it')
186
+ expect(html).toContain('Exceeded the PodsReady timeout')
187
+ })
188
+
189
+ it('suggests what an actionable state usually asks, and nothing for normal waiting', () => {
190
+ expect(render(response({ kueue: { phase: 'pending', admission: { state: 'local_queue_missing', attention: 'action_needed' } } }))).toContain('ask an admin to create the LocalQueue')
191
+ expect(render(response({ kueue: { phase: 'pending', admission: { state: 'insufficient_quota', attention: 'waiting' } } }))).not.toContain('ask an admin')
192
+ })
193
+
194
+ it('quietly marks a stale primary condition under a current classification', () => {
195
+ const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'old', observedGeneration: 1 }, kueue: { phase: 'pending', admission: { state: 'requeue_backoff', attention: 'waiting', blocker: { code: 'requeue_backoff', source: 'condition', name: 'Requeued', reason: 'PodsReadyTimeout' } } } }))
196
+ expect(html).toContain('The condition in Technical details was reported before')
197
+ expect(html).not.toContain('Stale evidence')
198
+ })
199
+
200
+ it('keeps the generic qualifier when an older Radar sends no admission state', () => {
201
+ expect(render(response({ decision: 'unknown', kueue: { phase: 'pending' } }))).toContain('Admission unknown')
202
+ })
203
+
87
204
  it('keeps disruptions, inactive state, check retries and requeues distinct', () => {
88
205
  const html = render(response({ decision: 'held', kueue: { phase: 'pending', active: false, requeueState: { count: 0, requeueAt: '2026-09-22T00:00:00Z' } }, disruptions: [{ type: 'Evicted', status: 'True', reason: 'Preempted' }], gates: [{ kind: 'preemption_gate', name: 'gate', decision: 'unsatisfied', retryCount: 0, requeueAfterSeconds: 0 }] }))
89
206
  expect(html).toContain('Workload inactive')
@@ -1,10 +1,10 @@
1
- import { admissionCheckSeverity, isKueueConditionStale } from './resource-utils-kueue'
1
+ import { admissionCheckSeverity, isKueueConditionStale, kueueAdmissionStateHint, kueueAdmissionStatePresentation } from './resource-utils-kueue'
2
2
  import { Disclosure } from '../ui/Disclosure'
3
3
  import { formatRelativeAgeTime } from '../../utils/format'
4
4
  import { Badge } from '../ui/Badge'
5
5
  import { AlertBanner, ResourceLink, Section } from '../ui/drawer-components'
6
6
  import { kindToPluralWithGroup } from '../../utils/navigation'
7
- import type { KueueAdmissionResponse, SchedulingCondition, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
7
+ import type { KueueAdmissionResponse, KueueAdmissionWorkload, SchedulingCondition, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
8
8
 
9
9
  interface KueueAdmissionSectionProps {
10
10
  presentation?: 'card' | 'drawer'
@@ -17,11 +17,14 @@ interface KueueAdmissionSectionProps {
17
17
  hasOwner?: boolean
18
18
  onRetry?: () => void
19
19
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
20
+ /** Host-supplied extra content for one Workload, e.g. Kueue's reported queue position. */
21
+ renderWorkloadExtra?: (workload: KueueAdmissionWorkload) => React.ReactNode
20
22
  }
21
23
 
22
24
  const phases = { pending: 'Pending admission', quota_reserved: 'Quota reserved', admitted: 'Admitted', finished: 'Finished' }
25
+ const phaseStates = new Set(['admitted', 'quota_reserved', 'finished'])
23
26
 
24
- export function KueueAdmissionSection({ presentation = 'card', data, loading, error, forbidden, hinted, externalExecution, hasOwner, onRetry, onNavigate }: KueueAdmissionSectionProps) {
27
+ export function KueueAdmissionSection({ presentation = 'card', data, loading, error, forbidden, hinted, externalExecution, hasOwner, onRetry, onNavigate, renderWorkloadExtra }: KueueAdmissionSectionProps) {
25
28
  if (!loading && !error && data && data.workloads.length === 0 && !hinted) return null
26
29
  const link = (name: string, ref?: SchedulingRef) => ref
27
30
  ? <ResourceLink {...ref} kind={kindToPluralWithGroup(ref.kind, ref.group ?? '')} label={name} onNavigate={onNavigate} />
@@ -40,6 +43,7 @@ export function KueueAdmissionSection({ presentation = 'card', data, loading, er
40
43
  {workload.projection === 'unsupported' ? <p className="text-theme-text-secondary">Associated Workload found. Scheduling projection is unavailable for {workload.apiVersion}; inspect the resource for native evidence.</p>
41
44
  : workload.projection === 'forbidden' ? <p className="text-theme-text-secondary">Associated Workload found, but permission to get this Workload is required for its scheduling detail.</p>
42
45
  : workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} presentation={presentation} observation={observation} identity={link(workload.name, workload.ref)} sourceLink={workload.ref && onNavigate ? link("View Workload details", workload.ref) : undefined} generation={workload.generation} deleting={workload.deleting} link={link} />)}
46
+ {renderWorkloadExtra?.(workload)}
43
47
  {workload.linksLimited && <p className="text-xs text-theme-text-tertiary">Some resource links are unavailable. Names already reported by the Workload remain visible.</p>}
44
48
  </article>)}
45
49
  </div>}
@@ -95,7 +99,10 @@ function GateEvidence({ gate, link }: { gate: Gate; link: Link }) {
95
99
  function AdmissionObservation({ observation, identity, sourceLink, generation, deleting, presentation, link }: { observation: SchedulingObservation; identity: React.ReactNode; sourceLink?: React.ReactNode; generation: number; deleting: boolean; presentation: 'card' | 'drawer'; link: Link }) {
96
100
  const kueue = observation.kueue
97
101
  const condition = observation.primaryCondition
98
- const stale = isKueueConditionStale(condition, observation.subjectGeneration)
102
+ const admission = kueue?.admission
103
+ // The server decides staleness from the evidence behind the state; an
104
+ // older Radar sends no admission block, so fall back to the condition.
105
+ const stale = admission ? !!admission.stale : isKueueConditionStale(condition, observation.subjectGeneration)
99
106
  const secondary = [...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
100
107
  .filter((entry) => entry != null)
101
108
  .filter((entry) => entry.type !== condition?.type)
@@ -113,8 +120,29 @@ function AdmissionObservation({ observation, identity, sourceLink, generation, d
113
120
  const split = presentation === 'card' && (visible.length > 0 || preemption.length > 0)
114
121
  const severity = stale || observation.decision === 'unknown' ? 'neutral'
115
122
  : kueue?.phase === 'finished' ? kueue.outcome === 'failed' ? 'error' : kueue.outcome === 'succeeded' ? 'success' : 'neutral'
116
- : observation.decision === 'satisfied' ? 'success' : 'warning'
117
- const qualifier = observation.decision === 'held' && kueue?.active !== false ? 'Admission held' : observation.decision === 'unknown' ? 'Admission unknown' : null
123
+ // With a classification, the state badge carries the tone unless the
124
+ // state is the phase itself.
125
+ : admission ? admission.state === 'admitted' ? 'success' : 'neutral'
126
+ : observation.decision === 'satisfied' ? 'success' : 'warning'
127
+ // The phase badge already says these states. A withdrawn Workload is not
128
+ // waiting, so its state replaces the pending phase rather than following it;
129
+ // one still admitted keeps its phase until it releases the quota.
130
+ // An unclassified reason the primary condition already states adds nothing.
131
+ const restatesPrimary = admission?.state === 'unrecognized' && admission.blocker?.reason === condition?.reason
132
+ const reason = admission && !phaseStates.has(admission.state) && !restatesPrimary ? kueueAdmissionStatePresentation(admission) : null
133
+ const withdrawn = !!admission?.inactive && kueue?.phase === 'pending'
134
+ // An unrecognized reason is Kueue's own word; say that rather than Radar's.
135
+ // Kueue's coarse reasons (Pending, Inadmissible) repeat what the phase and
136
+ // the primary condition already say, so name only a reason that adds to it.
137
+ const unrecognizedReason = admission?.state === 'unrecognized' && admission.blocker?.reason !== condition?.reason ? admission.blocker?.reason : undefined
138
+ const hint = admission && !admission.stale ? kueueAdmissionStateHint(admission) : undefined
139
+ // Show the condition behind the state when nothing else on the card says
140
+ // it: backoff or Kueue's deactivation cause. Checks have their own card.
141
+ const decidingFact = admission && [admission.inactive, admission.blocker, admission.disruption].find((fact) => fact?.code === admission.state)
142
+ const factEvidence = reason && decidingFact?.source === 'condition' && decidingFact.name !== condition?.type &&
143
+ !observation.disruptions?.some((entry) => entry.type === decidingFact.name)
144
+ ? decidingFact : undefined
145
+ const qualifier = admission ? null : observation.decision === 'held' && kueue?.active !== false ? 'Admission held' : observation.decision === 'unknown' ? 'Admission unknown' : null
118
146
  return <div className="@container/admission space-y-4">
119
147
  <div className={split ? 'grid min-w-0 items-start gap-4 @min-[1024px]/admission:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)]' : 'space-y-4'}>
120
148
  <div className="min-w-0 space-y-4">
@@ -123,15 +151,26 @@ function AdmissionObservation({ observation, identity, sourceLink, generation, d
123
151
  <div className="space-y-2 p-3">
124
152
  <div className="flex flex-wrap items-center gap-2">
125
153
  <span className="font-medium break-all">{identity}</span>
126
- <Badge severity={severity}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
154
+ {!withdrawn && <Badge severity={severity}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>}
155
+ {reason && <Badge severity={reason.severity}>{unrecognizedReason ? <>Kueue: <code>{unrecognizedReason}</code></> : reason.label}</Badge>}
127
156
  {qualifier && <span className="text-xs text-theme-text-secondary">{qualifier}</span>}
128
- {kueue?.active === false && <Badge severity="neutral">Workload inactive</Badge>}
157
+ {!admission && kueue?.active === false && <Badge severity="neutral">Workload inactive</Badge>}
129
158
  {deleting && <Badge severity="alert">Deleting</Badge>}
130
159
  {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
131
160
  </div>
132
- {stale && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition!.observedGeneration}; Workload generation {observation.subjectGeneration}.</p>}
133
- {condition ? <p className="max-w-3xl whitespace-pre-wrap break-words">{condition.message || <><code>{condition.reason}</code>{condition.reason && ' · '}{condition.type}={condition.status}</>}</p> : <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
134
- {condition?.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">{condition.type}={condition.status} since {formatRelativeAgeTime(condition.lastTransitionTime)}</p>}
161
+ {stale && (admission
162
+ ? <p className={`text-xs ${reason ? 'text-warning-text' : 'text-theme-text-tertiary'}`}>Reported before the Workload's spec last changed; it may not reflect the current spec.</p>
163
+ : <p className="text-xs text-warning-text">Stale evidence: condition generation {condition!.observedGeneration}; Workload generation {observation.subjectGeneration}.</p>)}
164
+ {admission && !stale && isKueueConditionStale(condition, observation.subjectGeneration) && <p className="text-xs text-theme-text-tertiary">The {factEvidence ? 'condition in Technical details' : 'condition below'} was reported before the Workload's spec last changed.</p>}
165
+ {hint && <p className="max-w-3xl text-xs text-theme-text-secondary">{hint}</p>}
166
+ {factEvidence ? <>
167
+ <p className="max-w-3xl whitespace-pre-wrap break-words">{factEvidence.name}{factEvidence.reason && ` · ${factEvidence.reason}`}{factEvidence.message && `: ${factEvidence.message}`}</p>
168
+ {factEvidence.since && <p className="text-xs text-theme-text-tertiary">{factEvidence.name} since <time dateTime={factEvidence.since} title={factEvidence.since}>{formatRelativeAgeTime(factEvidence.since)}</time></p>}
169
+ {factEvidence.stale && <p className="text-xs text-theme-text-tertiary">This reported cause predates the Workload's current spec.</p>}
170
+ </> : <>
171
+ {condition ? <p className="max-w-3xl whitespace-pre-wrap break-words">{condition.message || <><code>{condition.reason}</code>{condition.reason && ' · '}{condition.type}={condition.status}</>}</p> : <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
172
+ {condition?.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">{condition.type}={condition.status} since {formatRelativeAgeTime(condition.lastTransitionTime)}</p>}
173
+ </>}
135
174
  <div className="empty:hidden flex flex-wrap gap-x-5 gap-y-2 border-t border-theme-border pt-2 text-xs text-theme-text-secondary">
136
175
  {sourceLink && <span>{sourceLink}</span>}
137
176
  {(!!observation.queues?.length || kueue?.concurrentAdmission) && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-theme-text-secondary">
@@ -148,7 +187,7 @@ function AdmissionObservation({ observation, identity, sourceLink, generation, d
148
187
  <p>Decision: {observation.decision} · Generation {generation}</p>
149
188
  {[condition, ...secondary].filter((entry) => entry != null).map((entry) => <div key={entry.type}>
150
189
  <p>{entry.type}={entry.status}{entry.reason && ` · Reason: ${entry.reason}`}{entry.observedGeneration != null && ` · Observed generation ${entry.observedGeneration}`}{entry.lastTransitionTime && ` · Status since ${formatRelativeAgeTime(entry.lastTransitionTime)}`}</p>
151
- {supporting.includes(entry) && entry.message && <p className="whitespace-pre-wrap break-words">{entry.message}</p>}
190
+ {(supporting.includes(entry) || (factEvidence && entry === condition && entry.message !== factEvidence.message)) && entry.message && <p className="whitespace-pre-wrap break-words">{entry.message}</p>}
152
191
  </div>)}
153
192
  {(observation.gates ?? []).filter((gate) => gate.retryCount === 0 || gate.requeueAfterSeconds === 0).map((gate, index) => <p key={index}>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'} {gate.name}{gate.retryCount === 0 && ' · Retries: 0'}{gate.requeueAfterSeconds === 0 && ' · Requeue delay: 0s'}</p>)}
154
193
  {kueue?.requeueState?.count === 0 && !kueue.requeueState.requeueAt && <p>Requeues: 0</p>}
@@ -0,0 +1,97 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { KueuePendingPositionValue } from './KueuePendingPosition'
5
+ import { KueueAdmissionSection } from './KueueAdmissionSection'
6
+ import { KueueWorkloadRenderer } from './renderers/KueueWorkloadRenderer'
7
+ import type { KueuePendingPosition } from '../../types/scheduling'
8
+
9
+ const readAt = new Date(Date.now() - 12_000).toISOString()
10
+ const text = (position?: KueuePendingPosition, props: Partial<React.ComponentProps<typeof KueuePendingPositionValue>> = {}) => {
11
+ const node = document.createElement('div')
12
+ node.innerHTML = renderToStaticMarkup(<KueuePendingPositionValue position={position} {...props} />)
13
+ return node.textContent ?? ''
14
+ }
15
+
16
+ describe('KueuePendingPositionValue', () => {
17
+ it('shows Kueue\'s 0-based ahead counts as 1-based places, with the read time and what it is not', () => {
18
+ const out = text({ status: 'listed', aheadInLocalQueue: 1, aheadInClusterQueue: 2, localQueue: 'pos', readAt })
19
+ expect(out).toContain('#2 in LocalQueue pos · #3 in its ClusterQueue')
20
+ expect(out).toContain('12s ago')
21
+ expect(out).toContain('Not a promise of admission order or a start time')
22
+ })
23
+
24
+ it('says when Kueue holds a ranked Workload back from retrying', () => {
25
+ const later = new Date(Date.now() + 600_000).toISOString()
26
+ expect(text({ status: 'listed', aheadInLocalQueue: 0, aheadInClusterQueue: 0, localQueue: 'pos', readAt }, { requeueAt: later })).toContain("won't try to admit it again before")
27
+ const earlier = new Date(Date.now() - 600_000).toISOString()
28
+ expect(text({ status: 'listed', aheadInLocalQueue: 0, aheadInClusterQueue: 0, localQueue: 'pos', readAt }, { requeueAt: earlier })).not.toContain("won't try")
29
+ })
30
+
31
+ it('never words an absent row as not pending', () => {
32
+ const out = text({ status: 'not_listed', localQueue: 'pos', rows: 0, rowLimit: 1000, readAt })
33
+ expect(out).toContain("Not in Kueue's pending list for LocalQueue pos")
34
+ expect(out).not.toMatch(/not pending/i)
35
+ })
36
+
37
+ it('claims no bound when Kueue returned a full page without the Workload', () => {
38
+ const out = text({ status: 'limit_reached', localQueue: 'pos', rows: 1000, rowLimit: 1000, readAt })
39
+ expect(out).toContain('Not among the first 1,000 Workloads Kueue listed for LocalQueue pos')
40
+ expect(out).toContain('Its position is unknown')
41
+ })
42
+
43
+ it('says why there is no position', () => {
44
+ expect(text({ status: 'not_served' })).toContain("didn't find Kueue's visibility API")
45
+ expect(text({ status: 'no_local_queue' })).toContain('No LocalQueue named')
46
+ expect(text({ status: 'queue_unknown', message: "Kueue doesn't know LocalQueue x in namespace y." })).toContain("Kueue doesn't know LocalQueue x")
47
+ expect(text({ status: 'unsupported', message: 'Kueue serves its visibility API at [v1alpha1]; Radar reads v1beta2 and v1beta1.' })).toContain('v1alpha1')
48
+ const denied = text({ status: 'unavailable', code: 'user_forbidden', message: "Your Kubernetes access doesn't allow this. Reading Kueue's pending order needs get on localqueues/pendingworkloads (visibility.kueue.x-k8s.io) in namespace ml." })
49
+ expect(denied).toContain('localqueues/pendingworkloads')
50
+ expect(denied).not.toContain('Retry')
51
+ })
52
+
53
+ it('offers a retry only where a retry can help', () => {
54
+ const retry = () => {}
55
+ expect(renderToStaticMarkup(<KueuePendingPositionValue position={{ status: 'unavailable', code: 'read_error', message: "Kueue's visibility server isn't available." }} onRetry={retry} />)).toContain('Retry')
56
+ expect(renderToStaticMarkup(<KueuePendingPositionValue position={{ status: 'unavailable', code: 'radar_forbidden', message: 'no' }} onRetry={retry} />)).not.toContain('Retry')
57
+ expect(renderToStaticMarkup(<KueuePendingPositionValue position={{ status: 'not_served' }} onRetry={retry} />)).toContain('Check again')
58
+ })
59
+
60
+ it('keeps the last answer visible but marked when a refresh failed', () => {
61
+ const out = text({ status: 'listed', aheadInLocalQueue: 0, aheadInClusterQueue: 0, localQueue: 'pos', readAt }, { refreshFailed: true })
62
+ expect(out).toContain('#1 in LocalQueue pos')
63
+ expect(out).toContain("Couldn't refresh · last read 12s ago")
64
+ expect(out).not.toContain("Kueue's pending order, read")
65
+ })
66
+
67
+ it('renders loading, request failure, an older Radar and unknown statuses honestly', () => {
68
+ expect(text(undefined, { loading: true })).toContain('Asking Kueue')
69
+ expect(text(undefined, { error: 'boom' })).toContain("Couldn't ask Kueue: boom")
70
+ expect(text(undefined, { upgrade: { feature: 'Kueue queue positions' } })).toContain('Kueue queue positions')
71
+ expect(text({ status: 'something_new' })).toContain('something_new')
72
+ })
73
+ })
74
+
75
+ describe('position slots', () => {
76
+ it('the Workload renderer shows a supplied position in its Admission section', () => {
77
+ const html = renderToStaticMarkup(<KueueWorkloadRenderer data={{ metadata: { name: 'w', namespace: 'ml' }, spec: { queueName: 'pos' } }} pendingPosition={<span>POSITION</span>} />)
78
+ expect(html).toContain('Queue Position')
79
+ expect(html).toContain('POSITION')
80
+ expect(renderToStaticMarkup(<KueueWorkloadRenderer data={{ metadata: { name: 'w', namespace: 'ml' }, spec: { queueName: 'pos' } }} />)).not.toContain('Queue Position')
81
+ })
82
+
83
+ it('the admission section renders host content per Workload', () => {
84
+ const ref = { kind: 'Workload', group: 'kueue.x-k8s.io', namespace: 'ml', name: 'w' }
85
+ const html = renderToStaticMarkup(<KueueAdmissionSection loading={false} hinted={false} externalExecution={false} data={{ uid: 'r', installed: true, total: 1, truncated: false, workloads: [{
86
+ apiVersion: 'kueue.x-k8s.io/v1beta2', name: 'w', namespace: 'ml', uid: 'wl', generation: 1, createdAt: null, deleting: false, ref, projection: 'available', linksLimited: false,
87
+ scheduling: { observations: [{ source: 'kueue', domain: 'admission', subject: ref, decision: 'unsatisfied', kueue: { phase: 'pending' } }] },
88
+ }] }} renderWorkloadExtra={(workload) => <span>EXTRA {workload.uid}</span>} />)
89
+ expect(html).toContain('EXTRA wl')
90
+ })
91
+ it('marks a retained unknown queue answer when a later refresh fails', () => {
92
+ const html = renderToStaticMarkup(<KueuePendingPositionValue position={{ status: 'queue_unknown', localQueue: 'q', readAt: '2026-11-03T01:00:00Z' }} refreshFailed />)
93
+ expect(html).toContain("Kueue doesn&#x27;t know LocalQueue")
94
+ expect(html).toContain("Couldn&#x27;t refresh")
95
+ })
96
+
97
+ })
@@ -0,0 +1,91 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { KueuePendingPosition } from '../../types/scheduling'
3
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
4
+ import { RadarUpgradeNote } from '../ui/RadarUpgradeNote'
5
+ import { formatRelativeAgeTime } from '../../utils/format'
6
+ import { readActionFor } from '../../utils/read-actions'
7
+
8
+ export interface KueuePendingPositionValueProps {
9
+ position?: KueuePendingPosition
10
+ loading?: boolean
11
+ /** The request failed and there is no answer to show. */
12
+ error?: string
13
+ /** The last refresh failed; the answer shown is from an earlier read. */
14
+ refreshFailed?: boolean
15
+ /** The connected Radar predates the position endpoint. */
16
+ upgrade?: RadarUpgradeRequirement
17
+ onRetry?: () => void
18
+ /** The Workload's status.requeueState.requeueAt: Kueue keeps a ranked Workload back until then. */
19
+ requeueAt?: string
20
+ }
21
+
22
+ function ReadAge({ readAt }: { readAt?: string }) {
23
+ const [, tick] = useState(0)
24
+ useEffect(() => {
25
+ if (!readAt) return
26
+ const id = setInterval(() => tick(n => n + 1), 15_000)
27
+ return () => clearInterval(id)
28
+ }, [readAt])
29
+ if (!readAt) return null
30
+ return <time dateTime={readAt} title={readAt}>{formatRelativeAgeTime(readAt)}</time>
31
+ }
32
+
33
+ function Retry({ onRetry, label = 'Retry' }: { onRetry?: () => void; label?: string }) {
34
+ if (!onRetry) return null
35
+ return <button type="button" className="ml-1 text-accent-text hover:underline" onClick={onRetry}>{label}</button>
36
+ }
37
+
38
+ /**
39
+ * One Workload's position as Kueue's visibility API reported it, or why
40
+ * there is none. Radar never derives a position; every state says what Kueue
41
+ * answered, when, or why it couldn't be asked.
42
+ */
43
+ export function KueuePendingPositionValue({ position, loading, error, refreshFailed, upgrade, onRetry, requeueAt }: KueuePendingPositionValueProps) {
44
+ if (upgrade) return <RadarUpgradeNote requirement={upgrade} />
45
+ if (!position) {
46
+ if (error) return <span className="text-theme-text-secondary">Couldn't ask Kueue: {error}<Retry onRetry={onRetry} /></span>
47
+ return <span className="text-theme-text-tertiary">{loading ? 'Asking Kueue…' : 'Waiting for a current answer'}</span>
48
+ }
49
+ const queue = position.localQueue ? <span className="font-medium">{position.localQueue}</span> : 'its LocalQueue'
50
+ const caption = (text: React.ReactNode) => <span className="mt-0.5 block text-xs text-theme-text-tertiary">{text}</span>
51
+ const stale = refreshFailed && caption(<>Couldn't refresh{position.readAt && <> · last read <ReadAge readAt={position.readAt} /></>}<Retry onRetry={onRetry} /></>)
52
+ const content = (() => {
53
+ switch (position.status) {
54
+ case 'listed':
55
+ return <span>
56
+ <span className="text-theme-text-primary">
57
+ #{position.aheadInLocalQueue! + 1} in LocalQueue {queue}
58
+ {' · #'}{position.aheadInClusterQueue! + 1} in its ClusterQueue
59
+ </span>
60
+ {caption(<>Kueue's pending order{!refreshFailed && <>, read <ReadAge readAt={position.readAt} /></>}. Not a promise of admission order or a start time.</>)}
61
+ {requeueAt && Date.parse(requeueAt) > Date.now() && caption(<>Kueue won't try to admit it again before <time dateTime={requeueAt} title={requeueAt}>{new Date(requeueAt).toLocaleString()}</time>.</>)}
62
+ </span>
63
+ case 'not_listed':
64
+ return <span>
65
+ <span className="text-theme-text-secondary">Not in Kueue's pending list for LocalQueue {queue}</span>
66
+ {caption(<>{!refreshFailed && <>Read <ReadAge readAt={position.readAt} />. </>}It may have been admitted or deactivated since, or Kueue hasn't picked it up yet.</>)}
67
+ </span>
68
+ case 'limit_reached':
69
+ return <span>
70
+ <span className="text-theme-text-secondary">Not among the first {position.rows!.toLocaleString()} Workloads Kueue listed for LocalQueue {queue}</span>
71
+ {caption(<>{!refreshFailed && <>Read <ReadAge readAt={position.readAt} />. </>}Its position is unknown.</>)}
72
+ </span>
73
+ case 'queue_unknown':
74
+ return <span className="text-theme-text-secondary">{position.message || <>Kueue doesn't know LocalQueue {queue}.</>}<Retry onRetry={onRetry} /></span>
75
+ case 'no_local_queue':
76
+ return <span className="text-theme-text-tertiary">No LocalQueue named, so Kueue holds no position for it.</span>
77
+ case 'not_served':
78
+ return <span className="text-theme-text-tertiary">Radar's API discovery didn't find Kueue's visibility API (visibility.kueue.x-k8s.io).<Retry onRetry={onRetry} label="Check again" /></span>
79
+ case 'unsupported':
80
+ return <span className="text-theme-text-tertiary">{position.message || "Kueue serves its visibility API only at versions Radar doesn't read."}</span>
81
+ case 'unavailable':
82
+ return <span className="text-theme-text-secondary">
83
+ {position.message || "Radar couldn't get Kueue's answer."}
84
+ {readActionFor(position.code) === 'retry' && <Retry onRetry={onRetry} />}
85
+ </span>
86
+ default:
87
+ return <span className="text-theme-text-tertiary">Radar reported a position status this view doesn't know: {position.status}</span>
88
+ }
89
+ })()
90
+ return <span>{content}{stale}</span>
91
+ }
@@ -264,3 +264,11 @@ it('bounds a category total over kinds Radar collects in only some namespaces',
264
264
  // Each kind is exact where Radar collects it; their sum is exact for no scope.
265
265
  expect(html.slice(start, html.indexOf('</button>', start))).toContain('≥5')
266
266
  })
267
+
268
+ it('matches CRD aliases only for the definition API identity', () => {
269
+ const crd = { group: 'apiextensions.k8s.io', kind: 'CustomResourceDefinition', name: 'customresourcedefinitions' }
270
+ expect(resourceMatchesSidebarFilter(crd, 'crd')).toBe(true)
271
+ expect(resourceMatchesSidebarFilter(crd, 'CRDS')).toBe(true)
272
+ expect(resourceMatchesSidebarFilter(horizontalPodAutoscaler, 'crd')).toBe(false)
273
+ expect(resourceMatchesSidebarFilter({ ...crd, group: 'other.io' }, 'crd')).toBe(false)
274
+ })
@@ -12,7 +12,7 @@ import {
12
12
  import { clsx } from 'clsx'
13
13
  import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../ui/Collapse'
14
14
  import type { APIResource } from '../../types'
15
- import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
15
+ import { categorizeResources, CORE_RESOURCES, resourceSearchAliases } from '../../utils/api-resources'
16
16
  import { getResourceIcon } from '../../utils/resource-icons'
17
17
  import { Tooltip } from '../ui/Tooltip'
18
18
  import { certaintyGlyph } from '../facts'
@@ -138,7 +138,8 @@ export function resourceMatchesSidebarFilter(resource: Pick<APIResource, 'kind'
138
138
  if (!normalized) return true
139
139
  return resource.kind.toLowerCase().includes(normalized) ||
140
140
  resource.name.toLowerCase().includes(normalized) ||
141
- resource.group.toLowerCase().includes(normalized)
141
+ resource.group.toLowerCase().includes(normalized) ||
142
+ resourceSearchAliases(resource).some(alias => alias.includes(normalized))
142
143
  }
143
144
 
144
145
  export function rawCRDGroupTitle(resources: Pick<APIResource, 'group' | 'isCrd'>[]): string | undefined {
@@ -0,0 +1,28 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+
10
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
11
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
16
+
17
+ it('shows definition columns and only served versions, marking deprecated ones', async () => {
18
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
19
+ const kind: APIResource = { group: 'apiextensions.k8s.io', version: 'v1', kind: 'CustomResourceDefinition', name: 'customresourcedefinitions', namespaced: false, isCrd: false, verbs: ['get', 'list'] }
20
+ const data = { apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', metadata: { name: 'widgets.example.io' }, spec: { group: 'example.io', names: { kind: 'Widget' }, scope: 'Namespaced', versions: [{ name: 'v1', served: true, storage: true }, { name: 'v1beta1', served: true, deprecated: true }, { name: 'v1alpha1', served: false }] }, status: { conditions: [{ type: 'Established', status: 'True' }] } }
21
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={[]} apiResources={[kind]} resourceCounts={{ CustomResourceDefinition: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/customresourcedefinitions" locationSearch="?apiGroup=apiextensions.k8s.io" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
22
+ const table = host.querySelector('table')!
23
+ expect(table).not.toBeNull()
24
+ for (const text of ['Name', 'Kind', 'Scope', 'Served Versions', 'Status', 'Age', 'Widget', 'Namespaced', 'deprecated', 'Established']) expect(table.textContent).toContain(text)
25
+ expect(table.textContent).not.toContain('storage')
26
+ expect(table.textContent).not.toContain('v1alpha1')
27
+ expect(Array.from(table.querySelectorAll('th')).map(th => th.textContent).join(',')).not.toContain('Namespace')
28
+ })
@@ -0,0 +1,25 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+
10
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
11
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
16
+
17
+ it('shows an unknown Pod count, not zero, when node metrics are not loaded', async () => {
18
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
19
+ const kind: APIResource = { group: '', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: false, verbs: ['get', 'list'] }
20
+ const node = { apiVersion: 'v1', kind: 'Node', metadata: { name: 'gpu-a100-1', uid: 'n1' }, status: { allocatable: { pods: '110', cpu: '96', memory: '1100Gi' }, conditions: [{ type: 'Ready', status: 'True' }] } }
21
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['ml-research']} apiResources={[kind]} resourceCounts={{ Node: 1 }} selectedKindQuery={{ isLoading: false, data: [node], resourceName: kind.name, group: kind.group }} locationPathname="/resources/nodes" locationSearch="" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
22
+ const table = host.querySelector('table')!
23
+ expect(table.textContent).toContain('gpu-a100-1')
24
+ expect(table.textContent).not.toContain('0 / 110')
25
+ })
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+ import cases from '../../../../../testdata/strimzi/connector-status.json'
10
+ import { getCellFilterValue } from './resource-utils'
11
+
12
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
13
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
14
+ Element.prototype.scrollIntoView = vi.fn()
15
+ let root: ReturnType<typeof createRoot>
16
+ let host: HTMLDivElement
17
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
18
+
19
+ it('replaces the native Ready printer column with task-aware snapshot status', async () => {
20
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
21
+ const kind: APIResource = { group: 'kafka.strimzi.io', version: 'v1', kind: 'KafkaConnector', name: 'kafkaconnectors', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
22
+ const data = {...cases[1].resource, metadata: {...cases[1].resource.metadata, uid: 'fixture'}}
23
+ const second = {...data, metadata:{...data.metadata,name:'zzz',uid:'second'},spec:{...data.spec,class:'AConnector'},status:{...data.status,connectorStatus:{...data.status.connectorStatus,tasks:Array.from({length:10},(_,id)=>({id,state:'RUNNING'}))}}}
24
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView printerTable={{kind:kind.name,group:kind.group,columns:[{name:'Ready',type:'string',jsonPath:'.status.conditions[?(@.type=="Ready")].status'}],cells:{fixture:['True']}}} namespaces={['lab']} apiResources={[kind]} resourceCounts={{ KafkaConnector: 2 }} selectedKindQuery={{ isLoading: false, data: [data,second], resourceName: kind.name, group: kind.group }} locationPathname="/resources/kafkaconnectors" locationSearch="?apiGroup=kafka.strimzi.io" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
25
+ const table = host.querySelector('table')!
26
+ expect(table).not.toBeNull()
27
+ expect(table.textContent).toContain('Operator Snapshot')
28
+ expect(table.textContent).toContain('Tasks failed')
29
+ expect(table.textContent).not.toContain('Ready')
30
+ const classHeader = [...table.querySelectorAll('th')].find(th=>th.textContent?.trim()==='Class')!
31
+ act(() => classHeader.click())
32
+ expect(table.querySelector('tbody tr')?.textContent).toContain('zzz')
33
+ const tasksHeader = [...table.querySelectorAll('th')].find(th=>th.textContent?.trim()==='Tasks / Max')!
34
+ act(() => tasksHeader.click())
35
+ expect(table.querySelector('tbody tr')?.textContent).toContain('connector')
36
+ expect(getCellFilterValue(data, 'status', 'kafkaconnectors')).toBe('Tasks failed')
37
+ })