@skyhook-io/k8s-ui 1.15.0 → 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 (37) 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/gitops/GitOpsDetailLayout.tsx +3 -1
  5. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  8. package/src/components/issues/IssuesView.tsx +2 -2
  9. package/src/components/logs/LogCore.tsx +131 -97
  10. package/src/components/logs/StructuredLogLine.tsx +18 -28
  11. package/src/components/logs/log-palette.ts +4 -0
  12. package/src/components/logs/useLogBuffer.ts +8 -39
  13. package/src/components/logs/useLogSearch.ts +52 -24
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  16. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  17. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  18. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  19. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  20. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  21. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  22. package/src/components/shared/ResourceActionsBar.tsx +14 -9
  23. package/src/components/ui/FilterPill.tsx +1 -1
  24. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  25. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  26. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  27. package/src/components/ui/ResourceBar.tsx +12 -6
  28. package/src/components/ui/SelectMenu.tsx +62 -19
  29. package/src/components/ui/index.ts +8 -0
  30. package/src/components/workload/WorkloadView.tsx +109 -3
  31. package/src/types/core.ts +24 -4
  32. package/src/types/fetch-error.ts +33 -0
  33. package/src/utils/log-format.ts +1 -1
  34. package/src/utils/log-level.test.ts +269 -0
  35. package/src/utils/log-level.ts +301 -0
  36. package/src/utils/resource-hierarchy.test.ts +69 -0
  37. package/src/utils/resource-hierarchy.ts +87 -52
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
+ import { selectLevelField } from '../../utils/log-level'
4
5
  import { unescapeJsonStrings, parseLogfmt } from '../../utils/log-format'
5
6
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
6
7
 
@@ -65,7 +66,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
65
66
  className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
66
67
  >
67
68
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
68
- <SummaryLine obj={parsed} palette={palette} />
69
+ <SummaryLine obj={parsed} level={level} palette={palette} />
69
70
  <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
70
71
  </span>
