@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.
Files changed (72) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  6. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  7. package/src/components/gitops/RollbackDialog.tsx +4 -3
  8. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  9. package/src/components/gitops/health-provenance.test.ts +1 -1
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  12. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  13. package/src/components/issues/IssuesView.tsx +2 -2
  14. package/src/components/logs/LogCore.tsx +131 -97
  15. package/src/components/logs/StructuredLogLine.tsx +18 -28
  16. package/src/components/logs/log-palette.ts +4 -0
  17. package/src/components/logs/useLogBuffer.ts +8 -39
  18. package/src/components/logs/useLogSearch.ts +52 -24
  19. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  20. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  21. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  22. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  23. package/src/components/resources/ResourcesView.tsx +4 -0
  24. package/src/components/resources/printer-columns.test.ts +1 -1
  25. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  26. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  28. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  29. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  30. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  31. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  32. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  33. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  34. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  35. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  36. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  39. package/src/components/resources/renderers/index.ts +2 -0
  40. package/src/components/resources/resource-utils-kueue.ts +14 -0
  41. package/src/components/resources/resource-utils-ray.ts +14 -4
  42. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  43. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  44. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  45. package/src/components/shared/ResourceActionsBar.tsx +18 -11
  46. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  47. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  48. package/src/components/shared/SetImageDialog.tsx +4 -2
  49. package/src/components/shared/revision-role-badges.test.ts +1 -1
  50. package/src/components/trace/reachGraphModel.test.ts +1 -0
  51. package/src/components/trace/reachInspector.test.ts +4 -9
  52. package/src/components/ui/ConfirmDialog.tsx +5 -3
  53. package/src/components/ui/DialogPortal.test.tsx +279 -0
  54. package/src/components/ui/DialogPortal.tsx +147 -9
  55. package/src/components/ui/FilterPill.tsx +1 -1
  56. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  57. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  58. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  59. package/src/components/ui/ResourceBar.tsx +12 -6
  60. package/src/components/ui/SelectMenu.tsx +62 -19
  61. package/src/components/ui/YamlReview.tsx +4 -1
  62. package/src/components/ui/drawer-components.test.tsx +14 -1
  63. package/src/components/ui/drawer-components.tsx +50 -40
  64. package/src/components/ui/index.ts +8 -0
  65. package/src/components/workload/WorkloadView.tsx +109 -3
  66. package/src/types/core.ts +24 -4
  67. package/src/types/fetch-error.ts +33 -0
  68. package/src/utils/log-format.ts +1 -1
  69. package/src/utils/log-level.test.ts +269 -0
  70. package/src/utils/log-level.ts +301 -0
  71. package/src/utils/resource-hierarchy.test.ts +69 -0
  72. 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
- isFilterMode: boolean
15
- toggleFilterMode: () => void
16
- setFilterMode: (v: boolean) => void
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
- /** When filter mode is on, only matching entries */
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 [isFilterMode, setIsFilterMode] = useState(false)
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
- // Filtered entries for filter mode
76
- const filteredEntries = useMemo(() => {
77
- if (!isFilterMode || !deferredQuery) return entries
78
- const matchSet = new Set(matchIndices)
79
- return entries.filter((_, i) => matchSet.has(i))
80
- }, [entries, isFilterMode, deferredQuery, matchIndices])
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 (isFilterMode) {
98
- // In filter mode, match index maps directly to filtered list index
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: matchIdx,
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, isFilterMode, virtuosoRef])
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
- isFilterMode,
148
- toggleFilterMode,
149
- setFilterMode,
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, AlertTriangle } from 'lucide-react'
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
- return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
120
- }, [scope])
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
- : restrictedHint
215
- ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
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.' : 'No namespaces available.'}
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="No namespaces available."
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
- {!scope.authoritative && (
357
- <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
358
- Limited list — your RBAC doesn&rsquo;t allow listing all
359
- namespaces. Other namespaces may be accessible but won&rsquo;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('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
+ })