@skyhook-io/k8s-ui 1.14.11 → 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.
- package/package.json +2 -1
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/RollbackDialog.tsx +4 -3
- package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
- package/src/components/gitops/health-provenance.test.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
- package/src/components/resources/KueueAdmissionSection.tsx +140 -51
- package/src/components/resources/ResourcesView.tsx +4 -0
- package/src/components/resources/printer-columns.test.ts +1 -1
- package/src/components/resources/renderers/JobRenderer.tsx +5 -1
- package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
- package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils-kueue.ts +14 -0
- package/src/components/resources/resource-utils-ray.ts +14 -4
- package/src/components/shared/CreateResourceDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +4 -2
- package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
- package/src/components/shared/SetImageDialog.tsx +4 -2
- package/src/components/shared/revision-role-badges.test.ts +1 -1
- package/src/components/trace/reachGraphModel.test.ts +1 -0
- package/src/components/trace/reachInspector.test.ts +4 -9
- package/src/components/ui/ConfirmDialog.tsx +5 -3
- package/src/components/ui/DialogPortal.test.tsx +279 -0
- package/src/components/ui/DialogPortal.tsx +147 -9
- package/src/components/ui/YamlReview.tsx +4 -1
- package/src/components/ui/drawer-components.test.tsx +14 -1
- 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.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./src/components/resources/*.ts",
|
|
20
20
|
"./src/components/resources/*.tsx"
|
|
21
21
|
],
|
|
22
|
+
"./components/resources/KueueAdmissionSection": "./src/components/resources/KueueAdmissionSection.tsx",
|
|
22
23
|
"./components/resources/renderers/*": [
|
|
23
24
|
"./src/components/resources/renderers/*.tsx",
|
|
24
25
|
"./src/components/resources/renderers/*.ts"
|
|
@@ -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('
|
|
38
|
-
expect(html).toContain('
|
|
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
|
|
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('
|
|
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('
|
|
82
|
-
expect(html
|
|
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,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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="
|
|
36
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|