@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.
Files changed (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. 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
- <span className={clsx(
218
- 'text-xs py-0.5 rounded text-center font-mono',
219
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
220
- count < 1000 ? 'w-8' : 'w-9',
221
- )}>
222
- {count}
223
- </span>
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 confirmed-empty kinds. Unknown counts stay
406
- // visible as a dash so count coverage gaps do not masquerade as emptiness.
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 shouldShow = isSelectedResource || count === null || (count ?? 0) > 0 || showEmptyKinds
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={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', category.total < 1000 ? 'w-8' : 'w-9')}>
657
- {category.total}
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
- <span className={clsx(
752
- 'badge font-mono',
753
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
754
- )}>
755
- {count}
756
- </span>
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
- : `${largeListGuard.count?.toLocaleString()} ${largeListGuard.kind.toLowerCase()} are in the current scope. Choose a namespace or smaller namespace set to load this view.`}
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>No {selectedKind.kind} found</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?.includes('networking.istio.io')) {
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} />