@skyhook-io/k8s-ui 1.5.11 → 1.5.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.11",
3
+ "version": "1.5.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useRef, useEffect } from 'react'
1
+ import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'
2
2
  import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, Search, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { AuditFinding } from './AuditAlerts'
@@ -48,15 +48,35 @@ export interface AuditFindingsTableProps {
48
48
  }
49
49
 
50
50
  export function AuditFindingsTable({ groups, findings, checks, onResourceClick, onHideCheck, onHideCategory, onHideNamespace, multiCluster, onClusterClick }: AuditFindingsTableProps) {
51
- const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
52
- const [severityFilter, setSeverityFilter] = useState<string | null>(null)
53
- const [frameworkFilter, setFrameworkFilter] = useState<string | null>(null)
51
+ const [categoryFilter, setCategoryFilter] = useState<Set<string>>(new Set())
52
+ const [severityFilter, setSeverityFilter] = useState<Set<string>>(new Set())
53
+ const [frameworkFilter, setFrameworkFilter] = useState<Set<string>>(new Set())
54
54
  const [searchTerm, setSearchTerm] = useState('')
55
55
  const [expanded, setExpanded] = useState<Set<string>>(new Set())
56
56
  const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
57
57
  const [groupByNS, setGroupByNS] = useState(false)
58
58
  const searchInputRef = useRef<HTMLInputElement>(null)
59
59
 
60
+ const toggleInSet = (setter: Dispatch<SetStateAction<Set<string>>>, value: string) => {
61
+ setter(prev => {
62
+ const next = new Set(prev)
63
+ if (next.has(value)) next.delete(value)
64
+ else next.add(value)
65
+ return next
66
+ })
67
+ }
68
+
69
+ const clearChipFilters = () => {
70
+ setCategoryFilter(new Set())
71
+ setSeverityFilter(new Set())
72
+ setFrameworkFilter(new Set())
73
+ }
74
+
75
+ const clearAllFilters = () => {
76
+ clearChipFilters()
77
+ setSearchTerm('')
78
+ }
79
+
60
80
  // "/" keyboard shortcut to focus search
61
81
  useEffect(() => {
62
82
  const handler = (e: KeyboardEvent) => {
@@ -88,13 +108,14 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
88
108
 
89
109
  const searchLower = searchTerm.toLowerCase()
90
110
 
91
- // Match a finding against category/severity/framework filters
111
+ // Match a finding against category/severity/framework filters.
112
+ // Within a dimension, multiple selected values are OR'd. Across dimensions, AND.
92
113
  const matchesFinding = (f: AuditFinding) => {
93
- if (categoryFilter && f.category !== categoryFilter) return false
94
- if (severityFilter && f.severity !== severityFilter) return false
95
- if (frameworkFilter && checks) {
96
- const meta = checks[f.checkID]
97
- if (!meta?.frameworks?.includes(frameworkFilter)) return false
114
+ if (categoryFilter.size > 0 && !categoryFilter.has(f.category)) return false
115
+ if (severityFilter.size > 0 && !severityFilter.has(f.severity)) return false
116
+ if (frameworkFilter.size > 0 && checks) {
117
+ const fws = checks[f.checkID]?.frameworks
118
+ if (!fws || !fws.some(fw => frameworkFilter.has(fw))) return false
98
119
  }
99
120
  return true
100
121
  }
@@ -141,7 +162,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
141
162
  }
142
163
 
143
164
  // Compute counts from filtered results (so summary reflects active filters)
144
- const hasActiveFilters = !!(categoryFilter || severityFilter || frameworkFilter || searchTerm)
165
+ const hasActiveChipFilters = categoryFilter.size > 0 || severityFilter.size > 0 || frameworkFilter.size > 0
166
+ const hasActiveFilters = hasActiveChipFilters || searchTerm !== ''
145
167
  const filteredAllFindings = filteredGroups
146
168
  ? filteredGroups.flatMap(g => g.findings)
147
169
  : filteredFindings ?? []
@@ -243,28 +265,30 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
243
265
  )}
244
266
  </div>
245
267
 
246
- {/* Row 2: Filter chips — three groups separated by dividers (All | Categories | Severities | Frameworks). */}
268
+ {/* Row 2: Filter chips — three groups separated by dividers (All | Categories | Severities | Frameworks).
269
+ Each chip is an independent toggle. Multiple chips within a dimension OR together;
270
+ dimensions AND together. */}
247
271
  <div className="flex flex-wrap items-center gap-1.5">
248
- <FilterPill label="All" active={!categoryFilter && !severityFilter && !frameworkFilter} onClick={() => { setCategoryFilter(null); setSeverityFilter(null); setFrameworkFilter(null) }} />
272
+ <FilterPill label="All" active={!hasActiveChipFilters} onClick={clearChipFilters} />
249
273
  <span className="w-px h-5 bg-theme-border mx-2" />
250
274
  {CATEGORIES.map(cat => (
251
- <FilterPill key={cat} label={cat} active={categoryFilter === cat} onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)} />
275
+ <FilterPill key={cat} label={cat} active={categoryFilter.has(cat)} onClick={() => toggleInSet(setCategoryFilter, cat)} />
252
276
  ))}
253
277
  <span className="w-px h-5 bg-theme-border mx-2" />
254
278
  {SEVERITIES.map(sev => (
255
279
  <FilterPill
256
280
  key={sev}
257
281
  label={sev === 'danger' ? 'Critical' : 'Warning'}
258
- active={severityFilter === sev}
282
+ active={severityFilter.has(sev)}
259
283
  tone={sev === 'danger' ? 'danger' : 'warn'}
260
- onClick={() => setSeverityFilter(severityFilter === sev ? null : sev)}
284
+ onClick={() => toggleInSet(setSeverityFilter, sev)}
261
285
  />
262
286
  ))}
263
287
  {frameworks.length > 0 && (
264
288
  <>
265
289
  <span className="w-px h-5 bg-theme-border mx-2" />
266
290
  {frameworks.map(fw => (
267
- <FilterPill key={fw} label={fw} active={frameworkFilter === fw} onClick={() => setFrameworkFilter(frameworkFilter === fw ? null : fw)} />
291
+ <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggleInSet(setFrameworkFilter, fw)} />
268
292
  ))}
269
293
  </>
270
294
  )}
