@skyhook-io/k8s-ui 1.14.12 → 1.15.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  3. package/src/components/gitops/RollbackDialog.tsx +4 -3
  4. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  5. package/src/components/gitops/health-provenance.test.ts +1 -1
  6. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  7. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  8. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  9. package/src/components/resources/ResourcesView.tsx +4 -0
  10. package/src/components/resources/printer-columns.test.ts +1 -1
  11. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  12. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  13. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  14. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  15. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  16. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  17. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  18. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  19. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  21. package/src/components/resources/renderers/index.ts +2 -0
  22. package/src/components/resources/resource-utils-kueue.ts +14 -0
  23. package/src/components/resources/resource-utils-ray.ts +14 -4
  24. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  25. package/src/components/shared/ResourceActionsBar.tsx +4 -2
  26. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  27. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  28. package/src/components/shared/SetImageDialog.tsx +4 -2
  29. package/src/components/shared/revision-role-badges.test.ts +1 -1
  30. package/src/components/trace/reachGraphModel.test.ts +1 -0
  31. package/src/components/trace/reachInspector.test.ts +4 -9
  32. package/src/components/ui/ConfirmDialog.tsx +5 -3
  33. package/src/components/ui/DialogPortal.test.tsx +279 -0
  34. package/src/components/ui/DialogPortal.tsx +147 -9
  35. package/src/components/ui/YamlReview.tsx +4 -1
  36. package/src/components/ui/drawer-components.test.tsx +14 -1
  37. package/src/components/ui/drawer-components.tsx +50 -40
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.12",
3
+ "version": "1.15.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, useId } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { GitCompare, Search, X } from 'lucide-react'
4
4
  import { DialogPortal } from '../ui/DialogPortal'
@@ -33,6 +33,7 @@ export function CompareResourcePicker({
33
33
  error,
34
34
  onPick,
35
35
  }: CompareResourcePickerProps) {
36
+ const titleId = useId()
36
37
  const [query, setQuery] = useState('')
37
38
  const [highlightIdx, setHighlightIdx] = useState(0)
38
39
  const listRef = useRef<HTMLUListElement | null>(null)
@@ -90,11 +91,11 @@ export function CompareResourcePicker({
90
91
  const sourceChipBg = SIDE_TONES[sourceSide].chipBg
91
92
 
92
93
  return (
93
- <DialogPortal open={open} onClose={onClose} className="max-w-xl w-full max-h-[70vh] flex flex-col">
94
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="max-w-xl w-full max-h-[70vh] flex flex-col">
94
95
  <div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
95
96
  <div className="flex items-center gap-2">
96
97
  <GitCompare className="w-5 h-5 text-skyhook-400" />
97
- <h3 className="text-sm font-semibold text-theme-text-primary">
98
+ <h3 id={titleId} className="text-sm font-semibold text-theme-text-primary">
98
99
  {sourceSide === 'a'
99
100
  ? `Compare to another ${pluralToKind(source.kind)}`
100
101
  : `Replace side B with another ${pluralToKind(source.kind)}`}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useId } from 'react'
2
2
  import { History, Loader2 } from 'lucide-react'
3
3
 
4
4
  import { DialogPortal } from '../ui/DialogPortal'
@@ -30,6 +30,7 @@ export interface RollbackDialogProps {
30
30
  }
31
31
 
32
32
  export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
33
+ const titleId = useId()
33
34
  const [prune, setPrune] = useState(false)
34
35
  const [dryRun, setDryRun] = useState(false)
35
36
 
@@ -41,9 +42,9 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
41
42
  }, [open])
42
43
 
43
44
  return (
44
- <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[440px]" closable={!pending}>
45
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={pending ? () => {} : onCancel} className="w-[440px]" closable={!pending}>
45
46
  <div className="border-b border-theme-border px-4 py-3">
46
- <h2 className="text-sm font-semibold text-theme-text-primary">Roll back application</h2>
47
+ <h2 id={titleId} className="text-sm font-semibold text-theme-text-primary">Roll back application</h2>
47
48
  <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
48
49
  </div>
49
50
  <div className="space-y-4 px-4 py-4 text-sm">
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, type ComponentType } from 'react'
1
+ import { useState, useEffect, type ComponentType, useId } from 'react'
2
2
  import { AlertTriangle, CheckCircle2, CircleAlert, Loader2, RefreshCw, ShieldCheck, XCircle } from 'lucide-react'
3
3
 
4
4
  import { DialogPortal } from '../ui/DialogPortal'
@@ -58,6 +58,7 @@ export interface ResourceValidationResult {
58
58
  }
59
59
 
60
60
  export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncEnabled, validationPending, operationInProgress, validationResult, validationError, onCancel, onConfirm, onValidate, onValidationReset }: SyncOptionsDialogProps) {
61
+ const titleId = useId()
61
62
  const [revision, setRevision] = useState('')
62
63
  const [prune, setPrune] = useState(true)
63
64
  const [dryRun, setDryRun] = useState(false)
@@ -97,9 +98,9 @@ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncE
97
98
  }
98
99
 
