@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 +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +66 -30
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
- package/src/components/gitops/ManagedResourcesList.tsx +4 -2
- package/src/components/logs/LogCore.tsx +109 -14
- package/src/components/resources/ResourcesView.tsx +19 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
- package/src/components/resources/resource-utils.ts +2 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
- package/src/components/topology/TopologySearch.tsx +49 -16
- package/src/components/topology/topology-search-match.test.ts +112 -0
- package/src/types/core.ts +9 -1
package/package.json
CHANGED
|
@@ -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
|
|
52
|
-
const [severityFilter, setSeverityFilter] = useState<string
|
|
53
|
-
const [frameworkFilter, setFrameworkFilter] = useState<string
|
|
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
|
|
94
|
-
if (severityFilter && f.severity
|
|
95
|
-
if (frameworkFilter && checks) {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
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
|
|
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={!
|
|
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
|
|
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
|
|
282
|
+
active={severityFilter.has(sev)}
|
|
259
283
|
tone={sev === 'danger' ? 'danger' : 'warn'}
|
|
260
|
-
onClick={() => setSeverityFilter
|
|
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
|
|
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={
|
|
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
|
-
<
|
|
336
|
+
<div
|
|
337
|
+
role="button"
|
|
338
|
+
tabIndex={0}
|
|
339
|
+
aria-expanded={nsExpanded}
|
|
313
340
|
onClick={() => toggleNS(ns)}
|
|
314
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
456
|
+
<div
|
|
457
|
+
role="button"
|
|
458
|
+
tabIndex={0}
|
|
459
|
+
aria-expanded={isExpanded}
|
|
426
460
|
onClick={() => onToggle(key)}
|
|
427
|
-
|
|
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
|
-
<
|
|
438
|
-
|
|
439
|
-
tabIndex={0}
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
440
477
|
onClick={(e) => { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) }}
|
|
441
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 &&
|
|
136
|
-
<
|
|
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 [
|
|
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
|
-
{/*
|
|
481
|
+
{/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
|
|
433
482
|
{hasStructuredEntries && (
|
|
434
|
-
<
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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={
|
|
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', '
|
|
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:
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
const millis = parseInt(
|
|
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
|
|
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
|
|
542
|
+
// Batch upgrade info keyed by "storageNamespace/name".
|
|
535
543
|
export interface BatchUpgradeInfo {
|
|
536
544
|
releases: Record<string, UpgradeInfo>
|
|
537
545
|
}
|