@@ -292,7 +316,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
292
316
  action={
293
317
  <button
294
318
  type="button"
295
- onClick={() => { setCategoryFilter(null); setSeverityFilter(null); setFrameworkFilter(null); setSearchTerm('') }}
319
+ onClick={clearAllFilters}
296
320
  className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
297
321
  >
298
322
  Clear all filters
@@ -309,9 +333,16 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
309
333
  const nsWarning = nsGroups.reduce((n, g) => n + g.warning, 0)
310
334
  return (
311
335
  <div key={ns}>
312
- <button
336
+ <div
337
+ role="button"
338
+ tabIndex={0}
339
+ aria-expanded={nsExpanded}
313
340
  onClick={() => toggleNS(ns)}
314
- className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left"
341
+ onKeyDown={(e) => {
342
+ if (e.target !== e.currentTarget) return
343
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNS(ns) }
344
+ }}
345
+ className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
315
346
  >
316
347
  <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary shrink-0 transition-transform duration-200', nsExpanded && 'rotate-90')} />
317
348
  <span className="text-sm font-semibold text-theme-text-primary">{ns}</span>
@@ -324,7 +355,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
324
355
  {onHideNamespace && ns !== '(cluster-scoped)' && (
325
356
  <ContextMenu items={[{ label: `Hide ${ns} namespace`, onClick: () => onHideNamespace(ns) }]} />
326
357
  )}
327
- </button>
358
+ </div>
328
359
  <div
329
360
  className="grid transition-[grid-template-rows] duration-200 ease-out"
330
361
  style={{ gridTemplateRows: nsExpanded ? '1fr' : '0fr' }}
@@ -422,9 +453,16 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
422
453
 
423
454
  return (
424
455
  <div>
425
- <button
456
+ <div
457
+ role="button"
458
+ tabIndex={0}
459
+ aria-expanded={isExpanded}
426
460
  onClick={() => onToggle(key)}
427
- className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
461
+ onKeyDown={(e) => {
462
+ if (e.target !== e.currentTarget) return
463
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(key) }
464
+ }}
465
+ className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
428
466
  >
429
467
  <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary shrink-0 transition-transform duration-200', isExpanded && 'rotate-90')} />
430
468
  {hasDanger ? (
@@ -434,16 +472,14 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
434
472
  )}
435
473
  <span className="text-xs text-theme-text-tertiary shrink-0">{g.kind}</span>
436
474
  {onResourceClick ? (
437
- <span
438
- role="link"
439
- tabIndex={0}
475
+ <button
476
+ type="button"
440
477
  onClick={(e) => { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) }}
441
- onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) } }}
442
- className="text-sm font-medium text-skyhook-500 hover:text-skyhook-400 hover:underline cursor-pointer truncate max-w-[300px] inline-flex items-center gap-1"
478
+ className="text-sm font-medium text-skyhook-500 hover:text-skyhook-400 hover:underline cursor-pointer truncate max-w-[300px] inline-flex items-center gap-1 text-left"
443
479
  >