99
100
  return (
100
- <DialogPortal open={open} onClose={busy ? () => {} : onCancel} className="w-[480px]" closable={!busy}>
101
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={busy ? () => {} : onCancel} className="w-[480px]" closable={!busy}>
101
102
  <div className="border-b border-theme-border px-4 py-3">
102
- <h2 className="text-sm font-semibold text-theme-text-primary">{resource ? 'Sync resource' : 'Sync application'}</h2>
103
+ <h2 id={titleId} className="text-sm font-semibold text-theme-text-primary">{resource ? 'Sync resource' : 'Sync application'}</h2>
103
104
  <p className="mt-0.5 break-all text-xs text-theme-text-tertiary">{appLabel}</p>
104
105
  </div>
105
106
  <div className="space-y-4 px-4 py-4 text-sm">
@@ -30,7 +30,7 @@ describe('radarHealthNote', () => {
30
30
 
31
31
  test('hasRadarFinding reads the rows the markers read', () => {
32
32
  const row = (health: string, healthSource?: string) =>
33
- ({ ref: { kind: 'Deployment', namespace: 'p', name: 'w' }, category: 'Unknown', health, healthSource, hasDesired: false, hasLive: true }) as const
33
+ ({ ref: { kind: 'Deployment', namespace: 'p', name: 'w' }, category: 'Unknown', health, healthSource, hasDesired: false, hasLive: true, partial: false }) as const
34
34
  expect(hasRadarFinding(undefined)).toBe(false)
35
35
  expect(hasRadarFinding([row('Degraded', 'controller')])).toBe(false)
36
36
  expect(hasRadarFinding([row('Healthy', 'radar')])).toBe(false)
@@ -83,6 +83,7 @@ export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps
83
83
  <DialogPortal
84
84
  open={maximized}
85
85
  onClose={() => setMaximized(false)}
86
+ ariaLabel="Resource diff"
86
87
  className="dialog flex h-[90vh] w-full max-w-6xl flex-col"
87
88
  >
88
89
  <ArgoResourceDiffContent diff={diff} onClose={() => setMaximized(false)} />
@@ -1,3 +1,6 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
1
4
  import { renderToStaticMarkup } from 'react-dom/server'
2
5
  import { describe, expect, it } from 'vitest'
3
6
  import { KueueAdmissionSection } from './KueueAdmissionSection'
@@ -15,6 +18,40 @@ function render(data?: KueueAdmissionResponse, props: Partial<React.ComponentPro
15
18
  }
16
19
 
17
20
  describe('Kueue admission investigation', () => {
21
+ it('offers source navigation only when the Workload reference and navigation are available', async () => {
22
+ const data = response()
23
+ const navigated: unknown[] = []
24
+ const container = document.createElement('div')
25
+ const root = createRoot(container)
26
+ await act(async () => root.render(<KueueAdmissionSection data={data} loading={false} hinted externalExecution={false} onNavigate={(target) => navigated.push(target)} />))
27
+ const button = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'View Workload details')!
28
+ await act(async () => button.click())
29
+ expect(navigated).toEqual([{ ...ref, kind: 'workloads' }])
30
+ await act(async () => root.unmount())
31
+ expect(render(data, { onNavigate: undefined })).not.toContain('View Workload details')
32
+ delete data.workloads[0].ref
33
+ expect(render(data)).not.toContain('View Workload details')
34
+ })
35
+
36
+ it('shows supplied gate transition times without inventing missing timestamps', () => {
37
+ const lastTransitionTime = '2026-09-26T10:00:00Z'
38
+ const gate = { kind: 'admission_check', name: 'capacity', decision: 'unsatisfied' as const, nativeState: 'Pending' }
39
+ expect(render(response({ gates: [{ ...gate, lastTransitionTime }] }))).toContain(`dateTime="${lastTransitionTime}"`)
40
+ expect(render(response({ gates: [gate] }))).not.toContain('Status since')
41
+ })
42
+
43
+ it('shows record creation times only when distinguishing multiple Workloads', () => {
44
+ const data = response()
45
+ data.workloads[0].createdAt = '2026-09-26T09:00:00Z'
46
+ expect(render(data)).not.toContain('Created <time')
47
+ data.total = 2
48
+ data.truncated = true
49
+ expect(render(data)).toContain('Created <time')
50
+ expect(render(data)).toContain('order does not identify a current attempt')
51
+ data.workloads[0].createdAt = null
52
+ expect(render(data)).not.toContain('Created <time')
53
+ })
54
+
18
55
  it('hides confirmed empty unhinted roots but preserves unavailable and in-flight evidence', () => {
19
56
  const empty = { uid: 'root-uid', installed: true, total: 0, truncated: false, workloads: [] }
20
57
  expect(render(empty)).toBe('')
@@ -34,8 +71,8 @@ describe('Kueue admission investigation', () => {
34
71
  const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Inadmissible', message: 'ClusterQueue training is inactive', observedGeneration: 1 }, queues: [{ name: 'ready', roles: ['submission'] }] }))
35
72
  expect(html).toContain('Inadmissible')
36
73
  expect(html).toContain('ClusterQueue training is inactive')
37
- expect(html).toContain('stale evidence')
38
- expect(html).toContain('submission queue: ready')
74
+ expect(html).toContain('Stale evidence')
75
+ expect(html).toContain('Submission queue: ready')
39
76
  expect(html).not.toContain('suspended because')
40
77
  })
41
78
 
@@ -44,7 +81,7 @@ describe('Kueue admission investigation', () => {
44
81
  ] as const)('preserves native phase %s', (phase, label) => {
45
82
  const html = render(response({ decision: 'satisfied', kueue: { phase } }))
46
83
  expect(html).toContain(label)
47
- expect(html).toContain('does not prove that Jobs or Pods are running')
84
+ expect(html.includes('Admission status; execution is shown separately.')).toBe(phase === 'admitted' || phase === 'quota_reserved')
48
85
  })
49
86
 
50
87
  it('keeps disruptions, inactive state, check retries and requeues distinct', () => {
@@ -71,15 +108,15 @@ describe('Kueue admission investigation', () => {
71
108
  it('does not present an unrecognized finish reason as a successful outcome', () => {
72
109
  const html = render(response({ decision: 'unknown', primaryCondition: { type: 'Finished', status: 'True', reason: 'CustomFinish' }, kueue: { phase: 'finished' } }))
73
110
  expect(html).toContain('CustomFinish')
74
- expect(html).toContain('lucide-minus')
111
+ expect(html).toContain('Admission unknown')
75
112
  expect(html).not.toContain('lucide-check')
76
113
  })
77
114
 
78
115
  it('shows a deactivation condition once when it is also the primary evidence', () => {
79
116
  const condition = { type: 'DeactivationTarget', status: 'True', reason: 'AdmissionCheck' }
80
117
  const html = render(response({ decision: 'held', primaryCondition: condition, disruptions: [condition], kueue: { phase: 'pending', active: false } }))
81
- expect(html).toContain('Primary condition: DeactivationTarget')
82
- expect(html.match(/AdmissionCheck/g)).toHaveLength(1)
118
+ expect(html).toContain('DeactivationTarget=True')
119
+ expect(html).not.toContain('Reported disruptions')
83
120
  })
84
121
 
85
122
  it('keeps bounded records separate and never names the newest as current', () => {
@@ -93,3 +130,143 @@ describe('Kueue admission investigation', () => {
93
130
  expect(html).toContain('Deleting')
94
131
  })
95
132
  })
133
+
134
+
135
+ it('keeps actionable evidence visible while nominal evidence is inert until expanded', async () => {
136
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
137
+ const container = document.createElement('div')
138
+ const root = createRoot(container)
139
+ const data = response({
140
+ primaryCondition: { type: 'Admitted', status: 'True', reason: 'Admitted', message: 'Primary message' },
141
+ kueue: { phase: 'admitted', podsReady: { type: 'PodsReady', status: 'False', message: 'Workers not ready' }, waitingForReplacementPods: { type: 'WaitingForReplacementPods', status: 'False', message: 'No replacements needed' } },
142
+ gates: [
143
+ { kind: 'admission_check', name: 'ready-check', nativeState: 'Ready', decision: 'satisfied', message: 'Policy accepted' },
144
+ { kind: 'admission_check', name: 'reject-check', nativeState: 'Rejected', decision: 'unsatisfied', message: 'Policy limit exceeded', retryCount: 0 },
145
+ ],
146
+ })
147
+ try {
148
+ await act(async () => root.render(<KueueAdmissionSection data={data} loading={false} hinted externalExecution={false} />))
149
+ const text = (value: string) => [...container.querySelectorAll('p')].find((element) => element.textContent?.endsWith(value))!
150
+ expect(text('Workers not ready').closest('[inert]')).toBeNull()
151
+ expect(text('Policy limit exceeded').closest('[inert]')).toBeNull()
152
+ expect(text('No replacements needed').closest('[inert]')).not.toBeNull()
153
+ expect(text('Policy accepted').closest('[inert]')).not.toBeNull()
154
+ const toggle = [...container.querySelectorAll('button')].find((button) => button.textContent?.startsWith('Technical details'))!
155
+ expect(toggle.getAttribute('aria-expanded')).toBe('false')
156
+ expect(toggle.textContent).toContain('1 additional condition')
157
+ expect(toggle.textContent).toContain('1 ready check')
158
+ await act(async () => toggle.click())
159
+ expect(toggle.getAttribute('aria-expanded')).toBe('true')
160
+ expect(text('Policy accepted').closest('[inert]')).toBeNull()
161
+ expect(container.textContent?.match(/Primary message/g)).toHaveLength(1)
162
+ } finally { await act(async () => root.unmount()) }
163
+ })
164
+
165
+ it.each([
166
+ { type: 'PodsReady', status: 'True', observedGeneration: 1 },
167
+ { type: 'PodsReady', status: 'Unknown' },
168
+ { type: 'WaitingForReplacementPods', status: 'True' },
169
+ ])('does not hide stale or non-nominal supporting conditions: %j', async (condition) => {
170
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
171
+ const container = document.createElement('div')
172
+ const root = createRoot(container)
173
+ try {
174
+ await act(async () => root.render(<KueueAdmissionSection data={response({ kueue: { phase: 'pending', [condition.type === 'PodsReady' ? 'podsReady' : 'waitingForReplacementPods']: { ...condition, message: 'Needs attention' } } })} loading={false} hinted externalExecution={false} />))
175
+ const message = [...container.querySelectorAll('p')].find((element) => element.textContent?.endsWith('Needs attention'))!
176
+ expect(message.closest('[inert]')).toBeNull()
177
+ } finally { await act(async () => root.unmount()) }
178
+ })
179
+
180
+ it.each(['failed', undefined] as const)('never colors a finished %s outcome as successful admission', (outcome) => {
181
+ const html = render(response({ decision: 'satisfied', primaryCondition: { type: 'Finished', status: 'True' }, kueue: { phase: 'finished', outcome, podsReady: { type: 'PodsReady', status: 'False', message: 'No running pods' } } }))
182
+ expect(html).not.toContain('bg-emerald')
183
+ expect(html).toContain('Pod readiness')
184
+ expect(html).toContain('No running pods')
185
+ })
186
+
187
+
188
+ it('uses an expanded Section in drawers and keeps the default fullscreen card', async () => {
189
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
190
+ const container = document.createElement('div')
191
+ const root = createRoot(container)
192
+ try {
193
+ await act(async () => root.render(<KueueAdmissionSection presentation="drawer" data={response()} loading={false} hinted externalExecution={false} />))
194
+ const toggle = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Kueue admission')!
195
+ expect(toggle.getAttribute('aria-expanded')).toBe('true')
196
+ expect(container.querySelector('section[aria-label="Kueue admission"]')).toBeNull()
197
+ await act(async () => toggle.click())
198
+ expect(toggle.getAttribute('aria-expanded')).toBe('false')
199
+ expect(container.querySelector('article')!.closest('[inert]')).not.toBeNull()
200
+ await act(async () => root.render(<KueueAdmissionSection data={response()} loading={false} hinted externalExecution={false} />))
201
+ expect(container.querySelector('section[aria-label="Kueue admission"]')).not.toBeNull()
202
+ } finally { await act(async () => root.unmount()) }
203
+ })
204
+
205
+
206
+ it('does not color an open preemption gate as an admission warning', () => {
207
+ const html = render(response({ decision: 'satisfied', kueue: { phase: 'admitted' }, gates: [{ kind: 'preemption_gate', name: 'preemption-policy', nativeState: 'Open', decision: 'satisfied' }] }))
208
+ expect(html).toContain('Open')
209
+ expect(html).not.toContain('bg-amber')
210
+ expect(html).toContain('alone does not establish an admission blocker')
211
+ })
212
+
213
+
214
+ it('preserves producer check order and keeps operational delays visible while zero metadata collapses', async () => {
215
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
216
+ const container = document.createElement('div')
217
+ const root = createRoot(container)
218
+ const data = response({
219
+ primaryCondition: { type: 'QuotaReserved', status: 'True', reason: 'ReservedNativeReason', message: 'Waiting for checks' },
220
+ gates: [
221
+ { kind: 'admission_check', name: 'rejected', nativeState: 'Rejected', decision: 'unsatisfied', message: 'Policy rejected' },
222
+ { kind: 'admission_check', name: 'retry', nativeState: 'Retry', decision: 'unsatisfied', retryCount: 3, requeueAfterSeconds: 60 },
223
+ { kind: 'admission_check', name: 'pending', nativeState: 'Pending', decision: 'unsatisfied', retryCount: 0 },
224
+ ],
225
+ disruptions: [{ type: 'Evicted', status: 'True', reason: 'NativeEviction', message: 'Eviction message', lastTransitionTime: '2026-09-26T00:00:00Z' }],
226
+ kueue: { phase: 'quota_reserved', podsReady: { type: 'PodsReady', status: 'Unknown', message: 'Readiness message' }, requeueState: { count: 0 } },
227
+ })
228
+ try {
229
+ await act(async () => root.render(<KueueAdmissionSection data={data} loading={false} hinted externalExecution={false} />))
230
+ const checks = container.querySelector('section[aria-label="Admission checks"]')!
231
+ expect(checks.textContent!.indexOf('rejected')).toBeLessThan(checks.textContent!.indexOf('retry'))
232
+ expect(checks.textContent!.indexOf('retry')).toBeLessThan(checks.textContent!.indexOf('pending'))
233
+ const paragraph = (value: string) => [...container.querySelectorAll('p')].find(e => e.textContent?.includes(value))!
234
+ expect(paragraph('Retries: 3').closest('[inert]')).toBeNull()
235
+ expect(paragraph('Requeue delay: 60s').closest('[inert]')).toBeNull()
236
+ expect(paragraph('Retries: 0').closest('[inert]')).not.toBeNull()
237
+ expect(paragraph('Requeues: 0').closest('[inert]')).not.toBeNull()
238
+ expect(paragraph('ReservedNativeReason').closest('[inert]')).not.toBeNull()
239
+ expect(paragraph('Readiness message').textContent).toContain('Pods ready: Unknown')
240
+ expect(paragraph('Eviction message').querySelector('span.block')?.textContent).toMatch(/^Evicted · Status since .+ ago$/)
241
+ expect(paragraph('Eviction message').lastChild?.textContent).toBe('Eviction message')
242
+ expect([...container.querySelectorAll('h4, h5')].map(e => e.textContent)).toEqual(['Admission', 'Admission checks · 3 not ready', 'Supporting evidence', 'Reported disruptions · 1', 'Pod readiness · 1'])
243
+ } finally { await act(async () => root.unmount()) }
244
+ })
245
+
246
+
247
+ it('labels a missing native check state Unknown rather than displaying its decision as a state', () => {
248
+ const html = render(response({ gates: [{ kind: 'admission_check', name: 'unknown-check', decision: 'unsatisfied', message: 'Controller has not reported a state' }] }))
249
+ expect(html).toContain('>Unknown</span>')
250
+ expect(html).toContain('Controller has not reported a state')
251
+ })
252
+
253
+
254
+ it('shows requeue eligibility without inventing a count or a leading separator', () => {
255
+ const html = render(response({ kueue: { phase: 'pending', requeueState: { requeueAt: '2026-09-27T00:00:00Z' } } }))
256
+ expect(html).toContain('>Eligible again: 2026-09-27T00:00:00Z</p>')
257
+ expect(html).not.toContain('Requeues:')
258
+ })
259
+
260
+
261
+ it('shows primary-condition polarity next to its transition age', () => {
262
+ const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', message: 'Insufficient quota', lastTransitionTime: '2026-09-26T00:00:00Z' } }))
263
+ expect(html).toContain('QuotaReserved=False since')
264
+ })
265
+
266
+
267
+ it.each([false, true, undefined])('suppresses the held qualifier only when inactive already explains it: %s', (active) => {
268
+ const html = render(response({ decision: 'held', kueue: { phase: 'quota_reserved', active } }))
269
+ expect(html.includes('Admission held')).toBe(active !== false)
270
+ expect(html.includes('Workload inactive')).toBe(active === false)
271
+ expect(html).toContain('Decision: held')
272
+ })
@@ -1,87 +1,176 @@
1
- import { isKueueConditionStale } from './resource-utils-kueue'
1
+ import { admissionCheckSeverity, isKueueConditionStale } from './resource-utils-kueue'
2
+ import { Disclosure } from '../ui/Disclosure'
3
+ import { formatRelativeAgeTime } from '../../utils/format'
2
4
  import { Badge } from '../ui/Badge'
3
- import { AlertBanner, ConditionsSection, ResourceLink } from '../ui/drawer-components'
5
+ import { AlertBanner, ResourceLink, Section } from '../ui/drawer-components'
4
6
  import { kindToPluralWithGroup } from '../../utils/navigation'
5
- import type { KueueAdmissionResponse, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
7
+ import type { KueueAdmissionResponse, SchedulingCondition, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
6
8
 
7
9
  interface KueueAdmissionSectionProps {
10
+ presentation?: 'card' | 'drawer'
8
11
  data?: KueueAdmissionResponse
9
12
  loading: boolean
10
13
  error?: string
11
14
  forbidden?: boolean
12
15
  hinted: boolean
13
16
  externalExecution: boolean
17
+ hasOwner?: boolean
14
18
  onRetry?: () => void
15
19
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
16
20
  }
17
21
 
18
22
  const phases = { pending: 'Pending admission', quota_reserved: 'Quota reserved', admitted: 'Admitted', finished: 'Finished' }
19
23
 
20
- export function KueueAdmissionSection({ data, loading, error, forbidden, hinted, externalExecution, onRetry, onNavigate }: KueueAdmissionSectionProps) {
24
+ export function KueueAdmissionSection({ presentation = 'card', data, loading, error, forbidden, hinted, externalExecution, hasOwner, onRetry, onNavigate }: KueueAdmissionSectionProps) {
21
25
  if (!loading && !error && data && data.workloads.length === 0 && !hinted) return null
22
26
  const link = (name: string, ref?: SchedulingRef) => ref
23
27
  ? <ResourceLink {...ref} kind={kindToPluralWithGroup(ref.kind, ref.group ?? '')} label={name} onNavigate={onNavigate} />
24
28
  : name
25
- return (
26
- <section className="rounded-lg border border-theme-border bg-theme-surface p-4" aria-label="Kueue admission">
27
- <h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>
28
- <p className="mt-1 text-xs text-theme-text-secondary">Admission and execution are separate observations. An admitted Workload does not prove that Jobs or Pods are running.</p>
29
- {loading ? <p className="mt-3 text-sm text-theme-text-secondary">Looking for controller-owned Workloads…</p>
30
- : error ? <div className="mt-3 [&>div]:mb-0"><AlertBanner variant={forbidden ? 'info' : 'warning'} title={forbidden ? 'Admission lookup requires permission' : 'Admission evidence unavailable'} message={error}>{!forbidden && onRetry && <button type="button" className="mt-2 text-accent-text hover:underline" onClick={onRetry}>Retry admission lookup</button>}</AlertBanner></div>
31
- : data && !data.installed ? <p className="mt-3 text-sm text-theme-text-secondary">Kueue Workloads are not served by this cluster.</p>
32
- : data && data.workloads.length === 0 ? <p className="mt-3 text-sm text-theme-text-secondary">No controller-owned Kueue Workload observed in this namespace.{externalExecution ? ' This JobSet uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
33
- : data && <div className="mt-3 space-y-3">
29
+ const spacing = presentation === 'drawer' ? '' : 'mt-3'
30
+ const content = <>
31
+ {loading ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Looking for controller-owned Workloads…</p>
32
+ : error ? <div className={`${spacing} [&>div]:mb-0`}><AlertBanner variant={forbidden ? 'info' : 'warning'} title={forbidden ? 'Admission lookup requires permission' : 'Admission evidence unavailable'} message={error}>{!forbidden && onRetry && <button type="button" className="mt-2 text-accent-text hover:underline" onClick={onRetry}>Retry admission lookup</button>}</AlertBanner></div>
33
+ : data && !data.installed ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Kueue Workloads are not served by this cluster.</p>
34
+ : data && data.workloads.length === 0 ? <p className={`${spacing} text-sm text-theme-text-secondary`}>No controller-owned Kueue Workload observed in this namespace.{hasOwner ? ' Admission may be tracked on the owning resource; use the existing owner link to inspect it.' : externalExecution ? ' This workload uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
35
+ : data && <div className={`${spacing} space-y-3`}>
34
36
  {data.total > 1 && <p className="text-xs text-theme-text-secondary">{data.truncated ? `${data.workloads.length} of ${data.total}` : data.total} Workloads shown, newest first. Each is a separate controller-owned record; order does not identify a current attempt.</p>}
35
- {data.workloads.map((workload) => <article key={workload.uid} className="card-inner space-y-2 text-sm">
36
- <div className="flex flex-wrap items-center gap-2"><span className="font-medium">{link(workload.name, workload.ref)}</span>{workload.deleting && <Badge severity="alert">Deleting</Badge>}<span className="text-xs text-theme-text-tertiary">Generation {workload.generation}</span></div>
37
+ {data.workloads.map((workload) => <article key={workload.uid} className="space-y-2 text-sm border-t border-theme-border pt-3 first:border-0 first:pt-0">
38
+ {data.total > 1 && workload.createdAt && <p className="text-xs text-theme-text-tertiary">Created <time dateTime={workload.createdAt} title={workload.createdAt}>{formatRelativeAgeTime(workload.createdAt)}</time></p>}
39
+ {workload.projection !== 'available' && <div className="flex flex-wrap items-center gap-2">{link(workload.name, workload.ref)}{workload.deleting && <Badge severity="alert">Deleting</Badge>}</div>}
37
40
  {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>
38
41
  : 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>
39
- : workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} observation={observation} link={link} />)}
42
+ : 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} />)}
40
43
  {workload.linksLimited && <p className="text-xs text-theme-text-tertiary">Some resource links are unavailable. Names already reported by the Workload remain visible.</p>}
41
44
  </article>)}
42
45
  </div>}
43
- </section>
44
- )
46
+ </>
47
+ return presentation === 'drawer'
48
+ ? <Section title="Kueue admission">{content}</Section>
49
+ : <section className="space-y-3" aria-label="Kueue admission"><h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>{content}</section>
50
+ }
51
+
52
+ type Link = (name: string, ref?: SchedulingRef) => React.ReactNode
53
+
54
+
55
+ const evidenceCard = 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface'
56
+ const cardHeader = 'border-b border-theme-border bg-theme-elevated px-3 py-2.5'
57
+ const groupHeading = 'text-xs font-medium uppercase tracking-wider text-theme-text-secondary'
58
+ type Gate = NonNullable<SchedulingObservation['gates']>[number]
59
+
60
+ function conditionLabel(condition: SchedulingCondition): string {
61
+ const { type, status } = condition
62
+ if (type === 'PodsReady') return `Pods ready: ${status === 'True' ? 'yes' : status === 'False' ? 'no' : status}`
63
+ if (type === 'WaitingForReplacementPods') return `Replacement Pods: ${status === 'True' ? 'waiting' : status === 'False' ? 'not waiting' : status}`
64
+ if (status === 'True') {
65
+ if (type === 'Evicted') return 'Evicted'
66
+ if (type === 'Preempted') return 'Preempted'
67
+ if (type === 'DeactivationTarget') return 'Deactivation requested'
68
+ }
69
+ return `${type}=${status}`
70
+ }
71
+
72
+ function ConditionEvidence({ condition, generation, showAge }: { condition: SchedulingCondition; generation?: number; showAge?: boolean }) {
73
+ return <div className="space-y-1 py-3 first:pt-0 last:pb-0">
74
+ <p className="max-w-3xl whitespace-pre-wrap break-words text-theme-text-secondary">
75
+ <span className="block font-medium text-theme-text-primary">{conditionLabel(condition)}{showAge && condition.lastTransitionTime && <span className="font-normal text-xs text-theme-text-tertiary"> · Status since {formatRelativeAgeTime(condition.lastTransitionTime)}</span>}</span>
76
+ {condition.message || condition.reason || `${condition.type}=${condition.status}`}
77
+ </p>
78
+ {isKueueConditionStale(condition, generation) && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition.observedGeneration}; Workload generation {generation}.</p>}
79
+ </div>
80
+ }
81
+
82
+ function GateEvidence({ gate, link }: { gate: Gate; link: Link }) {
83
+ const retryMetadata = [gate.retryCount != null && gate.retryCount > 0 ? `Retries: ${gate.retryCount}` : null, gate.requeueAfterSeconds != null && gate.requeueAfterSeconds > 0 ? `Requeue delay: ${gate.requeueAfterSeconds}s` : null].filter(Boolean)
84
+ return <div className="space-y-2 py-3">
85
+ <div className="grid grid-cols-[minmax(0,1fr)_6rem] items-start gap-2">
86
+ <span className="min-w-0 break-words font-medium [&_button]:text-left">{link(gate.name, gate.ref)}</span>
87
+ <Badge className="justify-self-end" severity={gate.kind === 'preemption_gate' ? 'neutral' : admissionCheckSeverity(gate.nativeState)}>{gate.nativeState || 'Unknown'}</Badge>
88
+ </div>
89
+ {gate.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">Status since <time dateTime={gate.lastTransitionTime} title={gate.lastTransitionTime}>{formatRelativeAgeTime(gate.lastTransitionTime)}</time></p>}
90
+ {gate.message && <p className="max-w-3xl whitespace-pre-wrap break-words">{gate.message}</p>}
91
+ {retryMetadata.length > 0 && <p className="text-xs text-theme-text-secondary">{retryMetadata.join(' · ')}</p>}
92
+ </div>
45
93
  }
46
94
 
47
- function AdmissionObservation({ observation, link }: { observation: SchedulingObservation; link: (name: string, ref?: SchedulingRef) => React.ReactNode }) {
95
+ 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 }) {
48
96
  const kueue = observation.kueue
49
97
  const condition = observation.primaryCondition
50
98
  const stale = isKueueConditionStale(condition, observation.subjectGeneration)
51
- const conditions = [condition, ...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
99
+ const secondary = [...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
52
100
  .filter((entry) => entry != null)
101
+ .filter((entry) => entry.type !== condition?.type)
53
102
  .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.type === entry.type) === index)
54
- return <div className="space-y-2">
55
- <div className="flex flex-wrap items-center gap-2">
56
- <Badge severity={stale || observation.decision === 'unknown' ? 'neutral' : observation.decision === 'satisfied' ? 'success' : 'warning'}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
57
- <span className="text-theme-text-secondary">Decision: {observation.decision}</span>
58
- {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
59
- {kueue?.active === false && <span>Workload inactive</span>}
103
+ const nominal = (entry: SchedulingCondition) => !isKueueConditionStale(entry, observation.subjectGeneration) && ((entry.type === 'PodsReady' && entry.status === 'True') || (entry.type === 'WaitingForReplacementPods' && entry.status === 'False'))
104
+ const supporting = secondary.filter(nominal)
105
+ const visible = secondary.filter((entry) => !nominal(entry))
106
+ const isDisruption = (entry: SchedulingCondition) => observation.disruptions?.some((item) => item.type === entry.type)
107
+ const disruptions = visible.filter(isDisruption)
108
+ const readiness = visible.filter((entry) => !isDisruption(entry))
109
+ const checks = (observation.gates ?? []).filter((gate) => gate.kind !== 'preemption_gate')
110
+ const pendingChecks = checks.filter((gate) => gate.nativeState !== 'Ready')
111
+ const readyChecks = checks.filter((gate) => gate.nativeState === 'Ready')
112
+ const preemption = (observation.gates ?? []).filter((gate) => gate.kind === 'preemption_gate')
113
+ const split = presentation === 'card' && (visible.length > 0 || preemption.length > 0)
114
+ const severity = stale || observation.decision === 'unknown' ? 'neutral'
115
+ : 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
118
+ return <div className="@container/admission space-y-4">
119
+ <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
+ <div className="min-w-0 space-y-4">
121
+ <section aria-label="Admission" className={evidenceCard}>
122
+ <h4 className={`${groupHeading} ${cardHeader}`}>Admission</h4>
123
+ <div className="space-y-2 p-3">
124
+ <div className="flex flex-wrap items-center gap-2">
125
+ <span className="font-medium break-all">{identity}</span>
126
+ <Badge severity={severity}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
127
+ {qualifier && <span className="text-xs text-theme-text-secondary">{qualifier}</span>}
128
+ {kueue?.active === false && <Badge severity="neutral">Workload inactive</Badge>}
129
+ {deleting && <Badge severity="alert">Deleting</Badge>}
130
+ {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
131
+ </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>}
135
+ <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
+ {sourceLink && <span>{sourceLink}</span>}
137
+ {(!!observation.queues?.length || kueue?.concurrentAdmission) && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-theme-text-secondary">
138
+ {observation.queues?.map((queue, index) => <span key={index}>{queue.roles.join(' + ').replace(/^./, (letter) => letter.toUpperCase())} queue: {link(queue.name, queue.ref)}</span>)}
139
+ {kueue?.concurrentAdmission && <span>Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</span>}
140
+ </div>}
141
+ {kueue?.requeueState && ((kueue.requeueState.count ?? 0) > 0 || kueue.requeueState.requeueAt) && <p className="text-xs text-theme-text-secondary">{kueue.requeueState.count != null && `Requeues: ${kueue.requeueState.count}`}{kueue.requeueState.requeueAt && `${kueue.requeueState.count != null ? ' · ' : ''}Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
142
+ {(kueue?.phase === 'admitted' || kueue?.phase === 'quota_reserved') && <p className="text-xs text-theme-text-tertiary">Admission status; execution is shown separately.</p>}
143
+ </div>
144
+ </div>
145
+ <div className="border-t border-theme-border p-3">
146
+ <Disclosure summary={<span>Technical details{supporting.length > 0 && ` · ${supporting.length} additional condition${supporting.length === 1 ? '' : 's'}`}{readyChecks.length > 0 && ` · ${readyChecks.length} ready check${readyChecks.length === 1 ? '' : 's'}`}</span>} summaryClassName="text-xs text-theme-text-secondary">
147
+ <div className="space-y-2 pt-2 text-xs text-theme-text-secondary">
148
+ <p>Decision: {observation.decision} · Generation {generation}</p>
149
+ {[condition, ...secondary].filter((entry) => entry != null).map((entry) => <div key={entry.type}>
150
+ <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>}
152
+ </div>)}
153
+ {(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
+ {kueue?.requeueState?.count === 0 && !kueue.requeueState.requeueAt && <p>Requeues: 0</p>}
155
+ {readyChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}
156
+ </div>
157
+ </Disclosure>
158
+ </div>
159
+ </section>
160
+ {pendingChecks.length > 0 && <section aria-label="Admission checks" className={evidenceCard}>
161
+ <h4 className={`${groupHeading} ${cardHeader}`}>Admission checks · {pendingChecks.length} not ready</h4>
162
+ <div className="divide-y divide-theme-border [&>div]:p-3">{pendingChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div>
163
+ </section>}
60
164
  </div>
61
- {stale && <p className="text-sm text-theme-text-secondary">The primary condition describes generation {condition!.observedGeneration}; this Workload is now generation {observation.subjectGeneration}. Treat that condition as stale evidence.</p>}
62
- {condition && <p className="text-xs text-theme-text-secondary">Selected admission evidence · Primary condition: {condition.type}</p>}
63
- {!condition && <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
64
- <ConditionsSection
65
- conditions={conditions}
66
- defaultExpanded={true}
67
- getConditionTone={(entry) => {
68
- if (entry.status !== 'True' && entry.status !== 'False') return 'unknown'
69
- if (isKueueConditionStale(entry, observation.subjectGeneration)) return 'unknown'
70
- if (entry.type === 'Finished') return entry.status === 'False' ? 'unknown' : kueue?.outcome === 'failed' ? 'fail' : kueue?.outcome === 'succeeded' ? 'ok' : 'unknown'
71
- if (entry.type === condition?.type) return observation.decision === 'satisfied' ? 'ok' : observation.decision === 'unknown' ? 'unknown' : 'warning'
72
- if (entry.type === 'WaitingForReplacementPods' || observation.disruptions?.some((item) => item.type === entry.type)) return entry.status === 'True' ? 'warning' : 'ok'
73
- return entry.status === 'True' ? 'ok' : 'warning'
74
- }}
75
- />
76
- {!!observation.queues?.length && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs">{observation.queues.map((queue, index) => <span key={index}>{queue.roles.join(' + ')} queue: {link(queue.name, queue.ref)}</span>)}</div>}
77
- {observation.gates?.map((gate, index) => <div key={index} className="border-l-2 border-theme-border pl-3 text-xs text-theme-text-secondary">
78
- <p>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'}: {link(gate.name, gate.ref)} · {gate.nativeState || gate.decision}</p>
79
- {gate.message && <p className="whitespace-pre-wrap break-words">{gate.message}</p>}
80
- {gate.kind === 'preemption_gate' && <p>This gate governs preemption; it alone does not establish an admission blocker.</p>}
81
- {gate.retryCount != null && <p>Retries: {gate.retryCount}</p>}
82
- {gate.requeueAfterSeconds != null && <p>Requeue delay: {gate.requeueAfterSeconds}s</p>}
83
- </div>)}
84
- {kueue?.requeueState && <p className="text-xs text-theme-text-secondary">Requeues: {kueue.requeueState.count ?? 'Not reported'}{kueue.requeueState.requeueAt && ` · Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
85
- {kueue?.concurrentAdmission && <p className="text-xs">Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</p>}
165
+ {(visible.length > 0 || preemption.length > 0) && <section aria-label="Supporting evidence" className={`${evidenceCard} self-start`}>
166
+ <h4 className={`${groupHeading} ${cardHeader}`}>Supporting evidence</h4>
167
+ <div className="divide-y divide-theme-border px-3">
168
+ {disruptions.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Reported disruptions · {disruptions.length}</h5><div className="divide-y divide-theme-border">{disruptions.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} showAge />)}</div></div>}
169
+ {readiness.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Pod readiness · {readiness.length}</h5><div className="divide-y divide-theme-border">{readiness.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} />)}</div></div>}
170
+ {preemption.length > 0 && <div className="space-y-2 py-3"><h5 className={groupHeading}>Preemption gates · {preemption.length}</h5><p className="text-xs text-theme-text-tertiary">Governs preemption; it alone does not establish an admission blocker.</p><div className="divide-y divide-theme-border">{preemption.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div></div>}
171
+ </div>
172
+ </section>}
173
+ </div>
174
+
86
175
  </div>
87
176
  }
@@ -6243,6 +6243,7 @@ export function ResourcesView({
6243
6243
  {onCreateResource && (
6244
6244
  <Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
6245
6245
  <button
6246
+ aria-label={`Create ${selectedKind.kind || 'resource'}`}
6246
6247
  onClick={() => onCreateResource(selectedKind)}
6247
6248
  className="p-2 hover:bg-theme-elevated rounded-lg text-theme-text-secondary hover:text-theme-text-primary transition-colors"
6248
6249
  >
@@ -6846,6 +6847,7 @@ export function ResourcesView({
6846
6847
  <div className="flex items-center justify-center gap-3">
6847
6848
  <button
6848
6849
  type="button"
6850
+ aria-label="Decrease replicas"
6849
6851
  onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
6850
6852
  disabled={bulkScaleReplicas <= 0}
6851
6853
  className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -6857,12 +6859,14 @@ export function ResourcesView({
6857
6859
  min="0"
6858
6860
  max="10000"
6859
6861
  value={bulkScaleReplicas}
6862
+ aria-label="Replicas"
6860
6863
  onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
6861
6864
  className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500"
6862
6865
  autoFocus
6863
6866
  />
6864
6867
  <button
6865
6868
  type="button"
6869
+ aria-label="Increase replicas"
6866
6870
  onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
6867
6871
  disabled={bulkScaleReplicas >= 10000}
6868
6872
  className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"