@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.
- package/package.json +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- package/src/components/gitops/index.ts +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/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/shared/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +14 -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/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
|
@@ -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 =
|
|
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={`${
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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>,
|
|
@@ -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
|
+
})
|