444
480
  {showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
445
481
  <ExternalLink className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
446
- </span>
482
+ </button>
447
483
  ) : (
448
484
  <span className="text-sm font-medium text-theme-text-primary truncate max-w-[300px]">
449
485
  {showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
@@ -457,7 +493,7 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
457
493
  {showNamespace && onHideNamespace && g.namespace && (
458
494
  <ContextMenu items={[{ label: `Hide ${g.namespace} namespace`, onClick: () => onHideNamespace(g.namespace) }]} />
459
495
  )}
460
- </button>
496
+ </div>
461
497
  <div
462
498
  className="grid transition-[grid-template-rows] duration-200 ease-out"
463
499
  style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
@@ -4,9 +4,12 @@ import {
4
4
  useMemo,
5
5
  useRef,
6
6
  useState,
7
+ forwardRef,
8
+ useImperativeHandle,
7
9
  } from 'react'
8
- import { ChevronDown, Check, Loader2, Search, Server, X } from 'lucide-react'
10
+ import { ChevronDown, Check, FolderOpen, Loader2, Search, Server, X } from 'lucide-react'
9
11
  import { ClusterName } from '../ui/ClusterName'
12
+ import { MiddleEllipsis } from '../ui/MiddleEllipsis'
10
13
  import { StatusDot, type StatusTone } from '../ui/status-tone'
11
14
 
12
15
  export interface ClusterSwitcherItem {
@@ -16,6 +19,10 @@ export interface ClusterSwitcherItem {
16
19
  name: string
17
20
  secondary?: string
18
21
  badge?: string
22
+ /** Origin label, rendered as a folder-icon line under the name.
23
+ * Caller must only set this when 2+ distinct sources exist — the
24
+ * chip surfaces unconditionally when present. */
25
+ sourceLabel?: string
19
26
  group?: { key: string; label?: string }
20
27
  disabled?: boolean
21
28
  status?: StatusTone
@@ -27,11 +34,18 @@ export interface ClusterSwitcherItem {
27
34
  title?: string
28
35
  }
29
36
 
37
+ export interface ClusterSwitcherHandle {
38
+ open: () => void
39
+ }
40
+
30
41
  export interface ClusterSwitcherProps {
31
42
  currentId?: string
32
43
  /** Raw context / display string. Pass it as-is — the trigger renders
33
44
  * through ClusterName, which handles parse + provider badge + tooltip. */
34
45
  currentName: string
46
+ /** Trigger-side counterpart to {@link ClusterSwitcherItem.sourceLabel}.
47
+ * Only pass when 2+ kubeconfig sources are loaded. */
48
+ currentSourceLabel?: string
35
49
  items: ClusterSwitcherItem[]
36
50
  onSelect?: (item: ClusterSwitcherItem) => void
37
51
  searchable?: boolean
@@ -54,9 +68,10 @@ export interface ClusterSwitcherProps {
54
68
  // full — comfortably covering parsed cluster names from any provider.
55
69
  const TRIGGER_NAME_MAX_WIDTH = 'max-w-[160px] sm:max-w-[260px] xl:max-w-[400px]'
56
70
 
57
- export function ClusterSwitcher({
71
+ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcherProps>(({
58
72
  currentId,
59
73
  currentName,
74
+ currentSourceLabel,
60
75
  items,
61
76
  onSelect,
62
77
  searchable = true,
@@ -69,13 +84,19 @@ export function ClusterSwitcher({
69
84
  errorSlot,
70
85
  className = '',
71
86
  align = 'left',
72
- }: ClusterSwitcherProps) {
87
+ }, ref) => {
73
88
  const [isOpen, setIsOpen] = useState(false)
74
89
  const [search, setSearch] = useState('')
75
90
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
76
91
  const rootRef = useRef<HTMLDivElement>(null)
77
92
  const searchInputRef = useRef<HTMLInputElement>(null)
78
93
 
94
+ useImperativeHandle(ref, () => ({
95
+ open: () => {
96
+ if (!disabled && !loading) setIsOpen(true)
97
+ }
98
+ }), [disabled, loading])
99
+
79
100
  const groups = useMemo(() => {
80
101
  const q = search.trim().toLowerCase()
81
102
  const matches = (item: ClusterSwitcherItem) => {
@@ -84,6 +105,7 @@ export function ClusterSwitcher({
84
105
  item.name.toLowerCase().includes(q) ||
85
106
  item.secondary?.toLowerCase().includes(q) ||
86
107
  item.badge?.toLowerCase().includes(q) ||
108
+ item.sourceLabel?.toLowerCase().includes(q) ||
87
109
  item.group?.label?.toLowerCase().includes(q)
88
110
  )
89
111
  }
@@ -175,7 +197,7 @@ export function ClusterSwitcher({
175
197
  onClick={() => setIsOpen(v => !v)}
176
198
  disabled={disabled || loading}
177
199
  className={`
178
- flex items-center gap-1.5 px-2.5 py-1.5
200
+ flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
179
201
  bg-theme-elevated border border-theme-border rounded text-sm font-medium
180
202
  text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
181
203
  transition-colors cursor-pointer
@@ -195,14 +217,31 @@ export function ClusterSwitcher({
195
217
  // while the dropdown is open — the popover already shows the raw
196
218
  // context inline (per-row secondary line), and an extra hover
197
219
  // tooltip would just overlap the search input.
198
- <ClusterName
199
- name={currentName}
200
- fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
201
- className={TRIGGER_NAME_MAX_WIDTH}
202
- noTooltip={isOpen}
203
- />
220
+ <>
221
+ <ClusterName
222
+ name={currentName}
223
+ fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
224
+ className={TRIGGER_NAME_MAX_WIDTH}
225
+ noTooltip={isOpen}
226
+ />
227
+ {currentSourceLabel && (
228
+ // Icon-only on the trigger: long folder paths (the very case
229
+ // that motivates the chip) middle-truncate to something
230
+ // useless like "kube-cluster-pro…ion-eu" and steal width
231
+ // from the cluster name + nav. The folder icon signals
232
+ // "multi-source — disambiguation in dropdown"; hover or
233
+ // open the dropdown for the full label.
234
+ <span
235
+ className="shrink-0 inline-flex items-center text-theme-text-tertiary opacity-80"
236
+ title={`From kubeconfig: ${currentSourceLabel}`}
237
+ aria-label={`From kubeconfig: ${currentSourceLabel}`}
238
+ >
239
+ <FolderOpen className="w-3 h-3" />
240
+ </span>
241
+ )}
242
+ </>
204
243
  )}
205
- <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
244
+ <ChevronDown className={`w-3 h-3 ml-auto transition-transform ${isOpen ? 'rotate-180' : ''}`} />
206
245
  </button>
207
246
 
208
247
  {isOpen && (
@@ -308,6 +347,20 @@ export function ClusterSwitcher({
308
347
  </span>
309
348
  )}
310
349
  </div>
350
+ {item.sourceLabel && (
351
+ // Source chip lives on its own line under the
352
+ // name so long folder paths get the full row
353
+ // width to render via MiddleEllipsis. Inline
354
+ // would steal width from the name and force
355
+ // both to truncate.
356
+ <div
357
+ className="flex items-center gap-0.5 text-[10px] text-theme-text-tertiary opacity-80 mt-0.5"
358
+ title={`From kubeconfig: ${item.sourceLabel}`}
359
+ >
360
+ <FolderOpen className="w-2.5 h-2.5 shrink-0" />
361
+ <MiddleEllipsis text={item.sourceLabel} className="font-mono" />
362
+ </div>
363
+ )}
311
364
  {item.secondary && (
312
365
  <div
313
366
  className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5"
@@ -344,4 +397,4 @@ export function ClusterSwitcher({
344
397
  )}
345
398
  </div>
346
399
  )
347
- }
400
+ })
@@ -132,8 +132,10 @@ function ResourceItem({ resource, onClick, showHealth }: ResourceItemProps) {
132
132
 
133
133
  const content = (
134
134
  <div className="flex items-center gap-2 py-0.5">
135
- {showHealth && resource.health && (
136
- <HealthDot health={resource.health} />
135
+ {showHealth && (
136
+ <span className="shrink-0 w-1.5 h-1.5 inline-flex items-center justify-center">
137
+ {resource.health && <HealthDot health={resource.health} />}
138
+ </span>
137
139
  )}
138
140
  <span className="text-xs text-theme-text-secondary truncate" title={displayName}>
139
141
  {displayName}
@@ -72,6 +72,22 @@ const TIMESTAMP_FORMAT_ORDER: TimestampFormat[] = [
72
72
  'time-local', 'time-utc', 'iso-local', 'iso-utc', 'relative', 'epoch',
73
73
  ]
74
74
 
75
+ export type StructuredMode = 'compact' | 'expanded' | 'raw'
76
+
77
+ const STRUCTURED_MODE_ORDER: StructuredMode[] = ['compact', 'expanded', 'raw']
78
+
79
+ const STRUCTURED_MODE_LABELS: Record<StructuredMode, string> = {
80
+ compact: 'Compact',
81
+ expanded: 'Expanded',
82
+ raw: 'Raw',
83
+ }
84
+
85
+ const STRUCTURED_MODE_DESCRIPTIONS: Record<StructuredMode, string> = {
86
+ compact: 'Summary line with field count',
87
+ expanded: 'All fields shown as a tree',
88
+ raw: 'Original log line, unparsed',
89
+ }
90
+
75
91
  const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
76
92
  'time-local': 'Local time',
77
93
  'time-utc': 'UTC time',
@@ -163,7 +179,13 @@ export function LogCore({
163
179
  )
164
180
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
165
181
  const [showTsMenu, setShowTsMenu] = useState(false)
166
- const [expandAllStructured, setExpandAllStructured] = useState(false)
182
+ const [showStructuredMenu, setShowStructuredMenu] = useState(false)
183
+ const [structuredMode, setStructuredMode] = useState<StructuredMode>(() => {
184
+ try {
185
+ const v = localStorage.getItem('radar-logs-structured-mode') as StructuredMode | null
186
+ return v && STRUCTURED_MODE_ORDER.includes(v) ? v : 'compact'
187
+ } catch { return 'compact' }
188
+ })
167
189
  const [expandedStacks, setExpandedStacks] = useState<Set<number>>(() => new Set())
168
190
 
169
191
  // Re-render every 15s so "relative" timestamps tick forward during idle viewing.
@@ -225,6 +247,33 @@ export function LogCore({
225
247
  return () => window.removeEventListener('click', handleClick)
226
248
  }, [showTsMenu])
227
249
 
250
+ const structuredMenuRef = useRef<HTMLDivElement>(null)
251
+ useEffect(() => {
252
+ if (!showStructuredMenu) return
253
+ const handleClick = (e: MouseEvent) => {
254
+ if (structuredMenuRef.current?.contains(e.target as Node)) return
255
+ setShowStructuredMenu(false)
256
+ }
257
+ window.addEventListener('click', handleClick)
258
+ return () => window.removeEventListener('click', handleClick)
259
+ }, [showStructuredMenu])
260
+
261
+ const pickStructuredMode = useCallback((mode: StructuredMode) => {
262
+ setStructuredMode(mode)
263
+ try { localStorage.setItem('radar-logs-structured-mode', mode) } catch {}
264
+ setShowStructuredMenu(false)
265
+ }, [])
266
+
267
+ // Icon click cycles through the three modes; chevron exposes the explicit picker.
268
+ const cycleStructuredMode = useCallback(() => {
269
+ setStructuredMode(prev => {
270
+ const idx = STRUCTURED_MODE_ORDER.indexOf(prev)
271
+ const next = STRUCTURED_MODE_ORDER[(idx + 1) % STRUCTURED_MODE_ORDER.length]
272
+ try { localStorage.setItem('radar-logs-structured-mode', next) } catch {}
273
+ return next
274
+ })
275
+ }, [])
276
+
228
277
  // Keyboard shortcut: Ctrl+F to open search
229
278
  useEffect(() => {
230
279
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -429,18 +478,59 @@ export function LogCore({
429
478
 
430
479
  <div className="flex-1" />
431
480
 
432
- {/* Expand all structured logs toggle */}
481
+ {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
433
482
  {hasStructuredEntries && (
434
- <Tooltip content={expandAllStructured ? 'Collapse all structured' : 'Expand all structured'} delay={TIP_DELAY} position="bottom">
435
- <button
436
- onClick={() => setExpandAllStructured(prev => !prev)}
437
- className={`p-1.5 rounded transition-colors ${
438
- expandAllStructured ? palette.toolbarActive : iconBtnInactive
439
- }`}
440
- >
441
- <Braces className="w-4 h-4" />
442
- </button>
443
- </Tooltip>
483
+ <div className="flex items-center">
484
+ <Tooltip content={`Structured: ${STRUCTURED_MODE_LABELS[structuredMode]} — click to cycle`} delay={TIP_DELAY} position="bottom">
485
+ <button
486
+ onClick={cycleStructuredMode}
487
+ className={`p-1.5 rounded-l transition-colors ${
488
+ structuredMode === 'compact' ? iconBtnInactive : palette.toolbarActive
489
+ }`}
490
+ aria-label={`Structured log display mode: ${STRUCTURED_MODE_LABELS[structuredMode]}`}
491
+ >
492
+ <Braces className="w-4 h-4" />
493
+ </button>
494
+ </Tooltip>
495
+ <div className="relative" ref={structuredMenuRef}>
496
+ <Tooltip content="Pick structured display mode" delay={TIP_DELAY} position="bottom">
497
+ <button
498
+ onClick={() => setShowStructuredMenu(prev => !prev)}
499
+ className={`px-2 py-1.5 rounded-r text-[10px] font-medium transition-colors whitespace-nowrap ${
500
+ structuredMode === 'compact' ? iconBtnInactiveTertiary : palette.toolbarActive
501
+ }`}
502
+ aria-label="Pick structured log display mode"
503
+ >
504
+ <span className="inline-flex items-center gap-1">
505
+ <span>{STRUCTURED_MODE_LABELS[structuredMode]}</span>
506
+ <ChevronDown className="w-3 h-3" />
507
+ </span>
508
+ </button>
509
+ </Tooltip>
510
+ {showStructuredMenu && (
511
+ <div className={`absolute top-full right-0 mt-1 w-56 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
512
+ <div className={`px-3 py-1.5 text-[10px] uppercase tracking-wide ${palette.textTertiary} border-b ${palette.border}`}>
513
+ Structured display
514
+ </div>
515
+ {STRUCTURED_MODE_ORDER.map(mode => (
516
+ <button
517
+ key={mode}
518
+ onClick={() => pickStructuredMode(mode)}
519
+ className={`w-full text-left px-3 py-1.5 text-xs ${palette.hoverBg} flex items-start justify-between gap-2 ${
520
+ structuredMode === mode ? palette.textPrimary : palette.textSecondary
521
+ }`}
522
+ >
523
+ <span className="flex flex-col">
524
+ <span>{STRUCTURED_MODE_LABELS[mode]}</span>
525
+ <span className={`text-[10px] ${palette.textTertiary}`}>{STRUCTURED_MODE_DESCRIPTIONS[mode]}</span>
526
+ </span>
527
+ {structuredMode === mode && <span className={`text-[10px] ${palette.textAccent} mt-0.5`}>✓</span>}
528
+ </button>
529
+ ))}
530
+ </div>
531
+ )}
532
+ </div>
533
+ </div>
444
534
  )}
445
535
 
446
536
  {/* Timestamp toggle + format picker */}
@@ -731,7 +821,8 @@ export function LogCore({
731
821
  ansiEnabled={ansiEnabled}
732
822
  isCurrentMatch={group.head.id === currentHighlightId}
733
823
  wordWrap={wordWrap}
734
- defaultExpanded={expandAllStructured}
824
+ defaultExpanded={structuredMode === 'expanded'}
825
+ rawStructured={structuredMode === 'raw'}
735
826
  onFilterValue={handleFilterValue}
736
827
  isStackExpanded={expandedStacks.has(group.head.id)}
737
828
  onToggleStack={toggleStackExpanded}
@@ -785,6 +876,8 @@ interface LogLineProps {
785
876
  isCurrentMatch: boolean
786
877
  wordWrap: boolean
787
878
  defaultExpanded: boolean
879
+ /** When true, JSON/logfmt entries render as plain raw text instead of via StructuredLogLine. */
880
+ rawStructured: boolean
788
881
  onFilterValue?: (value: string) => void
789
882
  /** Optional lead element rendered at the start of the row (e.g. stack-trace toggle). */
790
883
  leadSlot?: ReactNode
@@ -804,6 +897,7 @@ function LogLine({
804
897
  isCurrentMatch,
805
898
  wordWrap,
806
899
  defaultExpanded,
900
+ rawStructured,
807
901
  onFilterValue,
808
902
  leadSlot,
809
903
  isDark,
@@ -825,7 +919,7 @@ function LogLine({
825
919
  dangerouslySetInnerHTML={{ __html: highlighted }}
826
920
  />
827
921
  )
828
- } else if (entry.isJson || entry.isLogfmt) {
922
+ } else if ((entry.isJson || entry.isLogfmt) && !rawStructured) {
829
923
  contentElement = (
830
924
  <StructuredLogLine
831
925
  content={entry.content}
@@ -901,6 +995,7 @@ interface LogGroupItemProps {
901
995
  isCurrentMatch: boolean
902
996
  wordWrap: boolean
903
997
  defaultExpanded: boolean
998
+ rawStructured: boolean
904
999
  onFilterValue: (value: string) => void
905
1000
  isStackExpanded: boolean
906
1001
  onToggleStack: (id: number) => void
@@ -156,7 +156,7 @@ const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
156
156
 
157
157
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
158
158
  const SKIP_FILTER_COLUMNS = new Set([
159
- 'name', 'namespace', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
159
+ 'name', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
160
160
  'ports', 'message', 'url', 'ref', 'revision', 'path', 'selector', 'ready', 'restarts',
161
161
  'completions', 'duration', 'schedule', 'lastRun', 'target', 'replicas', 'metrics',
162
162
  'capacity', 'accessModes', 'volume', 'step', 'progress', 'template', 'expires',
@@ -3471,7 +3471,7 @@ export function ResourcesView({
3471
3471
  {lastUpdated && (
3472
3472
  <div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
3473
3473
  <Clock className="w-3.5 h-3.5" />
3474
- <span>Updated {formatAge(lastUpdated.toISOString())}</span>
3474
+ <span>Updated <span className="inline-block min-w-[4ch] tabular-nums">{formatAge(lastUpdated.toISOString())}</span></span>
3475
3475
  </div>
3476
3476
  )}
3477
3477
  {/* Column picker */}
@@ -3578,6 +3578,23 @@ export function ResourcesView({
3578
3578
  <X className="w-3.5 h-3.5" />
3579
3579
  </button>
3580
3580
  )}
3581
+ {/*
3582
+ The sidebar's count badge shows the cluster-wide
3583
+ total for the selected kind (from resourceCounts) and
3584
+ deliberately stays unfiltered. When a search yields
3585
+ zero results the user can think the badge is lying.
3586
+ Spell out that the badge is the cluster total, not
3587
+ the filtered count.
3588
+ */}
3589
+ {searchTerm && (() => {
3590
+ const totalForKind = counts[selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind] ?? 0
3591
+ if (totalForKind === 0) return null
3592
+ return (
3593
+ <p className="text-xs mt-1 text-theme-text-disabled">
3594
+ The sidebar shows {pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.
3595
+ </p>
3596
+ )
3597
+ })()}
3581
3598
  {namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in {namespaces.length === 1 ? `namespace: ${namespaces[0]}` : `${namespaces.length} namespaces`}</p>}
3582
3599
  {/* Show active filters as dismissible badges so user can clear them */}
3583
3600
  {(() => {
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { KarpenterNodePoolRenderer } from './KarpenterNodePoolRenderer'
4
+
5
+ describe('KarpenterNodePoolRenderer', () => {
6
+ it('renders CPU quantities expressed as millicore strings', () => {
7
+ const html = renderToString(
8
+ <KarpenterNodePoolRenderer
9
+ data={{
10
+ spec: { limits: { cpu: '12000m' } },
11
+ status: { resources: { cpu: '6000m' } },
12
+ }}
13
+ />,
14
+ )
15
+
16
+ expect(html).toContain('6 / 12')
17
+ })
18
+
19
+ it('renders non-string CPU quantities without throwing', () => {
20
+ expect(() =>
21
+ renderToString(
22
+ <KarpenterNodePoolRenderer
23
+ data={{
24
+ spec: { limits: { cpu: 16 } },
25
+ status: { resources: { cpu: 12 } },
26
+ }}
27
+ />,
28
+ ),
29
+ ).not.toThrow()
30
+ })
31
+ })
@@ -9,13 +9,13 @@ import {
9
9
  getNodePoolWeight,
10
10
  } from '../resource-utils-karpenter'
11
11
 
12
- function formatCpuCores(value: string): string {
13
- // Karpenter status.resources CPU is typically in millicores (e.g. "12000m") or cores (e.g. "12")
14
- if (value.endsWith('m')) {
15
- const millis = parseInt(value, 10)
12
+ function formatCpuCores(value: unknown): string {
13
+ const quantity = String(value)
14
+ if (quantity.endsWith('m')) {
15
+ const millis = parseInt(quantity, 10)
16
16
  if (!isNaN(millis)) return String(millis / 1000)
17
17
  }
18
- return value
18
+ return quantity
19
19
  }
20
20
 
21
21
 
@@ -1636,6 +1636,8 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
1636
1636
  const kindLower = kind.toLowerCase()
1637
1637
 
1638
1638
  switch (column) {
1639
+ case 'namespace':
1640
+ return resource.metadata?.namespace || ''
1639
1641
  case 'type':
1640
1642
  if (kindLower === 'secrets' || kindLower === 'sealedsecrets') return getSecretType(resource).type
1641
1643
  if (kindLower === 'services') return resource.spec?.type || ''
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { pluralize } from '../../utils/pluralize'
3
+ import type { SelectedKindInfo } from './ResourcesSidebar'
4
+
5
+ // Mirrors the empty-state hint rule inside ResourcesView (kept in
6
+ // sync by reuse) — pure so we can pin the lookup + render contract
7
+ // without rendering the React component. See
8
+ // `packages/k8s-ui/src/components/resources/ResourcesView.tsx`
9
+ // search for "the count is unfiltered".
10
+ function sidebarHint(
11
+ counts: Record<string, number>,
12
+ selectedKind: SelectedKindInfo,
13
+ searchTerm: string,
14
+ ): string | null {
15
+ if (!searchTerm) return null
16
+ const key = selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind
17
+ const totalForKind = counts[key] ?? 0
18
+ if (totalForKind === 0) return null
19
+ return `The sidebar shows ${pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.`
20
+ }
21
+
22
+ describe('ResourcesView empty-search sidebar hint (SKY-828 bug 46)', () => {
23
+ // The bug: sidebar count badge stays unfiltered and shows the
24
+ // cluster-wide total per kind. When a search returns zero rows
25
+ // (e.g. "5 Pods" in sidebar, "No Pods found" in pane) the user
26
+ // reads the badge as a lie. The empty-state appends a sentence
27
+ // making the unfiltered nature explicit. The rule must:
28
+ // 1. Suppress entirely when there's no active search.
29
+ // 2. Suppress when the cluster total for the kind is 0
30
+ // (no badge to explain → no hint).
31
+ // 3. Look up the count under `${group}/${kind}` for grouped
32
+ // kinds, plain `kind` for core kinds (matches sidebar key).
33
+ // 4. Pluralize via pluralize() so "Ingress" → "Ingresses",
34
+ // "NetworkPolicy" → "NetworkPolicies" (regression of the
35
+ // naive `${kind}s` Cursor Bugbot caught in 80eb64b).
36
+ // 5. Singular for n===1: "1 Pod", not "1 Pods".
37
+
38
+ const pod: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
39
+
40
+ it('returns null when there is no active search', () => {
41
+ expect(sidebarHint({ Pod: 232 }, pod, '')).toBeNull()
42
+ })
43
+
44
+ it('returns null when the cluster total for the kind is 0', () => {
45
+ expect(sidebarHint({ Pod: 0 }, pod, 'xyz')).toBeNull()
46
+ })
47
+
48
+ it('returns null when the kind is missing from counts', () => {
49
+ expect(sidebarHint({}, pod, 'xyz')).toBeNull()
50
+ })
51
+
52
+ it('formats the sentence with the cluster total and pluralized kind', () => {
53
+ expect(sidebarHint({ Pod: 232 }, pod, 'xyz')).toBe(
54
+ 'The sidebar shows 232 Pods in the cluster — the count is unfiltered.',
55
+ )
56
+ })
57
+
58
+ it('uses singular noun when total is 1', () => {
59
+ expect(sidebarHint({ Pod: 1 }, pod, 'xyz')).toBe(
60
+ 'The sidebar shows 1 Pod in the cluster — the count is unfiltered.',
61
+ )
62
+ })
63
+
64
+ it('looks up grouped kinds under `${group}/${kind}`', () => {
65
+ const ar: SelectedKindInfo = { name: 'rollouts', kind: 'Rollout', group: 'argoproj.io' }
66
+ const counts = { Rollout: 999, 'argoproj.io/Rollout': 4 }
67
+ expect(sidebarHint(counts, ar, 'xyz')).toBe(
68
+ 'The sidebar shows 4 Rollouts in the cluster — the count is unfiltered.',
69
+ )
70
+ })
71
+
72
+ it('pluralizes Ingress correctly (regression: not "Ingresss")', () => {
73
+ const ing: SelectedKindInfo = { name: 'ingresses', kind: 'Ingress', group: '' }
74
+ expect(sidebarHint({ Ingress: 3 }, ing, 'xyz')).toBe(
75
+ 'The sidebar shows 3 Ingresses in the cluster — the count is unfiltered.',
76
+ )
77
+ })
78
+
79
+ it('pluralizes NetworkPolicy correctly (regression: not "NetworkPolicys")', () => {
80
+ const np: SelectedKindInfo = { name: 'networkpolicies', kind: 'NetworkPolicy', group: 'networking.k8s.io' }
81
+ expect(sidebarHint({ 'networking.k8s.io/NetworkPolicy': 2 }, np, 'xyz')).toBe(
82
+ 'The sidebar shows 2 NetworkPolicies in the cluster — the count is unfiltered.',
83
+ )
84
+ })
85
+ })
@@ -9,6 +9,20 @@ interface TopologySearchProps {
9
9
  nodes: TopologyNode[]
10
10
  onNodeSelect?: (node: TopologyNode) => void
11
11
  onZoomToNode?: (nodeId: string) => void
12
+ /**
13
+ * Optional unfiltered node set. When the parent has applied a
14
+ * view-mode filter (e.g. Fleet mode hides Pods/Deployments and
15
+ * only shows CAPI kinds), it should still pass the full topology
16
+ * here so the empty-state can tell users "your query matches X
17
+ * resources hidden by the current view" rather than the
18
+ * misleading "No resources found." (SKY-828 bug 45)
19
+ */
20
+ allNodes?: TopologyNode[]
21
+ /**
22
+ * Human-readable label for the current view mode. Used in the
23
+ * empty-state hint above. Defaults to "current view".
24
+ */
25
+ viewModeLabel?: string
12
26
  }
13
27
 
14
28
  // Icon mapping for different resource kinds
@@ -50,33 +64,47 @@ function getKindColor(kind: string): string {
50
64
  }
51
65
  }
52
66
 
53
- export function TopologySearch({ nodes, onNodeSelect, onZoomToNode }: TopologySearchProps) {
67
+ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel }: TopologySearchProps) {
54
68
  const [isOpen, setIsOpen] = useState(false)
55
69
  const [query, setQuery] = useState('')
56
70
  const [selectedIndex, setSelectedIndex] = useState(0)
57
71
  const inputRef = useRef<HTMLInputElement>(null)
58
72
  const resultsRef = useRef<HTMLDivElement>(null)
59
73
 
74
+ const matchesQuery = useCallback((node: TopologyNode, lowerQuery: string) => {
75
+ const name = node.name.toLowerCase()
76
+ const kind = node.kind.toLowerCase()
77
+ const namespace = (node.data.namespace as string || '').toLowerCase()
78
+ return (
79
+ name.includes(lowerQuery) ||
80
+ kind.includes(lowerQuery) ||
81
+ namespace.includes(lowerQuery) ||
82
+ `${kind}/${name}`.includes(lowerQuery) ||
83
+ `${namespace}/${name}`.includes(lowerQuery)
84
+ )
85
+ }, [])
86
+
60
87
  // Filter nodes based on query
61
88
  const filteredNodes = useMemo(() => {
62
89
  if (!query.trim()) return []
63
-
64
90
  const lowerQuery = query.toLowerCase()
65
91
  return nodes
66
- .filter(node => {
67
- const name = node.name.toLowerCase()
68
- const kind = node.kind.toLowerCase()
69
- const namespace = (node.data.namespace as string || '').toLowerCase()
70
- return (
71
- name.includes(lowerQuery) ||
72
- kind.includes(lowerQuery) ||
73
- namespace.includes(lowerQuery) ||
74
- `${kind}/${name}`.includes(lowerQuery) ||
75
- `${namespace}/${name}`.includes(lowerQuery)
76
- )
77
- })
92
+ .filter(node => matchesQuery(node, lowerQuery))
78
93
  .slice(0, 10) // Limit results
79
- }, [nodes, query])
94
+ }, [nodes, query, matchesQuery])
95
+
96
+ // Count of matches in the unfiltered topology that the current
97
+ // view-mode filter is hiding. We only compute this when there's
98
+ // a query AND the visible set returned zero results, so the cost
99
+ // is bounded to actual no-result interactions.
100
+ // (SKY-828 bug 45: Fleet view hides Pods, so searching pod names
101
+ // returned "No resources found" — misleading.)
102
+ const hiddenMatchCount = useMemo(() => {
103
+ if (!query.trim() || filteredNodes.length > 0 || !allNodes) return 0
104
+ const lowerQuery = query.toLowerCase()
105
+ const visibleIds = new Set(nodes.map(n => n.id))
106
+ return allNodes.filter(n => !visibleIds.has(n.id) && matchesQuery(n, lowerQuery)).length
107
+ }, [query, filteredNodes.length, allNodes, nodes, matchesQuery])
80
108
 
81
109
  // Reset selection when results change
82
110
  useEffect(() => {
@@ -216,7 +244,12 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode }: TopologySe
216
244
  {query && filteredNodes.length === 0 && (
217
245
  <div className="px-4 py-8 text-center text-theme-text-tertiary">
218
246
  <Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
219
- <p>No resources found for "{query}"</p>
247
+ <p>No resources found for "{query}"{viewModeLabel ? ` in ${viewModeLabel}` : ''}</p>
248
+ {hiddenMatchCount > 0 && (
249
+ <p className="mt-2 text-xs text-amber-400">
250
+ {hiddenMatchCount} {hiddenMatchCount === 1 ? 'match is' : 'matches are'} hidden by the current view{viewModeLabel ? ` (${viewModeLabel})` : ''}. Switch view to see them.
251
+ </p>
252
+ )}
220
253
  </div>
221
254
  )}
222
255
 
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { TopologyNode } from '../../types'
3
+
4
+ // Mirrors the matching predicate inside TopologySearch (kept in sync
5
+ // by reuse) — pure so we can pin the matching rule without rendering
6
+ // the React component.
7
+ function matchesQuery(node: TopologyNode, lowerQuery: string): boolean {
8
+ const name = node.name.toLowerCase()
9
+ const kind = node.kind.toLowerCase()
10
+ const namespace = (node.data.namespace as string || '').toLowerCase()
11
+ return (
12
+ name.includes(lowerQuery) ||
13
+ kind.includes(lowerQuery) ||
14
+ namespace.includes(lowerQuery) ||
15
+ `${kind}/${name}`.includes(lowerQuery) ||
16
+ `${namespace}/${name}`.includes(lowerQuery)
17
+ )
18
+ }
19
+
20
+ function countHidden(allNodes: TopologyNode[], visibleNodes: TopologyNode[], query: string): number {
21
+ if (!query.trim()) return 0
22
+ const lowerQuery = query.toLowerCase()
23
+ // First check whether the visible set already has matches — the
24
+ // hint should only appear when the visible search yielded zero.
25
+ const visibleMatches = visibleNodes.filter(n => matchesQuery(n, lowerQuery))
26
+ if (visibleMatches.length > 0) return 0
27
+ const visibleIds = new Set(visibleNodes.map(n => n.id))
28
+ return allNodes.filter(n => !visibleIds.has(n.id) && matchesQuery(n, lowerQuery)).length
29
+ }
30
+
31
+ function makeNode(id: string, kind: string, name: string, namespace = 'default'): TopologyNode {
32
+ // Tests only need the four fields the matcher reads — cast through
33
+ // unknown so the partial shape doesn't have to satisfy the full
34
+ // TopologyNode type.
35
+ return {
36
+ id,
37
+ kind,
38
+ name,
39
+ data: { namespace },
40
+ } as unknown as TopologyNode
41
+ }
42
+
43
+ describe('TopologySearch hidden-match counting (SKY-828 bug 45)', () => {
44
+ // The bug: in Fleet topology view (which only shows CAPI kinds),
45
+ // searching pod names returned "No resources found" — misleading
46
+ // when the cluster has 338 pods. We compute a hidden-match count
47
+ // so the empty-state can say "X matches are hidden by the current
48
+ // view; switch view to see them."
49
+ //
50
+ // The count should:
51
+ // 1. Be 0 when the visible set has any match (don't shame the
52
+ // visible empty-state).
53
+ // 2. Otherwise count nodes that match in the unfiltered set but
54
+ // are NOT in the visible set.
55
+ // 3. Match by name, kind, namespace, kind/name, or
56
+ // namespace/name (case-insensitive).
57
+
58
+ it('returns 0 when visible nodes contain a match', () => {
59
+ const visible = [makeNode('a', 'Cluster', 'prod')]
60
+ const all = [
61
+ ...visible,
62
+ makeNode('b', 'Pod', 'prod-app'), // would also match "prod"
63
+ ]
64
+ expect(countHidden(all, visible, 'prod')).toBe(0)
65
+ })
66
+
67
+ it('returns count of hidden matches when visible has none (Fleet → pod search)', () => {
68
+ const visible = [
69
+ makeNode('cluster-1', 'Cluster', 'prod-cluster'),
70
+ makeNode('machine-1', 'Machine', 'prod-machine-1'),
71
+ ]
72
+ const all = [
73
+ ...visible,
74
+ makeNode('pod-1', 'Pod', '3scale-gateway-abc'),
75
+ makeNode('pod-2', 'Pod', '3scale-system-xyz'),
76
+ makeNode('pod-3', 'Pod', 'billing-api-789'),
77
+ ]
78
+ expect(countHidden(all, visible, '3scale')).toBe(2)
79
+ expect(countHidden(all, visible, 'billing')).toBe(1)
80
+ })
81
+
82
+ it('matches case-insensitively', () => {
83
+ const visible = [makeNode('a', 'Cluster', 'prod')]
84
+ const all = [...visible, makeNode('b', 'Pod', 'MyApp')]
85
+ expect(countHidden(all, visible, 'myapp')).toBe(1)
86
+ expect(countHidden(all, visible, 'MYAPP')).toBe(1)
87
+ })
88
+
89
+ it('matches by namespace as well as name', () => {
90
+ const visible = [makeNode('a', 'Cluster', 'prod')]
91
+ const all = [
92
+ ...visible,
93
+ makeNode('p1', 'Pod', 'frontend', 'billing'),
94
+ makeNode('p2', 'Pod', 'backend', 'billing'),
95
+ ]
96
+ expect(countHidden(all, visible, 'billing')).toBe(2)
97
+ })
98
+
99
+ it('returns 0 for an empty/whitespace query', () => {
100
+ const visible: TopologyNode[] = []
101
+ const all = [makeNode('a', 'Pod', 'foo')]
102
+ expect(countHidden(all, visible, '')).toBe(0)
103
+ expect(countHidden(all, visible, ' ')).toBe(0)
104
+ })
105
+
106
+ it('does not double-count: a node visible AND matching is excluded from hidden', () => {
107
+ const visible = [makeNode('a', 'Pod', 'foo-1')]
108
+ const all = [...visible, makeNode('b', 'Pod', 'foo-2')]
109
+ // visible has a match → hint suppressed entirely
110
+ expect(countHidden(all, visible, 'foo')).toBe(0)
111
+ })
112
+ })
package/src/types/core.ts CHANGED
@@ -329,6 +329,9 @@ export interface ContextInfo {
329
329
  user: string
330
330
  namespace: string
331
331
  isCurrent: boolean
332
+ /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by backend
333
+ * only when 2+ kubeconfig files are loaded; empty otherwise. */
334
+ source?: string
332
335
  }
333
336
 
334
337
  // Namespace
@@ -439,6 +442,8 @@ export interface APIResource {
439
442
  export interface HelmRelease {
440
443
  name: string
441
444
  namespace: string
445
+ // Empty means Helm stores release metadata in namespace.
446
+ storageNamespace?: string
442
447
  chart: string
443
448
  chartVersion: string
444
449
  appVersion: string
@@ -463,6 +468,8 @@ export interface HelmRevision {
463
468
  export interface HelmReleaseDetail {
464
469
  name: string
465
470
  namespace: string
471
+ // Empty means Helm stores release metadata in namespace.
472
+ storageNamespace?: string
466
473
  chart: string
467
474
  chartVersion: string
468
475
  appVersion: string
@@ -520,6 +527,7 @@ export interface ManifestDiff {
520
527
  export interface SelectedHelmRelease {
521
528
  namespace: string
522
529
  name: string
530
+ storageNamespace?: string
523
531
  }
524
532
 
525
533
  // Upgrade availability info
@@ -531,7 +539,7 @@ export interface UpgradeInfo {
531
539
  error?: string
532
540
  }
533
541
 
534
- // Batch upgrade info (map of "namespace/name" to UpgradeInfo)
542
+ // Batch upgrade info keyed by "storageNamespace/name".
535
543
  export interface BatchUpgradeInfo {
536
544
  releases: Record<string, UpgradeInfo>
537
545
  }