@skyhook-io/k8s-ui 1.18.0 → 1.18.1
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/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +66 -2
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -68,6 +68,7 @@ describe('ResourcesSidebar count visibility', () => {
|
|
|
68
68
|
onSelectedKindChange={() => {}}
|
|
69
69
|
apiResources={[horizontalPodAutoscaler]}
|
|
70
70
|
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
71
|
+
resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
|
|
71
72
|
/>
|
|
72
73
|
)
|
|
73
74
|
|
|
@@ -82,6 +83,7 @@ describe('ResourcesSidebar count visibility', () => {
|
|
|
82
83
|
onSelectedKindChange={() => {}}
|
|
83
84
|
apiResources={[horizontalPodAutoscaler]}
|
|
84
85
|
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
86
|
+
resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
|
|
85
87
|
/>
|
|
86
88
|
)
|
|
87
89
|
|
|
@@ -96,6 +98,7 @@ describe('ResourcesSidebar count visibility', () => {
|
|
|
96
98
|
onSelectedKindChange={() => {}}
|
|
97
99
|
apiResources={[horizontalPodAutoscaler]}
|
|
98
100
|
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
101
|
+
resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } } }}
|
|
99
102
|
pinned={[{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }]}
|
|
100
103
|
isPinned={(name, group) => name === 'horizontalpodautoscalers' && group === 'autoscaling'}
|
|
101
104
|
/>
|
|
@@ -197,6 +200,33 @@ describe('ResourcesSidebar category workspaces', () => {
|
|
|
197
200
|
})
|
|
198
201
|
})
|
|
199
202
|
|
|
203
|
+
describe('ResourcesSidebar counts from a Radar that predates coverage', () => {
|
|
204
|
+
it('hides an empty kind and shows plain counts, as before coverage', () => {
|
|
205
|
+
const hidden = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'unknown' } }} />)
|
|
206
|
+
expect(hidden).not.toContain('HorizontalPodAutoscaler')
|
|
207
|
+
const shown = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 23 }} />)
|
|
208
|
+
expect(shown).toContain('>23</')
|
|
209
|
+
expect(shown).not.toContain('≥')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('sizes the count badge to fit a bounded count in the thousands', () => {
|
|
214
|
+
const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 12345 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
|
|
215
|
+
const label = `≥${(12345).toLocaleString()}`
|
|
216
|
+
const badge = html.slice(html.lastIndexOf('<span', html.indexOf(label)), html.indexOf(label))
|
|
217
|
+
const classes = badge.match(/class="([^"]*)"/)?.[1].split(/\s+/) ?? []
|
|
218
|
+
expect(classes).toContain('min-w-8')
|
|
219
|
+
expect(classes).not.toContain('w-8')
|
|
220
|
+
expect(classes).not.toContain('w-9')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('keeps a partial zero visible rather than hiding the kind as empty', () => {
|
|
224
|
+
const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[horizontalPodAutoscaler]} resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }} resourceCoverage={{ 'autoscaling/HorizontalPodAutoscaler': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
|
|
225
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
226
|
+
expect(html).toContain('–')
|
|
227
|
+
expect(html).not.toContain('≥0')
|
|
228
|
+
})
|
|
229
|
+
|
|
200
230
|
describe('sidebarDestinationsMatching', () => {
|
|
201
231
|
const views = ['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'].map((label) => ({ id: label, label, onSelect: () => {} }))
|
|
202
232
|
const resources = [{ group: 'postgresql.cnpg.io' }, { group: 'barmancloud.cnpg.io' }]
|
|
@@ -215,3 +245,22 @@ describe('sidebarDestinationsMatching', () => {
|
|
|
215
245
|
expect(labels(' ')).toHaveLength(5)
|
|
216
246
|
})
|
|
217
247
|
})
|
|
248
|
+
|
|
249
|
+
it('qualifies the collapsed category total as well as the individual kind', () => {
|
|
250
|
+
const widget = { name: 'widgets', kind: 'Widget', group: 'read.example.test', version: 'v1', namespaced: true, isCrd: true, verbs: ['get', 'list', 'watch'] }
|
|
251
|
+
const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[widget]} resourceCounts={{ 'read.example.test/Widget': 3 }} resourceCoverage={{ 'read.example.test/Widget': { status: 'partial', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } } }} />)
|
|
252
|
+
const start = html.indexOf('>Test Read Example</span>')
|
|
253
|
+
expect(start).toBeGreaterThan(-1)
|
|
254
|
+
expect(html.slice(start, html.indexOf('</button>', start))).toContain('≥3')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('bounds a category total over kinds Radar collects in only some namespaces', () => {
|
|
258
|
+
const widget = { name: 'widgets', kind: 'Widget', group: 'read.example.test', version: 'v1', namespaced: true, isCrd: true, verbs: ['get', 'list', 'watch'] }
|
|
259
|
+
const gadget = { ...widget, name: 'gadgets', kind: 'Gadget' }
|
|
260
|
+
const collected = (ns: string) => ({ status: 'complete' as const, narrowedBy: ['namespace_collection'], gaps: [], scope: { type: 'namespaces' as const, namespaces: { names: [ns], count: 1 } }, read: { type: 'namespaces' as const, namespaces: { names: [ns], count: 1 } } })
|
|
261
|
+
const html = renderToString(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[widget, gadget]} resourceCounts={{ 'read.example.test/Widget': 3, 'read.example.test/Gadget': 2 }} resourceCoverage={{ 'read.example.test/Widget': collected('alpha'), 'read.example.test/Gadget': collected('beta') }} />)
|
|
262
|
+
const start = html.indexOf('>Test Read Example</span>')
|
|
263
|
+
expect(start).toBeGreaterThan(-1)
|
|
264
|
+
// Each kind is exact where Radar collects it; their sum is exact for no scope.
|
|
265
|
+
expect(html.slice(start, html.indexOf('</button>', start))).toContain('≥5')
|
|
266
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ResourceCoverage } from '../../types/resource-coverage'
|
|
2
|
+
import { boundedCountLabel, isEmptyCount, isLowerBound, resourceCountHint, resourceCountLabel } from '../../utils/resource-coverage'
|
|
1
3
|
import { useState, useMemo, useEffect, useRef, useCallback, useId, forwardRef, type ComponentType } from 'react'
|
|
2
4
|
import {
|
|
3
5
|
Search,
|
|
@@ -60,6 +62,7 @@ export interface ResourcesSidebarProps {
|
|
|
60
62
|
onKindChange?: () => void
|
|
61
63
|
apiResources?: APIResource[]
|
|
62
64
|
resourceCounts?: Record<string, number | null>
|
|
65
|
+
resourceCoverage?: Record<string, ResourceCoverage>
|
|
63
66
|
resourceUnavailable?: string[]
|
|
64
67
|
resourceForbidden?: string[]
|
|
65
68
|
pinned?: PinnedItem[]
|
|
@@ -143,6 +146,7 @@ interface ResourceTypeButtonProps {
|
|
|
143
146
|
/** `null` means "count not loaded yet" — rendered as a placeholder so
|
|
144
147
|
* the badge doesn't flicker to "0" while the API call is in flight. */
|
|
145
148
|
count: number | null
|
|
149
|
+
coverage?: ResourceCoverage
|
|
146
150
|
isSelected: boolean
|
|
147
151
|
/** Keyboard-highlight state (arrow nav in the filter input). */
|
|
148
152
|
isHighlighted?: boolean
|
|
@@ -154,7 +158,7 @@ interface ResourceTypeButtonProps {
|
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
|
|
157
|
-
function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
|
|
161
|
+
function ResourceTypeButton({ resource, count, coverage, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
|
|
158
162
|
const Icon = getResourceIcon(resource.kind, resource.group)
|
|
159
163
|
return (
|
|
160
164
|
<button
|
|
@@ -214,13 +218,14 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
|
|
|
214
218
|
</span>
|
|
215
219
|
</Tooltip>
|
|
216
220
|
) : (
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
<Tooltip disabled={!resourceCountHint(coverage)} content={resourceCountHint(coverage)} position="left">
|
|
222
|
+
<span className={clsx(
|
|
223
|
+
'text-xs py-0.5 px-1 min-w-8 rounded text-center font-mono',
|
|
224
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
225
|
+
)}>
|
|
226
|
+
{resourceCountLabel(count, coverage)}
|
|
227
|
+
</span>
|
|
228
|
+
</Tooltip>
|
|
224
229
|
)}
|
|
225
230
|
</div>
|
|
226
231
|
</button>
|
|
@@ -234,6 +239,7 @@ export function ResourcesSidebar({
|
|
|
234
239
|
onKindChange,
|
|
235
240
|
apiResources,
|
|
236
241
|
resourceCounts,
|
|
242
|
+
resourceCoverage,
|
|
237
243
|
resourceUnavailable,
|
|
238
244
|
resourceForbidden,
|
|
239
245
|
pinned = [],
|
|
@@ -401,20 +407,25 @@ export function ResourcesSidebar({
|
|
|
401
407
|
(sum, resource) => sum + (counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? 0),
|
|
402
408
|
0
|
|
403
409
|
)
|
|
410
|
+
const lowerBound = category.resources.some(resource =>
|
|
411
|
+
isLowerBound(resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]))
|
|
412
|
+
const totalLabel = boundedCountLabel(total, lowerBound)
|
|
404
413
|
|
|
405
|
-
// Filter resources: hide only
|
|
406
|
-
//
|
|
414
|
+
// Filter resources: hide only empty kinds. An unknown count stays visible
|
|
415
|
+
// as a dash, and a partial zero as one too, so count coverage gaps do not
|
|
416
|
+
// masquerade as emptiness.
|
|
407
417
|
const visibleResources = category.resources.filter(resource => {
|
|
408
418
|
const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
|
|
409
419
|
const isSelectedResource =
|
|
410
420
|
(effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
|
|
411
421
|
(effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
|
|
412
|
-
const
|
|
422
|
+
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
423
|
+
const shouldShow = isSelectedResource || count === null || !isEmptyCount(count ?? 0, resourceCoverage?.[key]) || showEmptyKinds
|
|
413
424
|
if (!shouldShow) totalHiddenKinds++
|
|
414
425
|
return shouldShow
|
|
415
426
|
})
|
|
416
427
|
|
|
417
|
-
return { ...category, total, visibleResources }
|
|
428
|
+
return { ...category, total, totalLabel, visibleResources }
|
|
418
429
|
})
|
|
419
430
|
|
|
420
431
|
// Sort: the category whose workspace is open first, then categories with
|
|
@@ -435,7 +446,7 @@ export function ResourcesSidebar({
|
|
|
435
446
|
})
|
|
436
447
|
|
|
437
448
|
return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
|
|
438
|
-
}, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
|
|
449
|
+
}, [categories, counts, resourceCoverage, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
|
|
439
450
|
|
|
440
451
|
// Filter sidebar categories/kinds by the kind search term
|
|
441
452
|
const filteredCategories = useMemo(() => {
|
|
@@ -621,6 +632,7 @@ export function ResourcesSidebar({
|
|
|
621
632
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
622
633
|
resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
|
|
623
634
|
count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
|
|
635
|
+
coverage={resourceCoverage?.[p.group ? `${p.group}/${p.kind}` : p.kind]}
|
|
624
636
|
isSelected={isResourceSelected}
|
|
625
637
|
isHighlighted={highlighted}
|
|
626
638
|
isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
|
|
@@ -653,8 +665,8 @@ export function ResourcesSidebar({
|
|
|
653
665
|
<CollapseChevron open={isExpanded} className="w-3 h-3" />
|
|
654
666
|
<span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
|
|
655
667
|
{!isExpanded && (
|
|
656
|
-
<span className=
|
|
657
|
-
{category.
|
|
668
|
+
<span className="text-xs py-0.5 px-1 min-w-8 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono">
|
|
669
|
+
{category.totalLabel}
|
|
658
670
|
</span>
|
|
659
671
|
)}
|
|
660
672
|
</button>
|
|
@@ -692,6 +704,7 @@ export function ResourcesSidebar({
|
|
|
692
704
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
693
705
|
resource={resource}
|
|
694
706
|
count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
|
|
707
|
+
coverage={resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]}
|
|
695
708
|
isSelected={showSelected}
|
|
696
709
|
isHighlighted={highlighted}
|
|
697
710
|
isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
|
|
@@ -748,12 +761,14 @@ export function ResourcesSidebar({
|
|
|
748
761
|
</span>
|
|
749
762
|
</Tooltip>
|
|
750
763
|
) : (
|
|
751
|
-
<
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
764
|
+
<Tooltip disabled={!resourceCountHint(resourceCoverage?.[kindKey])} content={resourceCountHint(resourceCoverage?.[kindKey])} position="left">
|
|
765
|
+
<span className={clsx(
|
|
766
|
+
'badge font-mono',
|
|
767
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
768
|
+
)}>
|
|
769
|
+
{resourceCountLabel(count, resourceCoverage?.[kindKey])}
|
|
770
|
+
</span>
|
|
771
|
+
</Tooltip>
|
|
757
772
|
)}
|
|
758
773
|
</button>
|
|
759
774
|
)
|
|
@@ -3,6 +3,7 @@ import type { APIResource } from '../../types'
|
|
|
3
3
|
import {
|
|
4
4
|
canonicalizeSelectedKind,
|
|
5
5
|
deriveSidebarResourceCounts,
|
|
6
|
+
deriveSidebarResourceCoverage,
|
|
6
7
|
LOADED_RESOURCE_COUNT_TTL_MS,
|
|
7
8
|
resourceQueryMatchesSelectedKind,
|
|
8
9
|
} from './ResourcesView'
|
|
@@ -141,3 +142,16 @@ describe('resourceQueryMatchesSelectedKind', () => {
|
|
|
141
142
|
).toBe(true)
|
|
142
143
|
})
|
|
143
144
|
})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
describe('loaded count coverage', () => {
|
|
148
|
+
const partial = { status: 'partial' as const, scope: { type: 'all_namespaces' as const }, read: { type: 'namespaces' as const, namespaces: { names: ['alpha'], count: 1 } }, gaps: [{ code: 'radar_namespace_not_collected' }] }
|
|
149
|
+
it('retains a cached partial count qualification and expires both together', () => {
|
|
150
|
+
const cache = { Endpoints: { count: 0, expiresAt: 20, coverage: partial } }
|
|
151
|
+
expect(deriveSidebarResourceCoverage({}, {}, cache, 10)).toEqual({ Endpoints: partial })
|
|
152
|
+
expect(deriveSidebarResourceCoverage({}, {}, cache, 20)).toEqual({})
|
|
153
|
+
})
|
|
154
|
+
it('does not pair older unqualified count responses with cached newer metadata', () => {
|
|
155
|
+
expect(deriveSidebarResourceCoverage({ Endpoints: 0 }, undefined, { Endpoints: { count: 0, expiresAt: 20, coverage: partial } }, 10)).toEqual({ Endpoints: { status: 'unknown' } })
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ResourcesView } from './ResourcesView'
|
|
6
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
7
|
+
import type { APIResource } from '../../types'
|
|
8
|
+
|
|
9
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
10
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
11
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
12
|
+
|
|
13
|
+
const group = 'aquasecurity.github.io'
|
|
14
|
+
const vulnReports: APIResource = { group, version: 'v1alpha1', kind: 'VulnerabilityReport', name: 'vulnerabilityreports', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
15
|
+
const report = (name: string, vulnerabilityID: string) => ({
|
|
16
|
+
apiVersion: `${group}/v1alpha1`, kind: 'VulnerabilityReport',
|
|
17
|
+
metadata: { name, namespace: 'legacy', uid: name },
|
|
18
|
+
report: { artifact: { repository: 'trafex/php-nginx', tag: '3.6.0' }, vulnerabilities: [{ vulnerabilityID, resource: 'php83', installedVersion: '8.3.20-r0', fixedVersion: '8.3.36-r0' }] },
|
|
19
|
+
})
|
|
20
|
+
// The match sorts last, so selecting the first row proves the filter ran.
|
|
21
|
+
const billing = report('replicaset-billing-5cfccf7776-php-nginx', 'CVE-2025-0001')
|
|
22
|
+
const web = report('replicaset-web-7685556d86-php', 'CVE-2026-109618')
|
|
23
|
+
|
|
24
|
+
let root: ReturnType<typeof createRoot>
|
|
25
|
+
let host: HTMLDivElement
|
|
26
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
|
|
27
|
+
|
|
28
|
+
function render(search: string, onResourceClick = vi.fn()) {
|
|
29
|
+
const locationSearch = `?apiGroup=${group}&search=${encodeURIComponent(search)}`
|
|
30
|
+
window.history.replaceState(null, '', `/resources/vulnerabilityreports${locationSearch}`)
|
|
31
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
32
|
+
act(() => root.render(<KeyboardShortcutProvider><ResourcesView
|
|
33
|
+
namespaces={[]}
|
|
34
|
+
apiResources={[vulnReports]}
|
|
35
|
+
resourceCounts={{ VulnerabilityReport: 2 }}
|
|
36
|
+
selectedKindQuery={{ isLoading: false, data: [web, billing], resourceName: 'vulnerabilityreports', group }}
|
|
37
|
+
locationPathname="/resources/vulnerabilityreports"
|
|
38
|
+
locationSearch={locationSearch}
|
|
39
|
+
onNavigate={() => {}}
|
|
40
|
+
onResourceClick={onResourceClick}
|
|
41
|
+
/></KeyboardShortcutProvider>))
|
|
42
|
+
return onResourceClick
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('Trivy report table search', () => {
|
|
46
|
+
it('finds a report by a CVE in its findings', () => {
|
|
47
|
+
const selected = render('CVE-2026-109618')
|
|
48
|
+
act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })))
|
|
49
|
+
act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
|
50
|
+
expect(selected).toHaveBeenCalledTimes(1)
|
|
51
|
+
expect(selected).toHaveBeenCalledWith(expect.objectContaining({ name: web.metadata.name }))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('says what it searched when nothing matches', () => {
|
|
55
|
+
render('CVE-1999-0001')
|
|
56
|
+
expect(host.textContent).toContain('No results for "CVE-1999-0001"')
|
|
57
|
+
expect(host.textContent).toContain('Searched names, namespaces and report findings')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ResourceCoverage } from '../../types/resource-coverage'
|
|
2
|
+
import { resourceCoverageNotice, resourceEmptyMessage, resourceNarrowedNotice } from '../../utils/resource-coverage'
|
|
1
3
|
import React, { useState, useMemo, useEffect, useCallback, useDeferredValue, useRef, useContext, useId } from 'react'
|
|
2
4
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
5
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
@@ -148,6 +150,7 @@ import {
|
|
|
148
150
|
SEVERITY_DOT_COLOR,
|
|
149
151
|
healthColors,
|
|
150
152
|
} from './resource-utils'
|
|
153
|
+
import { TRIVY_GROUP, searchTrivyFindings, type TrivyFindingMatch, type TrivyFindingsSearch } from './resource-utils-trivy'
|
|
151
154
|
import { getGenericResourceStatus } from './generic-status'
|
|
152
155
|
import { ActiveFilterBar } from './ActiveFilterBar'
|
|
153
156
|
import { getIstioGatewayServerCount, getIstioGatewaySelectorString } from './resource-utils-istio'
|
|
@@ -156,7 +159,7 @@ import {
|
|
|
156
159
|
printerCellTone, printerColumnKey, printerColumnWidth, printerTableKey, printerTableMatchesKind, readPrinterCell,
|
|
157
160
|
} from './printer-columns'
|
|
158
161
|
import { SEVERITY_BADGE, SEVERITY_TEXT, healthToSeverity, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
|
|
159
|
-
import { pluralize } from '../../utils/pluralize'
|
|
162
|
+
import { englishPlural, isEnglishPlural, pluralize } from '../../utils/pluralize'
|
|
160
163
|
import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
|
|
161
164
|
import { formatRelativeAgeTime, parseQuantityToNumber } from '../../utils/format'
|
|
162
165
|
import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
|
|
@@ -3363,6 +3366,7 @@ interface ResourcesViewData {
|
|
|
3363
3366
|
export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
|
|
3364
3367
|
|
|
3365
3368
|
export interface ResourceQueryResult {
|
|
3369
|
+
coverage?: ResourceCoverage
|
|
3366
3370
|
resourceName?: string
|
|
3367
3371
|
group?: string
|
|
3368
3372
|
data?: any[]
|
|
@@ -3398,6 +3402,7 @@ interface ResourcesViewProps {
|
|
|
3398
3402
|
resourceQueries?: ResourceQueryResult[]
|
|
3399
3403
|
// Lightweight counts for sidebar badges (from /api/resource-counts)
|
|
3400
3404
|
resourceCounts?: Record<string, number | null>
|
|
3405
|
+
resourceCoverage?: Record<string, ResourceCoverage>
|
|
3401
3406
|
resourceForbidden?: string[]
|
|
3402
3407
|
/** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
|
|
3403
3408
|
* keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
|
|
@@ -3551,6 +3556,7 @@ const DEFAULT_KIND_INFO: SelectedKindInfo = { name: 'pods', kind: 'Pod', group:
|
|
|
3551
3556
|
export const LOADED_RESOURCE_COUNT_TTL_MS = 5 * 60 * 1000
|
|
3552
3557
|
|
|
3553
3558
|
interface LoadedResourceCount {
|
|
3559
|
+
coverage?: ResourceCoverage
|
|
3554
3560
|
count: number
|
|
3555
3561
|
expiresAt: number
|
|
3556
3562
|
}
|
|
@@ -3660,6 +3666,22 @@ export function deriveSidebarResourceCounts(
|
|
|
3660
3666
|
return results
|
|
3661
3667
|
}
|
|
3662
3668
|
|
|
3669
|
+
export function deriveSidebarResourceCoverage(
|
|
3670
|
+
resourceCounts: Record<string, number | null> | undefined,
|
|
3671
|
+
resourceCoverage: Record<string, ResourceCoverage> | undefined,
|
|
3672
|
+
loadedCountCache: LoadedResourceCountCache,
|
|
3673
|
+
now = Date.now(),
|
|
3674
|
+
): Record<string, ResourceCoverage> {
|
|
3675
|
+
const result: Record<string, ResourceCoverage> = { ...resourceCoverage }
|
|
3676
|
+
for (const key of Object.keys(resourceCounts ?? {})) {
|
|
3677
|
+
if (!(key in result)) result[key] = { status: 'unknown' }
|
|
3678
|
+
}
|
|
3679
|
+
for (const [key, cached] of Object.entries(loadedCountCache)) {
|
|
3680
|
+
if (!(key in result) && cached.expiresAt > now) result[key] = cached.coverage ?? { status: 'unknown' }
|
|
3681
|
+
}
|
|
3682
|
+
return result
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3663
3685
|
const FILTER_INTENT_PARAMS = [...RESOURCE_LIST_FILTER_PARAMS, 'resource']
|
|
3664
3686
|
|
|
3665
3687
|
/**
|
|
@@ -3694,6 +3716,7 @@ export function ResourcesView({
|
|
|
3694
3716
|
apiResources: apiResourcesProp,
|
|
3695
3717
|
resourceQueries: resourceQueriesProp,
|
|
3696
3718
|
resourceCounts: resourceCountsProp,
|
|
3719
|
+
resourceCoverage: resourceCoverageProp,
|
|
3697
3720
|
resourceForbidden: resourceForbiddenProp,
|
|
3698
3721
|
resourceReasons,
|
|
3699
3722
|
resourceUnavailable: resourceUnavailableProp,
|
|
@@ -5165,16 +5188,16 @@ export function ResourcesView({
|
|
|
5165
5188
|
}
|
|
5166
5189
|
|
|
5167
5190
|
const existing = next[selectedKindCountKey]
|
|
5168
|
-
if (existing?.count === selectedLoadedResourceCount && existing.expiresAt === expiresAt) {
|
|
5191
|
+
if (existing?.count === selectedLoadedResourceCount && existing.expiresAt === expiresAt && existing.coverage === selectedQuery?.coverage) {
|
|
5169
5192
|
return changed ? next : prev
|
|
5170
5193
|
}
|
|
5171
5194
|
|
|
5172
5195
|
return {
|
|
5173
5196
|
...next,
|
|
5174
|
-
[selectedKindCountKey]: { count: selectedLoadedResourceCount, expiresAt },
|
|
5197
|
+
[selectedKindCountKey]: { count: selectedLoadedResourceCount, expiresAt, coverage: selectedQuery?.coverage },
|
|
5175
5198
|
}
|
|
5176
5199
|
})
|
|
5177
|
-
}, [selectedQueryHasLoadedCount, selectedLoadedResourceCount, selectedKindCountKey, selectedQuery?.dataUpdatedAt])
|
|
5200
|
+
}, [selectedQueryHasLoadedCount, selectedLoadedResourceCount, selectedKindCountKey, selectedQuery?.dataUpdatedAt, selectedQuery?.coverage])
|
|
5178
5201
|
|
|
5179
5202
|
// Derive counts — prefer lightweight resourceCounts prop over full query data
|
|
5180
5203
|
const counts = useMemo(() => {
|
|
@@ -5195,6 +5218,12 @@ export function ResourcesView({
|
|
|
5195
5218
|
return results
|
|
5196
5219
|
}, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceUnavailableProp, loadedCountCache, selectedQueryHasLoadedCount, selectedLoadedResourceCount, selectedKindCountKey, resourceQueries])
|
|
5197
5220
|
|
|
5221
|
+
const sidebarResourceCoverage = useMemo(() => {
|
|
5222
|
+
const result = deriveSidebarResourceCoverage(resourceCountsProp, resourceCoverageProp, loadedCountCache)
|
|
5223
|
+
if (selectedQueryHasLoadedCount) result[selectedKindCountKey] = selectedQuery?.coverage ?? { status: 'unknown' }
|
|
5224
|
+
return result
|
|
5225
|
+
}, [resourceCountsProp, resourceCoverageProp, loadedCountCache, selectedQueryHasLoadedCount, selectedKindCountKey, selectedQuery?.coverage])
|
|
5226
|
+
|
|
5198
5227
|
const sidebarResourceUnavailable = useMemo(() => {
|
|
5199
5228
|
if (!resourceUnavailableProp?.length) {
|
|
5200
5229
|
return resourceUnavailableProp
|
|
@@ -5462,13 +5491,18 @@ export function ResourcesView({
|
|
|
5462
5491
|
}
|
|
5463
5492
|
}, [regexMode, deferredSearchTerm])
|
|
5464
5493
|
|
|
5494
|
+
const isTrivyKind = selectedKind.group === TRIVY_GROUP
|
|
5495
|
+
|
|
5465
5496
|
// Filter resources by search term, status, problems, and sort
|
|
5466
|
-
const filteredResources = useMemo(() => {
|
|
5467
|
-
if (!tableActive || !resources) return []
|
|
5497
|
+
const { list: filteredResources, findings: findingsSearch } = useMemo((): { list: any[]; findings: TrivyFindingsSearch | null } => {
|
|
5498
|
+
if (!tableActive || !resources) return { list: [], findings: null }
|
|
5468
5499
|
|
|
5469
5500
|
let result = resources
|
|
5501
|
+
let findings: TrivyFindingsSearch | null = null
|
|
5470
5502
|
|
|
5471
|
-
// Apply search filter
|
|
5503
|
+
// Apply search filter. On Trivy report kinds a plain search also matches
|
|
5504
|
+
// findings and runs below, after the other filters. Regex mode stays on
|
|
5505
|
+
// names: a pattern run over every finding could backtrack for seconds.
|
|
5472
5506
|
if (deferredSearchTerm) {
|
|
5473
5507
|
if (regexMode) {
|
|
5474
5508
|
const re = searchRegex.re
|
|
@@ -5478,7 +5512,7 @@ export function ResourcesView({
|
|
|
5478
5512
|
re.test(r.metadata?.namespace ?? '')
|
|
5479
5513
|
)
|
|
5480
5514
|
}
|
|
5481
|
-
} else {
|
|
5515
|
+
} else if (!isTrivyKind) {
|
|
5482
5516
|
const term = deferredSearchTerm.toLowerCase()
|
|
5483
5517
|
result = result.filter((r: any) =>
|
|
5484
5518
|
r.metadata?.name?.toLowerCase().includes(term) ||
|
|
@@ -5554,6 +5588,19 @@ export function ResourcesView({
|
|
|
5554
5588
|
})
|
|
5555
5589
|
}
|
|
5556
5590
|
|
|
5591
|
+
// Trivy findings are searched after the other filters, so rows they hide
|
|
5592
|
+
// don't spend the findings budget.
|
|
5593
|
+
if (deferredSearchTerm && !regexMode && isTrivyKind) {
|
|
5594
|
+
const term = deferredSearchTerm.toLowerCase()
|
|
5595
|
+
const matched = searchTrivyFindings(result, term)
|
|
5596
|
+
findings = matched
|
|
5597
|
+
result = result.filter((r: any) =>
|
|
5598
|
+
r.metadata?.name?.toLowerCase().includes(term) ||
|
|
5599
|
+
r.metadata?.namespace?.toLowerCase().includes(term) ||
|
|
5600
|
+
matched.matches.has(r)
|
|
5601
|
+
)
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5557
5604
|
// Apply custom sorting if set. Extra columns override
|
|
5558
5605
|
// the built-in getSortValue for their key.
|
|
5559
5606
|
if (effectiveSortColumn && sortDirection) {
|
|
@@ -5649,8 +5696,8 @@ export function ResourcesView({
|
|
|
5649
5696
|
}
|
|
5650
5697
|
}
|
|
5651
5698
|
|
|
5652
|
-
return result
|
|
5653
|
-
}, [tableActive, resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, getDisplayFilterValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
5699
|
+
return { list: result, findings }
|
|
5700
|
+
}, [tableActive, resources, deferredSearchTerm, regexMode, searchRegex, isTrivyKind, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, getDisplayFilterValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
5654
5701
|
|
|
5655
5702
|
// For nodes table: compute the majority minor version so outliers can be highlighted
|
|
5656
5703
|
const majorityNodeMinorVersion = useMemo(() => {
|
|
@@ -6063,6 +6110,7 @@ export function ResourcesView({
|
|
|
6063
6110
|
onKindChange={onKindChange}
|
|
6064
6111
|
apiResources={apiResourcesProp}
|
|
6065
6112
|
resourceCounts={counts}
|
|
6113
|
+
resourceCoverage={sidebarResourceCoverage}
|
|
6066
6114
|
resourceForbidden={Array.from(forbiddenKinds)}
|
|
6067
6115
|
resourceUnavailable={sidebarResourceUnavailable}
|
|
6068
6116
|
pinned={pinned}
|
|
@@ -6088,7 +6136,7 @@ export function ResourcesView({
|
|
|
6088
6136
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
6089
6137
|
<Input
|
|
6090
6138
|
ref={searchInputRef}
|
|
6091
|
-
placeholder={regexMode ? 'Search by regex... (press /)' : 'Search... (press /)'}
|
|
6139
|
+
placeholder={regexMode ? 'Search by regex... (press /)' : isTrivyKind ? 'Search names and findings... (press /)' : 'Search... (press /)'}
|
|
6092
6140
|
value={searchTerm}
|
|
6093
6141
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
6094
6142
|
onKeyDown={(e) => {
|
|
@@ -6150,6 +6198,11 @@ export function ResourcesView({
|
|
|
6150
6198
|
<X className="w-3.5 h-3.5" />
|
|
6151
6199
|
</button>
|
|
6152
6200
|
)}
|
|
6201
|
+
{!searchRegex.error && findingsSearch?.truncated && (
|
|
6202
|
+
<div className="absolute left-0 top-full mt-1 z-10 px-2 py-1 rounded bg-theme-elevated border border-theme-border text-xs text-theme-text-secondary shadow-theme-sm">
|
|
6203
|
+
Too many findings to search them all here; some weren't searched.
|
|
6204
|
+
</div>
|
|
6205
|
+
)}
|
|
6153
6206
|
{searchRegex.error && (
|
|
6154
6207
|
<div
|
|
6155
6208
|
title={searchRegex.error}
|
|
@@ -6524,6 +6577,18 @@ export function ResourcesView({
|
|
|
6524
6577
|
</div>
|
|
6525
6578
|
)}
|
|
6526
6579
|
|
|
6580
|
+
{!isLoading && !selectedQueryError && resourceCoverageNotice(selectedQuery?.coverage, isEnglishPlural(selectedKind.kind) ? selectedKind.kind : englishPlural(selectedKind.kind)) && (
|
|
6581
|
+
<div role="status" className="px-4 py-2 text-xs text-warning-text border-b border-theme-border bg-theme-surface">
|
|
6582
|
+
{resourceCoverageNotice(selectedQuery?.coverage, isEnglishPlural(selectedKind.kind) ? selectedKind.kind : englishPlural(selectedKind.kind))}
|
|
6583
|
+
</div>
|
|
6584
|
+
)}
|
|
6585
|
+
|
|
6586
|
+
{!isLoading && !selectedQueryError && resourceNarrowedNotice(selectedQuery?.coverage) && (
|
|
6587
|
+
<div role="status" className="px-4 py-2 text-xs text-theme-text-tertiary border-b border-theme-border bg-theme-surface">
|
|
6588
|
+
{resourceNarrowedNotice(selectedQuery?.coverage)}
|
|
6589
|
+
</div>
|
|
6590
|
+
)}
|
|
6591
|
+
|
|
6527
6592
|
{!isLoading && !isSelectedForbidden && !largeListGuard && filteredResources.length > 0 && (
|
|
6528
6593
|
<ActiveFilterBar
|
|
6529
6594
|
columnFilters={columnFilters}
|
|
@@ -6565,7 +6630,7 @@ export function ResourcesView({
|
|
|
6565
6630
|
/>
|
|
6566
6631
|
</div>
|
|
6567
6632
|
</div>
|
|
6568
|
-
) : selectedQueryError && !Array.isArray(resources) ? (
|
|
6633
|
+
) : selectedQueryError && !Array.isArray(resources) && !largeListGuard ? (
|
|
6569
6634
|
// A failed fetch with no rows to fall back on must not reach the
|
|
6570
6635
|
// empty state below — it would tell the user the cluster has none.
|
|
6571
6636
|
<FetchResult
|
|
@@ -6585,7 +6650,9 @@ export function ResourcesView({
|
|
|
6585
6650
|
<p className="text-sm mt-1 max-w-xl">
|
|
6586
6651
|
{largeListGuard.reason === 'count-unavailable'
|
|
6587
6652
|
? 'Radar could not verify this list is small enough to load. Choose a namespace or smaller namespace set to try again.'
|
|
6588
|
-
:
|
|
6653
|
+
: largeListGuard.count == null
|
|
6654
|
+
? 'This list exceeds the resource limit. Choose a namespace or smaller namespace set to load this view.'
|
|
6655
|
+
: `${largeListGuard.count.toLocaleString()} ${largeListGuard.kind.toLowerCase()} were read in the current scope. Choose a namespace or smaller namespace set to load this view.`}
|
|
6589
6656
|
</p>
|
|
6590
6657
|
<p className="text-xs mt-2 text-theme-text-disabled">
|
|
6591
6658
|
Radar limits full table loads to {largeListGuard.limit.toLocaleString()} resources to keep the UI responsive.
|
|
@@ -6596,8 +6663,8 @@ export function ResourcesView({
|
|
|
6596
6663
|
</div>
|
|
6597
6664
|
) : filteredResources.length === 0 ? (
|
|
6598
6665
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
|
|
6599
|
-
<p>
|
|
6600
|
-
{emptyKindNote(selectedKind.kind, selectedKind.group, resources?.length ?? 0) && (
|
|
6666
|
+
<p>{resourceEmptyMessage(selectedKind.kind, selectedQuery?.coverage, (resources?.length ?? 0) > 0)}</p>
|
|
6667
|
+
{selectedQuery?.coverage?.status !== 'partial' && emptyKindNote(selectedKind.kind, selectedKind.group, resources?.length ?? 0) && (
|
|
6601
6668
|
<p className="text-xs mt-2 max-w-md text-center text-theme-text-disabled">
|
|
6602
6669
|
{emptyKindNote(selectedKind.kind, selectedKind.group, resources?.length ?? 0)}
|
|
6603
6670
|
</p>
|
|
@@ -6614,6 +6681,13 @@ export function ResourcesView({
|
|
|
6614
6681
|
<X className="w-3.5 h-3.5" />
|
|
6615
6682
|
</button>
|
|
6616
6683
|
)}
|
|
6684
|
+
{searchTerm && isTrivyKind && (
|
|
6685
|
+
<p className="text-xs mt-1 text-theme-text-tertiary">
|
|
6686
|
+
{regexMode
|
|
6687
|
+
? 'Regex search matches names and namespaces only.'
|
|
6688
|
+
: 'Searched names, namespaces and report findings (CVE, package, check ID, image).'}
|
|
6689
|
+
</p>
|
|
6690
|
+
)}
|
|
6617
6691
|
{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>}
|
|
6618
6692
|
<ActiveFilterBar
|
|
6619
6693
|
columnFilters={columnFilters}
|
|
@@ -6922,6 +6996,7 @@ export function ResourcesView({
|
|
|
6922
6996
|
comparePickIndex={pickIdx}
|
|
6923
6997
|
rowHref={rowHrefFor?.(resource)}
|
|
6924
6998
|
onRowCheckToggle={toggleChecked}
|
|
6999
|
+
findingMatch={findingsSearch?.matches.get(resource)}
|
|
6925
7000
|
/>
|
|
6926
7001
|
)
|
|
6927
7002
|
}}
|
|
@@ -7089,6 +7164,9 @@ interface ResourceRowCellsProps {
|
|
|
7089
7164
|
* is unaffected (still toggles picks). */
|
|
7090
7165
|
rowHref?: string
|
|
7091
7166
|
onRowCheckToggle?: (resource: any) => void
|
|
7167
|
+
/** What a Trivy report matched the table search through, when its name
|
|
7168
|
+
* and namespace didn't. */
|
|
7169
|
+
findingMatch?: TrivyFindingMatch
|
|
7092
7170
|
}
|
|
7093
7171
|
|
|
7094
7172
|
function rowHighlightClass(
|
|
@@ -7110,7 +7188,7 @@ function rowHighlightClass(
|
|
|
7110
7188
|
return 'group-hover/row:bg-theme-surface/50'
|
|
7111
7189
|
}
|
|
7112
7190
|
|
|
7113
|
-
const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, storeProviders, onRowClick, onRowMouseEnter, compareMode, comparePickIndex = -1, rowHref, onRowCheckToggle }: ResourceRowCellsProps) {
|
|
7191
|
+
const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, storeProviders, onRowClick, onRowMouseEnter, compareMode, comparePickIndex = -1, rowHref, onRowCheckToggle, findingMatch }: ResourceRowCellsProps) {
|
|
7114
7192
|
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
|
|
7115
7193
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
7116
7194
|
const onClick = onRowClick ? () => onRowClick(resource, !!isSelected) : undefined
|
|
@@ -7180,6 +7258,7 @@ const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind,
|
|
|
7180
7258
|
storeProviders={storeProviders}
|
|
7181
7259
|
extraColumn={extraColumnsByKey?.get(col.key)}
|
|
7182
7260
|
nameHref={col.key === 'name' ? rowHref : undefined}
|
|
7261
|
+
findingMatch={col.key === 'name' ? findingMatch : undefined}
|
|
7183
7262
|
/>
|
|
7184
7263
|
</td>
|
|
7185
7264
|
))}
|
|
@@ -7229,9 +7308,10 @@ interface CellContentProps {
|
|
|
7229
7308
|
/** When set on the name column, the resource name renders as `<a href>`
|
|
7230
7309
|
* so ⌘-click / copy-link / hover-URL all work. */
|
|
7231
7310
|
nameHref?: string
|
|
7311
|
+
findingMatch?: TrivyFindingMatch
|
|
7232
7312
|
}
|
|
7233
7313
|
|
|
7234
|
-
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, storeProviders, extraColumn, nameHref }: CellContentProps) {
|
|
7314
|
+
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, storeProviders, extraColumn, nameHref, findingMatch }: CellContentProps) {
|
|
7235
7315
|
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
7236
7316
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
7237
7317
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
@@ -7249,7 +7329,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7249
7329
|
const auditTotal = audit ? audit.danger + audit.warning : 0
|
|
7250
7330
|
return (
|
|
7251
7331
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
7252
|
-
<Tooltip content={meta.name}>
|
|
7332
|
+
<Tooltip content={meta.name} wrapperClassName={findingMatch ? 'min-w-0' : undefined}>
|
|
7253
7333
|
{nameHref ? (
|
|
7254
7334
|
<a
|
|
7255
7335
|
href={nameHref}
|
|
@@ -7264,6 +7344,13 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7264
7344
|
)}
|
|
7265
7345
|
</Tooltip>
|
|
7266
7346
|
<CopyNameButton name={meta.name} />
|
|
7347
|
+
{findingMatch && (
|
|
7348
|
+
<Tooltip content={findingMatch.text}>
|
|
7349
|
+
<span className="shrink-0 text-xs italic text-theme-text-tertiary cursor-help">
|
|
7350
|
+
{findingMatch.site === 'image' ? 'in image' : 'in findings'}
|
|
7351
|
+
</span>
|
|
7352
|
+
</Tooltip>
|
|
7353
|
+
)}
|
|
7267
7354
|
{auditTotal > 0 && audit && (
|
|
7268
7355
|
<Tooltip content={audit.messages && audit.messages.length > 0
|
|
7269
7356
|
? <AuditBadgeTooltip messages={audit.messages} />
|
|
@@ -7400,7 +7487,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7400
7487
|
// Disambiguate Gateway API vs Istio Gateway by apiVersion. Retained
|
|
7401
7488
|
// alongside the case above because not every call site normalizes the
|
|
7402
7489
|
// kind through GROUP_QUALIFIED_COLUMN_KEYS before dispatching.
|
|
7403
|
-
if (resource.apiVersion
|
|
7490
|
+
if (isApiGroup(resource.apiVersion, 'networking.istio.io')) {
|
|
7404
7491
|
return <IstioGatewayCell resource={resource} column={column} />
|
|
7405
7492
|
}
|
|
7406
7493
|
return <GatewayCell resource={resource} column={column} />
|