@skyhook-io/k8s-ui 1.14.12 → 1.16.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 +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- 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/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- 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/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- 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/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +18 -11
- 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/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- 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/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +24 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +87 -52
|
@@ -3,6 +3,8 @@ import type { VirtuosoHandle } from 'react-virtuoso'
|
|
|
3
3
|
import type { LogEntry } from './useLogBuffer'
|
|
4
4
|
import { stripAnsi, escapeRegExp } from '../../utils/log-format'
|
|
5
5
|
|
|
6
|
+
export type LogSearchMode = 'highlight' | 'only' | 'hide'
|
|
7
|
+
|
|
6
8
|
interface UseLogSearchReturn {
|
|
7
9
|
query: string
|
|
8
10
|
setQuery: (q: string) => void
|
|
@@ -11,14 +13,16 @@ interface UseLogSearchReturn {
|
|
|
11
13
|
setIsRegex: (v: boolean) => void
|
|
12
14
|
isCaseSensitive: boolean
|
|
13
15
|
toggleCaseSensitive: () => void
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/** highlight: show every line, mark matches · only: show matching lines · hide: drop matching lines */
|
|
17
|
+
mode: LogSearchMode
|
|
18
|
+
setMode: (mode: LogSearchMode) => void
|
|
19
|
+
/** Whether the query currently narrows the visible lines (only/hide with a non-empty, valid query) */
|
|
20
|
+
isFiltering: boolean
|
|
17
21
|
matchCount: number
|
|
18
22
|
currentMatch: number
|
|
19
23
|
/** Indices into the entries array that match */
|
|
20
24
|
matchIndices: number[]
|
|
21
|
-
/**
|
|
25
|
+
/** The lines left after applying the mode: matching lines for `only`, non-matching for `hide` */
|
|
22
26
|
filteredEntries: LogEntry[]
|
|
23
27
|
/** Error message when regex is invalid (null when valid) */
|
|
24
28
|
regexError: string | null
|
|
@@ -29,14 +33,32 @@ interface UseLogSearchReturn {
|
|
|
29
33
|
close: () => void
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The lines a search mode leaves visible. Only and Hide act on whole records:
|
|
38
|
+
* a match on any line of a stack trace keeps or hides the trace together.
|
|
39
|
+
*/
|
|
40
|
+
export function applySearchMode<T extends { id: number }>(
|
|
41
|
+
entries: readonly T[],
|
|
42
|
+
matchIndices: readonly number[],
|
|
43
|
+
mode: LogSearchMode,
|
|
44
|
+
recordIdOf: (entry: T) => number = e => e.id,
|
|
45
|
+
): T[] {
|
|
46
|
+
if (mode === 'highlight') return [...entries]
|
|
47
|
+
const matchedRecords = new Set(matchIndices.map(i => recordIdOf(entries[i])))
|
|
48
|
+
const keepMatches = mode === 'only'
|
|
49
|
+
return entries.filter(e => matchedRecords.has(recordIdOf(e)) === keepMatches)
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export function useLogSearch(
|
|
33
53
|
entries: LogEntry[],
|
|
34
54
|
virtuosoRef: React.RefObject<VirtuosoHandle | null>,
|
|
55
|
+
/** Id of the record an entry belongs to (its first line's id); defaults to the entry itself */
|
|
56
|
+
recordIdOf?: (entry: LogEntry) => number,
|
|
35
57
|
): UseLogSearchReturn {
|
|
36
58
|
const [query, setQuery] = useState('')
|
|
37
59
|
const [isRegex, setIsRegex] = useState(false)
|
|
38
60
|
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
|
39
|
-
const [
|
|
61
|
+
const [mode, setMode] = useState<LogSearchMode>('highlight')
|
|
40
62
|
const [currentMatch, setCurrentMatch] = useState(0)
|
|
41
63
|
const [isOpen, setIsOpen] = useState(false)
|
|
42
64
|
|
|
@@ -72,12 +94,17 @@ export function useLogSearch(
|
|
|
72
94
|
}
|
|
73
95
|
}, [entries, deferredQuery, isRegex, isCaseSensitive])
|
|
74
96
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
// Gate on the live query too, so clearing or closing search unfilters immediately
|
|
98
|
+
// rather than after the deferred value catches up.
|
|
99
|
+
const isFiltering = mode !== 'highlight' && !!query && !!deferredQuery && !regexError
|
|
100
|
+
const filteredEntries = useMemo(
|
|
101
|
+
() => (isFiltering ? applySearchMode(entries, matchIndices, mode, recordIdOf) : entries),
|
|
102
|
+
[entries, isFiltering, mode, matchIndices, recordIdOf],
|
|
103
|
+
)
|
|
104
|
+
const filteredIndexById = useMemo(() => {
|
|
105
|
+
if (mode !== 'only' || !isFiltering) return null
|
|
106
|
+
return new Map(filteredEntries.map((e, i) => [e.id, i]))
|
|
107
|
+
}, [mode, isFiltering, filteredEntries])
|
|
81
108
|
|
|
82
109
|
// Reset current match when search criteria change (but not when new entries arrive during streaming)
|
|
83
110
|
const prevCriteria = useRef({ query, isRegex, isCaseSensitive })
|
|
@@ -94,10 +121,13 @@ export function useLogSearch(
|
|
|
94
121
|
|
|
95
122
|
const scrollToMatch = useCallback((matchIdx: number) => {
|
|
96
123
|
if (matchIdx < 0 || matchIdx >= matchIndices.length) return
|
|
97
|
-
if (
|
|
98
|
-
|
|
124
|
+
if (mode === 'hide') return
|
|
125
|
+
if (mode === 'only') {
|
|
126
|
+
// The list holds whole matching records, so find the matched line within it
|
|
127
|
+
const index = filteredIndexById?.get(entries[matchIndices[matchIdx]].id)
|
|
128
|
+
if (index === undefined) return
|
|
99
129
|
virtuosoRef.current?.scrollToIndex({
|
|
100
|
-
index
|
|
130
|
+
index,
|
|
101
131
|
align: 'center',
|
|
102
132
|
behavior: 'smooth',
|
|
103
133
|
})
|
|
@@ -109,26 +139,24 @@ export function useLogSearch(
|
|
|
109
139
|
behavior: 'smooth',
|
|
110
140
|
})
|
|
111
141
|
}
|
|
112
|
-
}, [matchIndices,
|
|
142
|
+
}, [entries, matchIndices, mode, filteredIndexById, virtuosoRef])
|
|
113
143
|
|
|
114
144
|
const goToNext = useCallback(() => {
|
|
115
|
-
if (matchIndices.length === 0) return
|
|
145
|
+
if (matchIndices.length === 0 || mode === 'hide') return
|
|
116
146
|
const next = (currentMatch + 1) % matchIndices.length
|
|
117
147
|
setCurrentMatch(next)
|
|
118
148
|
scrollToMatch(next)
|
|
119
|
-
}, [currentMatch, matchIndices.length, scrollToMatch])
|
|
149
|
+
}, [currentMatch, matchIndices.length, mode, scrollToMatch])
|
|
120
150
|
|
|
121
151
|
const goToPrev = useCallback(() => {
|
|
122
|
-
if (matchIndices.length === 0) return
|
|
152
|
+
if (matchIndices.length === 0 || mode === 'hide') return
|
|
123
153
|
const prev = (currentMatch - 1 + matchIndices.length) % matchIndices.length
|
|
124
154
|
setCurrentMatch(prev)
|
|
125
155
|
scrollToMatch(prev)
|
|
126
|
-
}, [currentMatch, matchIndices.length, scrollToMatch])
|
|
156
|
+
}, [currentMatch, matchIndices.length, mode, scrollToMatch])
|
|
127
157
|
|
|
128
158
|
const toggleRegex = useCallback(() => setIsRegex(p => !p), [])
|
|
129
159
|
const toggleCaseSensitive = useCallback(() => setIsCaseSensitive(p => !p), [])
|
|
130
|
-
const toggleFilterMode = useCallback(() => setIsFilterMode(p => !p), [])
|
|
131
|
-
const setFilterMode = useCallback((v: boolean) => setIsFilterMode(v), [])
|
|
132
160
|
|
|
133
161
|
const open = useCallback(() => setIsOpen(true), [])
|
|
134
162
|
const close = useCallback(() => {
|
|
@@ -144,9 +172,9 @@ export function useLogSearch(
|
|
|
144
172
|
setIsRegex,
|
|
145
173
|
isCaseSensitive,
|
|
146
174
|
toggleCaseSensitive,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
175
|
+
mode,
|
|
176
|
+
setMode,
|
|
177
|
+
isFiltering,
|
|
150
178
|
matchCount: matchIndices.length,
|
|
151
179
|
currentMatch,
|
|
152
180
|
matchIndices,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, type ReactNode } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { NamespacePicker, type NamespacePickerProps, type NamespaceScopeView } from './NamespacePicker'
|
|
6
|
+
|
|
7
|
+
vi.mock('../ui/Tooltip', () => ({
|
|
8
|
+
Tooltip: ({ children, content }: { children: ReactNode; content: ReactNode }) => (
|
|
9
|
+
<>
|
|
10
|
+
{children}
|
|
11
|
+
<span data-testid="tooltip">{content}</span>
|
|
12
|
+
</>
|
|
13
|
+
),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
17
|
+
let root: Root
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
const element = document.createElement('div')
|
|
21
|
+
document.body.appendChild(element)
|
|
22
|
+
root = createRoot(element)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await act(async () => root.unmount())
|
|
27
|
+
document.body.replaceChildren()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const baseScope: NamespaceScopeView = {
|
|
31
|
+
actives: ['team-a'],
|
|
32
|
+
accessibleNamespaces: ['team-a'],
|
|
33
|
+
kubeconfigNamespace: 'team-a',
|
|
34
|
+
deniedNamespaces: [],
|
|
35
|
+
mode: 'namespace',
|
|
36
|
+
cacheScoped: false,
|
|
37
|
+
namespaceRescope: false,
|
|
38
|
+
canClearNamespace: true,
|
|
39
|
+
authoritative: false,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const help = <a data-testid="help">How to add namespaces</a>
|
|
43
|
+
|
|
44
|
+
async function renderOpen(scope: Partial<NamespaceScopeView>, props: Partial<NamespacePickerProps> = {}) {
|
|
45
|
+
await act(async () => {
|
|
46
|
+
root.render(<NamespacePicker scope={{ ...baseScope, ...scope }} onApply={vi.fn()} {...props} />)
|
|
47
|
+
})
|
|
48
|
+
const trigger = document.querySelector<HTMLButtonElement>('button[aria-label="Switch active namespaces"]')!
|
|
49
|
+
const warning = trigger.querySelector('.lucide-triangle-alert, .lucide-alert-triangle') !== null
|
|
50
|
+
const tooltip = document.querySelector('[data-testid="tooltip"]')?.textContent ?? ''
|
|
51
|
+
await act(async () => { trigger.click() })
|
|
52
|
+
return { warning, tooltip, text: document.body.textContent ?? '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('NamespacePicker incomplete-list notice', () => {
|
|
56
|
+
it.each(['namespace', 'restricted'] as const)('offers the host action for a non-authoritative list in %s mode', async (mode) => {
|
|
57
|
+
const { text } = await renderOpen({ mode }, { limitedListHelp: help })
|
|
58
|
+
expect(text).toContain('Missing a namespace?')
|
|
59
|
+
expect(document.querySelector('[data-testid="help"]')).not.toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('says nothing about the list when the host has no action to offer', async () => {
|
|
63
|
+
const { warning, text } = await renderOpen({})
|
|
64
|
+
expect(warning).toBe(false)
|
|
65
|
+
expect(text).not.toContain('Missing a namespace?')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('says nothing when the list is authoritative', async () => {
|
|
69
|
+
const { warning, text } = await renderOpen({ authoritative: true }, { limitedListHelp: help })
|
|
70
|
+
expect(warning).toBe(false)
|
|
71
|
+
expect(text).not.toContain('Missing a namespace?')
|
|
72
|
+
expect(document.querySelector('[data-testid="help"]')).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('points an empty list at the note below it', async () => {
|
|
76
|
+
const { text } = await renderOpen(
|
|
77
|
+
{ actives: [], accessibleNamespaces: [], mode: 'restricted' },
|
|
78
|
+
{ limitedListHelp: help },
|
|
79
|
+
)
|
|
80
|
+
expect(text).toContain('No namespaces yet.')
|
|
81
|
+
expect(text).toContain('Missing a namespace?')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// The picker can't tell an unconfigured list from a configured or timed-out
|
|
85
|
+
// one, so a standing warning icon would nag users who have nothing to fix.
|
|
86
|
+
it('never puts a warning icon on the trigger', async () => {
|
|
87
|
+
const { warning } = await renderOpen({ mode: 'namespace' }, { limitedListHelp: help })
|
|
88
|
+
expect(warning).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('says Radar can\u2019t list namespaces when the host can help and nothing is picked', async () => {
|
|
92
|
+
const { tooltip } = await renderOpen({ actives: [], mode: 'restricted' }, { limitedListHelp: help })
|
|
93
|
+
expect(tooltip).toBe('Radar can\u2019t list namespaces on this cluster.')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// A picked view keeps describing its filter; the footer carries the help.
|
|
97
|
+
it('keeps the filter tooltip once a namespace is picked', async () => {
|
|
98
|
+
const { tooltip, text } = await renderOpen({ mode: 'namespace' }, { limitedListHelp: help })
|
|
99
|
+
expect(tooltip).toBe('View is filtered to namespace team-a. Click to switch or reset.')
|
|
100
|
+
expect(text).toContain('Missing a namespace?')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Without a host action the list is usually the viewer's complete RBAC view
|
|
104
|
+
// (auth-enabled installs, Radar Cloud), so say what it is rather than warn.
|
|
105
|
+
it('describes an RBAC-scoped list with nothing picked', async () => {
|
|
106
|
+
const { tooltip, text } = await renderOpen({ actives: [], mode: 'restricted' })
|
|
107
|
+
expect(tooltip).toBe('Showing the namespaces your account can access.')
|
|
108
|
+
expect(text).not.toContain('Missing a namespace?')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('keeps the usual tooltip for an authoritative list', async () => {
|
|
112
|
+
const { tooltip } = await renderOpen({ actives: [], mode: 'cluster-wide', authoritative: true })
|
|
113
|
+
expect(tooltip).toBe('Currently viewing all namespaces. Click to narrow the view.')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The backend keeps saved picks through a failed namespace load, when the
|
|
117
|
+
// list it returns can be shorter than the pick.
|
|
118
|
+
it('lists a pick missing from the available namespaces so it can be unchecked', async () => {
|
|
119
|
+
const onApply = vi.fn()
|
|
120
|
+
await act(async () => {
|
|
121
|
+
root.render(
|
|
122
|
+
<NamespacePicker
|
|
123
|
+
scope={{ ...baseScope, actives: ['team-a', 'team-b'], accessibleNamespaces: ['team-a'] }}
|
|
124
|
+
onApply={onApply}
|
|
125
|
+
/>,
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
const trigger = document.querySelector<HTMLButtonElement>('button[aria-label="Switch active namespaces"]')!
|
|
129
|
+
await act(async () => { trigger.click() })
|
|
130
|
+
|
|
131
|
+
const row = Array.from(document.querySelectorAll('label')).find(l => l.textContent?.includes('team-b'))
|
|
132
|
+
const checkbox = row?.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
|
133
|
+
expect(checkbox?.checked).toBe(true)
|
|
134
|
+
|
|
135
|
+
await act(async () => { checkbox!.click() })
|
|
136
|
+
const done = Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Done')!
|
|
137
|
+
await act(async () => { done.click() })
|
|
138
|
+
expect(onApply).toHaveBeenCalledWith(['team-a'])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { ChevronDown, Globe, Search
|
|
3
|
+
import { ChevronDown, Globe, Search } from 'lucide-react'
|
|
4
4
|
import { Badge } from '../ui/Badge'
|
|
5
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
6
6
|
import { MultiSelectPicker } from '../ui/MultiSelectPicker'
|
|
@@ -57,6 +57,12 @@ export interface NamespacePickerProps {
|
|
|
57
57
|
variant?: 'chip' | 'segment'
|
|
58
58
|
/** Muted label shown before the value in the 'segment' variant (e.g. "Namespace"). */
|
|
59
59
|
label?: string
|
|
60
|
+
/**
|
|
61
|
+
* A short action (e.g. a "How to add namespaces" link) for when the list is
|
|
62
|
+
* not authoritative and the viewer can supply namespaces themselves. Omit it
|
|
63
|
+
* when the viewer can't act — the picker then says nothing about the list.
|
|
64
|
+
*/
|
|
65
|
+
limitedListHelp?: ReactNode
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -79,7 +85,7 @@ export interface NamespacePickerProps {
|
|
|
79
85
|
* onApply. "Clear all" applies immediately and closes.
|
|
80
86
|
*/
|
|
81
87
|
export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
|
|
82
|
-
{ scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label },
|
|
88
|
+
{ scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label, limitedListHelp },
|
|
83
89
|
ref,
|
|
84
90
|
) {
|
|
85
91
|
const [isOpen, setIsOpen] = useState(false)
|
|
@@ -116,8 +122,11 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
116
122
|
|
|
117
123
|
const items = useMemo(() => {
|
|
118
124
|
if (!scope) return [] as string[]
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
// A pick the backend kept through a failed namespace load can be missing
|
|
126
|
+
// from accessibleNamespaces; list it anyway so it can still be unchecked.
|
|
127
|
+
const all = new Set([...(scope.accessibleNamespaces ?? []), ...scopeActives])
|
|
128
|
+
return [...all].sort((a, b) => a.localeCompare(b))
|
|
129
|
+
}, [scope, scopeActives])
|
|
121
130
|
const deniedNamespaces = useMemo(() => new Set(scope?.deniedNamespaces ?? []), [scope?.deniedNamespaces])
|
|
122
131
|
|
|
123
132
|
const filteredItems = useMemo(() => {
|
|
@@ -201,8 +210,9 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
201
210
|
const triggerLabel =
|
|
202
211
|
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
203
212
|
const isClusterWide = activeCount === 0
|
|
204
|
-
const restrictedHint = scope.mode === 'restricted'
|
|
205
213
|
const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
|
|
214
|
+
const needsNamespaces = scope.authoritative === false && limitedListHelp != null
|
|
215
|
+
const emptyStateLabel = needsNamespaces ? 'No namespaces yet.' : 'No namespaces available.'
|
|
206
216
|
const isDisabled = disabled || loading || pending || cacheScopeLocked
|
|
207
217
|
const canClearAll = scope.canClearNamespace || activeCount === 0
|
|
208
218
|
const tooltipContent = disabled && disabledTooltip
|
|
@@ -211,8 +221,10 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
211
221
|
? scope.namespaceRescope
|
|
212
222
|
? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
|
|
213
223
|
: `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
|
|
214
|
-
:
|
|
215
|
-
? '
|
|
224
|
+
: needsNamespaces && isClusterWide
|
|
225
|
+
? 'Radar can\u2019t list namespaces on this cluster.'
|
|
226
|
+
: scope.authoritative === false && isClusterWide
|
|
227
|
+
? 'Showing the namespaces your account can access.'
|
|
216
228
|
: isClusterWide
|
|
217
229
|
? 'Currently viewing all namespaces. Click to narrow the view.'
|
|
218
230
|
: activeCount === 1
|
|
@@ -255,11 +267,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
255
267
|
{label && (
|
|
256
268
|
<span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
|
|
257
269
|
)}
|
|
258
|
-
{isClusterWide
|
|
259
|
-
<Globe className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
260
|
-
) : restrictedHint ? (
|
|
261
|
-
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
262
|
-
) : null}
|
|
270
|
+
{isClusterWide && <Globe className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />}
|
|
263
271
|
<span className={`font-medium truncate ${variant === 'segment' ? 'min-w-0' : 'max-w-[180px]'}`}>
|
|
264
272
|
{pending ? 'Switching…' : triggerLabel}
|
|
265
273
|
</span>
|
|
@@ -299,7 +307,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
299
307
|
<ul className="max-h-80 overflow-y-auto py-1">
|
|
300
308
|
{filteredItems.length === 0 && (
|
|
301
309
|
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
302
|
-
{search ? 'No matches.' :
|
|
310
|
+
{search ? 'No matches.' : emptyStateLabel}
|
|
303
311
|
</li>
|
|
304
312
|
)}
|
|
305
313
|
|
|
@@ -346,18 +354,16 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
346
354
|
onSearchChange={setSearch}
|
|
347
355
|
searchPlaceholder="Filter namespaces"
|
|
348
356
|
summaryEmptyLabel="All namespaces"
|
|
349
|
-
noItemsLabel=
|
|
357
|
+
noItemsLabel={emptyStateLabel}
|
|
350
358
|
clearAllDisabled={!canClearAll || activeCount === 0}
|
|
351
359
|
clearAllAriaLabel="Clear namespace selection"
|
|
352
360
|
renderItemMeta={renderNamespaceMeta}
|
|
353
361
|
/>
|
|
354
362
|
)}
|
|
355
363
|
|
|
356
|
-
{
|
|
357
|
-
<div className="px-3 py-2 border-t border-theme-border text-[11px]
|
|
358
|
-
|
|
359
|
-
namespaces. Other namespaces may be accessible but won’t
|
|
360
|
-
appear here until you switch context.
|
|
364
|
+
{needsNamespaces && (
|
|
365
|
+
<div className="px-3 py-2 border-t border-theme-border text-[11px] text-theme-text-secondary">
|
|
366
|
+
Missing a namespace? {limitedListHelp}
|
|
361
367
|
</div>
|
|
362
368
|
)}
|
|
363
369
|
</div>,
|
|
@@ -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
|
+
})
|