71
72
  ) : (
@@ -76,7 +77,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
76
77
  className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5`}
77
78
  >
78
79
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
79
- <SummaryLine obj={parsed} palette={palette} />
80
+ <SummaryLine obj={parsed} level={level} palette={palette} />
80
81
  <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
81
82
  </span>
82
83
  <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
@@ -167,8 +168,8 @@ function JsonExpanded({ text, onFilterValue, palette }: { text: string; onFilter
167
168
  return <>{nodes}</>
168
169
  }
169
170
 
170
- function SummaryLine({ obj, palette }: { obj: Record<string, unknown>; palette: LogPalette }) {
171
- const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
171
+ function SummaryLine({ obj, level, palette }: { obj: Record<string, unknown>; level: LogLevel; palette: LogPalette }) {
172
+ const lvl = selectLevelField(obj)?.raw
172
173
  const msg = obj.msg ?? obj.message
173
174
  const rawErr = obj.error ?? obj.err
174
175
  const err = typeof rawErr === 'string'
@@ -179,8 +180,8 @@ function SummaryLine({ obj, palette }: { obj: Record<string, unknown>; palette:
179
180
  return (
180
181
  <>
181
182
  {lvl != null && (
182
- <span className={`${getLevelBadgeColor(lvl, palette)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
183
- {formatLevel(lvl)}
183
+ <span className={`${levelBadgeColor(level, palette)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
184
+ {formatLevel(lvl, level)}
184
185
  </span>
185
186
  )}
186
187
  {typeof msg === 'string' && (
@@ -221,30 +222,19 @@ function nestedField(obj: Record<string, unknown>, parent: string, child: string
221
222
  return undefined
222
223
  }
223
224
 
224
- function formatLevel(lvl: unknown): string {
225
- if (typeof lvl === 'number') {
226
- if (lvl >= 50) return 'ERR'
227
- if (lvl >= 40) return 'WARN'
228
- if (lvl >= 30) return 'INFO'
229
- return 'DBG'
230
- }
225
+ const NUMERIC_LEVEL_LABELS: Record<LogLevel, string> = { error: 'ERR', warn: 'WARN', info: 'INFO', debug: 'DBG', unknown: '?' }
226
+
227
+ function formatLevel(lvl: unknown, level: LogLevel): string {
228
+ if (typeof lvl === 'number') return NUMERIC_LEVEL_LABELS[level]
231
229
  return String(lvl).toUpperCase()
232
230
  }
233
231
 
234
- function getLevelBadgeColor(lvl: unknown, palette: LogPalette): string {
235
- let normalized: string
236
- if (typeof lvl === 'number') {
237
- // Pino/bunyan numeric levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
238
- if (lvl >= 50) normalized = 'error'
239
- else if (lvl >= 40) normalized = 'warn'
240
- else if (lvl >= 30) normalized = 'info'
241
- else normalized = 'debug'
242
- } else {
243
- normalized = String(lvl).toLowerCase()
232
+ function levelBadgeColor(level: LogLevel, palette: LogPalette): string {
233
+ switch (level) {
234
+ case 'error': return palette.levelBadgeError
235
+ case 'warn': return palette.levelBadgeWarn
236
+ case 'info': return palette.levelBadgeInfo
237
+ case 'debug': return palette.levelBadgeDebug
238
+ default: return palette.levelBadgeNeutral
244
239
  }
245
- if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return palette.levelBadgeError
246
- if (/^(warn|warning)$/.test(normalized)) return palette.levelBadgeWarn
247
- if (/^(info|information|notice)$/.test(normalized)) return palette.levelBadgeInfo
248
- if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return palette.levelBadgeDebug
249
- return palette.levelBadgeNeutral
250
240
  }
@@ -53,6 +53,8 @@ export interface LogPalette {
53
53
  levelActiveWarn: string
54
54
  levelActiveInfo: string
55
55
  levelActiveDebug: string
56
+ /** Lines with no recognizable level */
57
+ levelActiveOther: string
56
58
 
57
59
  // Level-badge colors used inside StructuredLogLine
58
60
  levelBadgeError: string
@@ -110,6 +112,7 @@ const DARK_PALETTE: LogPalette = {
110
112
  levelActiveWarn: 'bg-amber-500/20 text-amber-400 border-amber-500/40',
111
113
  levelActiveInfo: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
112
114
  levelActiveDebug: 'bg-slate-700 text-slate-300 border-slate-600',
115
+ levelActiveOther: 'bg-transparent text-slate-300 border-slate-500',
113
116
 
114
117
  levelBadgeError: 'bg-red-500/20 text-red-400 border border-red-500/40',
115
118
  levelBadgeWarn: 'bg-amber-500/20 text-amber-400 border border-amber-500/40',
@@ -166,6 +169,7 @@ const LIGHT_PALETTE: LogPalette = {
166
169
  levelActiveWarn: 'bg-amber-100 text-amber-700 border-amber-400',
167
170
  levelActiveInfo: 'bg-blue-100 text-blue-700 border-blue-400',
168
171
  levelActiveDebug: 'bg-slate-200 text-slate-700 border-slate-400',
172
+ levelActiveOther: 'bg-white text-slate-700 border-slate-400',
169
173
 
170
174
  levelBadgeError: 'bg-red-100 text-red-700 border border-red-400',
171
175
  levelBadgeWarn: 'bg-amber-100 text-amber-700 border border-amber-400',
@@ -1,7 +1,9 @@
1
1
  import { useState, useRef, useCallback } from 'react'
2
2
  import { isLogfmt } from '../../utils/log-format'
3
+ import { detectLevel, type LevelSource, type LogLevel } from '../../utils/log-level'
3
4
 
4
- export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
5
+ export type { LevelSource, LogLevel }
6
+ export { detectLogLevel } from '../../utils/log-level'
5
7
 
6
8
  export interface LogEntry {
7
9
  sourceLabel?: string
@@ -17,54 +19,19 @@ export interface LogEntry {
17
19
  */
18
20
  podColorIndex?: number
19
21
  level: LogLevel
22
+ levelSource: LevelSource
20
23
  isJson: boolean
21
24
  isLogfmt: boolean
22
25
  }
23
26
 
24
27
  const MAX_BUFFER_SIZE = 10_000
25
28
 
26
- /**
27
- * Detect log level from content using word-boundary matching.
28
- * For JSON logs, prefer the `level`, `severity`, or `lvl` field.
29
- */
30
- export function detectLogLevel(content: string): LogLevel {
31
- // Fast path for JSON: check level/severity field
32
- const trimmed = content.trimStart()
33
- if (trimmed[0] === '{') {
34
- try {
35
- const obj = JSON.parse(trimmed)
36
- const rawLevel = obj.level ?? obj.severity ?? obj.lvl ?? ''
37
- // Numeric levels (pino/bunyan): 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
38
- if (typeof rawLevel === 'number') {
39
- if (rawLevel >= 50) return 'error'
40
- if (rawLevel >= 40) return 'warn'
41
- if (rawLevel >= 30) return 'info'
42
- return 'debug'
43
- }
44
- const lvl = String(rawLevel).toLowerCase()
45
- if (/^(error|err|fatal|panic|critical|crit)$/.test(lvl)) return 'error'
46
- if (/^(warn|warning)$/.test(lvl)) return 'warn'
47
- if (/^(info|information|notice)$/.test(lvl)) return 'info'
48
- if (/^(debug|trace|verbose)$/.test(lvl)) return 'debug'
49
- } catch {
50
- // Not valid JSON, fall through to text matching
51
- }
52
- }
53
-
54
- const lower = content.toLowerCase()
55
- if (/\b(error|fatal|panic|critical|crit|exception)\b/.test(lower)) return 'error'
56
- if (/\b(warn|warning)\b/.test(lower)) return 'warn'
57
- if (/\b(debug|trace)\b/.test(lower)) return 'debug'
58
- if (/\b(info)\b/.test(lower)) return 'info'
59
- return 'unknown'
60
- }
61
-
62
29
  function isJsonContent(content: string): boolean {
63
30
  const trimmed = content.trimStart()
64
31
  return trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}'
65
32
  }
66
33
 
67
- type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'isJson' | 'isLogfmt'>
34
+ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'isLogfmt'>
68
35
 
69
36
  interface UseLogBufferReturn {
70
37
  entries: LogEntry[]
@@ -82,10 +49,12 @@ export function useLogBuffer(): UseLogBufferReturn {
82
49
 
83
50
  const enrichEntry = useCallback((raw: RawLogEntry): LogEntry => {
84
51
  const isJ = isJsonContent(raw.content)
52
+ const detected = detectLevel(raw.content)
85
53
  return {
86
54
  ...raw,
87
55
  id: idCounter.current++,
88
- level: detectLogLevel(raw.content),
56
+ level: detected.level,
57
+ levelSource: detected.source,
89
58
  isJson: isJ,
90
59
  isLogfmt: !isJ && isLogfmt(raw.content),
91
60
  }
@@ -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>,
@@ -435,3 +435,18 @@ describe('PodRenderer metrics', () => {
435
435
  expect(html).toContain('CPU')
436
436
  })
437
437
  })
438
+
439
+ describe('PodRenderer on a Radar without variable sources', () => {
440
+ it('falls back to the spec view with an upgrade note, not an error', () => {
441
+ const unsupported = Object.assign(new Error('unsupported'), {
442
+ radarUpgrade: { feature: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', currentVersion: 'v1.7.2' },
443
+ })
444
+ const html = renderToString(
445
+ <PodRenderer data={pod} onCopy={() => undefined} copied={null} environmentError={unsupported} />,
446
+ )
447
+ expect(html).toContain('Values from ConfigMaps and Secrets')
448
+ expect(html).toContain('need Radar v1.9 or newer on this cluster. It runs v1.7.2.')
449
+ expect(html).toContain('ConfigMap')
450
+ expect(html).not.toContain('could not be loaded')
451
+ })
452
+ })