@skyhook-io/k8s-ui 1.17.1 → 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 (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  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/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -0,0 +1,174 @@
1
+ import { useId, useLayoutEffect, useRef, useState } from 'react'
2
+ import type React from 'react'
3
+ import { GripVertical, X } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { Tooltip } from '../ui/Tooltip'
6
+ import { isMac } from '../../utils/platform'
7
+ import { moveColumn, pinnedColumnCount } from './column-order'
8
+ import type { Column } from './ResourcesView'
9
+
10
+ interface ColumnPickerListProps {
11
+ /** Every offered column, in display order. */
12
+ columns: Column[]
13
+ visibleColumns: Set<string>
14
+ customColumnKeys: Set<string>
15
+ onToggle: (key: string) => void
16
+ onRemoveCustom: (key: string) => void
17
+ /** Receives the movable column keys in their new order. */
18
+ onReorder: (order: string[]) => void
19
+ }
20
+
21
+ /** The Columns picker's rows: show/hide, remove a custom column, and reorder by
22
+ * dragging the grip or with Alt/Option+Up/Down on the row's checkbox. */
23
+ export function ColumnPickerList({
24
+ columns,
25
+ visibleColumns,
26
+ customColumnKeys,
27
+ onToggle,
28
+ onRemoveCustom,
29
+ onReorder,
30
+ }: ColumnPickerListProps) {
31
+ const pinned = pinnedColumnCount(columns)
32
+ const [dragKey, setDragKey] = useState<string | null>(null)
33
+ // Insertion point among the movable rows: 0 = before the first one.
34
+ const [dropGap, setDropGap] = useState<number | null>(null)
35
+ const [announcement, setAnnouncement] = useState('')
36
+ const hintId = useId()
37
+ const mac = isMac()
38
+
39
+ // React moves a keyed row by re-inserting DOM nodes, and re-inserting the
40
+ // focused checkbox drops focus. Put it back so repeated presses keep working.
41
+ const refocus = useRef<HTMLElement | null>(null)
42
+ useLayoutEffect(() => {
43
+ const el = refocus.current
44
+ if (!el) return
45
+ refocus.current = null
46
+ if (el.isConnected && document.activeElement !== el) el.focus()
47
+ })
48
+
49
+ const commitMove = (key: string, toIndex: number) => {
50
+ const next = moveColumn(columns, key, toIndex)
51
+ if (!next) return false
52
+ onReorder(next)
53
+ const col = columns.find(c => c.key === key)
54
+ setAnnouncement(`${col?.label ?? key} moved to position ${pinned + next.indexOf(key) + 1} of ${columns.length}.`)
55
+ return true
56
+ }
57
+
58
+ const handleKeyDown = (e: React.KeyboardEvent, key: string, movableIndex: number) => {
59
+ if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
60
+ e.preventDefault()
61
+ e.stopPropagation()
62
+ const up = e.key === 'ArrowUp'
63
+ refocus.current = e.target instanceof HTMLElement ? e.target : null
64
+ if (!commitMove(key, movableIndex + (up ? -1 : 1))) {
65
+ refocus.current = null
66
+ const label = columns.find(c => c.key === key)?.label ?? key
67
+ setAnnouncement(up ? `${label} is already at the top.` : `${label} is already at the bottom.`)
68
+ }
69
+ }
70
+
71
+ // The gap above the row when the pointer is in its top half, else below it.
72
+ const gapAt = (e: React.DragEvent, movableIndex: number) => {
73
+ const rect = e.currentTarget.getBoundingClientRect()
74
+ return e.clientY < rect.top + rect.height / 2 ? movableIndex : movableIndex + 1
75
+ }
76
+
77
+ const endDrag = () => {
78
+ setDragKey(null)
79
+ setDropGap(null)
80
+ }
81
+
82
+ return (
83
+ <>
84
+ <span id={hintId} className="sr-only">
85
+ {mac ? 'Press Option+Up Arrow or Option+Down Arrow to move this column.' : 'Press Alt+Up Arrow or Alt+Down Arrow to move this column.'}
86
+ </span>
87
+ <span className="sr-only" role="status" aria-live="polite">{announcement}</span>
88
+ {columns.map((col, i) => {
89
+ const isCustom = customColumnKeys.has(col.key)
90
+ const movableIndex = i - pinned
91
+ const movable = movableIndex >= 0
92
+ const fromIndex = dragKey ? columns.findIndex(c => c.key === dragKey) - pinned : -1
93
+ const showGap = (gap: number) => dropGap === gap && gap !== fromIndex && gap !== fromIndex + 1
94
+ return (
95
+ <div
96
+ key={col.key}
97
+ data-column-row
98
+ className={clsx(
99
+ 'group relative flex items-center gap-1.5 pl-1.5 pr-3 py-1.5 hover:bg-theme-elevated',
100
+ dragKey === col.key && 'opacity-50',
101
+ )}
102
+ onKeyDown={movable ? e => handleKeyDown(e, col.key, movableIndex) : undefined}
103
+ onDragOver={movable && dragKey ? e => {
104
+ e.preventDefault()
105
+ e.dataTransfer.dropEffect = 'move'
106
+ setDropGap(gapAt(e, movableIndex))
107
+ } : undefined}
108
+ onDrop={movable && dragKey ? e => {
109
+ e.preventDefault()
110
+ const gap = gapAt(e, movableIndex)
111
+ commitMove(dragKey, gap > fromIndex ? gap - 1 : gap)
112
+ endDrag()
113
+ } : undefined}
114
+ >
115
+ {movable && showGap(movableIndex) && (
116
+ <span aria-hidden className="pointer-events-none absolute inset-x-2 -top-px h-0.5 rounded-full bg-accent" />
117
+ )}
118
+ {movable && showGap(movableIndex + 1) && i === columns.length - 1 && (
119
+ <span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
120
+ )}
121
+ {movable ? (
122
+ <Tooltip content={mac ? 'Drag to reorder, or press ⌥↑ / ⌥↓' : 'Drag to reorder, or press Alt+↑ / Alt+↓'} position="left">
123
+ <span
124
+ aria-hidden
125
+ draggable
126
+ onDragStart={e => {
127
+ e.dataTransfer.effectAllowed = 'move'
128
+ // Firefox starts a drag only when data is set.
129
+ e.dataTransfer.setData('text/plain', col.key)
130
+ const row = e.currentTarget.closest('[data-column-row]')
131
+ if (row instanceof HTMLElement) e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2)
132
+ setDragKey(col.key)
133
+ }}
134
+ onDragEnd={endDrag}
135
+ className="flex shrink-0 cursor-grab items-center text-theme-text-tertiary opacity-60 group-hover:opacity-100 active:cursor-grabbing"
136
+ >
137
+ <GripVertical className="w-3.5 h-3.5" />
138
+ </span>
139
+ </Tooltip>
140
+ ) : (
141
+ <span aria-hidden className="w-3.5 shrink-0" />
142
+ )}
143
+ <label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
144
+ <input
145
+ type="checkbox"
146
+ checked={visibleColumns.has(col.key)}
147
+ onChange={() => onToggle(col.key)}
148
+ disabled={col.key === 'name'}
149
+ aria-describedby={movable ? hintId : undefined}
150
+ className="rounded border-theme-border"
151
+ />
152
+ <span className={clsx(
153
+ 'text-sm truncate',
154
+ col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
155
+ )} title={col.tooltip || col.label}>
156
+ {col.label}
157
+ </span>
158
+ </label>
159
+ {isCustom && (
160
+ <button
161
+ type="button"
162
+ onClick={() => onRemoveCustom(col.key)}
163
+ className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
164
+ title="Remove column"
165
+ >
166
+ <X className="w-3.5 h-3.5" />
167
+ </button>
168
+ )}
169
+ </div>
170
+ )
171
+ })}
172
+ </>
173
+ )
174
+ }
@@ -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
  )
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from 'react'
2
+ import { act, type ComponentProps } from 'react'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import { ResourcesView } from './ResourcesView'
@@ -83,7 +83,7 @@ it('closes bulk dialogs when the same kind switches to a custom view and keeps t
83
83
  expect(document.querySelector('[role="dialog"]')).toBeNull()
84
84
  render(false)
85
85
  expect(host.textContent).not.toContain('1 selected')
86
- expect(host.querySelector('input[title="Select all"]')).toBeNull()
86
+ expect(host.querySelector<HTMLInputElement>('input[title="Select all"]')!.checked).toBe(false)
87
87
  expect(document.querySelector('[role="dialog"]')).toBeNull()
88
88
  act(() => host.querySelector<SVGElement>('button .lucide-git-compare')!.closest<HTMLButtonElement>('button')!.click())
89
89
  act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })))
@@ -147,3 +147,150 @@ it('skips hidden-table sort and filter aggregation for a loaded list, then resum
147
147
  expect(getSortValue).toHaveBeenCalled()
148
148
  expect(getFilterValue).toHaveBeenCalled()
149
149
  })
150
+
151
+ const deployments: APIResource = { group: 'apps', version: 'v1', kind: 'Deployment', name: 'deployments', namespaced: true, isCrd: false, verbs: ['get', 'list'] }
152
+ const widgets: APIResource = { group: 'example.com', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
153
+ const checkboxKey = 'radar-resource-checkboxes'
154
+ const key = (value: string) => act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true })))
155
+ const required = <T extends Element>(selector: string, parent: ParentNode = host): T => {
156
+ const element = parent.querySelector<T>(selector)
157
+ if (!element) throw new Error(`Missing element: ${selector}`)
158
+ return element
159
+ }
160
+ const clickButton = (label: string, parent: ParentNode = host) => {
161
+ const button = [...parent.querySelectorAll('button')].find(candidate => candidate.textContent?.trim() === label)
162
+ if (!button) throw new Error(`Missing button: ${label}`)
163
+ act(() => button.click())
164
+ }
165
+ const renderBulkTable = (initialKind = pods, bulkEnabled = true) => {
166
+ const selected = vi.fn()
167
+ const remove = vi.fn<NonNullable<ComponentProps<typeof ResourcesView>['onBulkDelete']>>()
168
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
169
+ const render = (kind = initialKind, items = [pod]) => act(() => root.render(<KeyboardShortcutProvider><ResourcesView
170
+ namespaces={['test']}
171
+ apiResources={[pods, deployments, widgets]}
172
+ resourceCounts={{ [kind.kind]: items.length }}
173
+ selectedKindQuery={{ isLoading: false, data: items, resourceName: kind.name, group: kind.group }}
174
+ locationPathname={`/resources/${kind.name}`}
175
+ locationSearch={kind.group ? `?apiGroup=${kind.group}` : ''}
176
+ onNavigate={() => {}}
177
+ onResourceClick={selected}
178
+ onBulkDelete={bulkEnabled ? remove : undefined}
179
+ /></KeyboardShortcutProvider>))
180
+ render()
181
+ return { selected, remove, render }
182
+ }
183
+
184
+ it.each([pods, deployments, widgets])('hides selection by default on $kind and enables it without an empty action bar', kind => {
185
+ renderBulkTable(kind)
186
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
187
+ act(() => required<HTMLButtonElement>('button[aria-label="Show selection checkboxes"]').click())
188
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
189
+ expect(host.textContent).not.toContain('0 selected')
190
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
191
+ })
192
+
193
+ it('remembers both visibility choices across remounts without remembering selected rows', () => {
194
+ renderBulkTable()
195
+ key('b')
196
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
197
+ expect(host.textContent).toContain('1 selected')
198
+ act(() => root.unmount()); host.remove()
199
+ renderBulkTable(deployments)
200
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
201
+ expect(host.textContent).not.toContain('1 selected')
202
+ key('b')
203
+ expect(localStorage.getItem(checkboxKey)).toBe('false')
204
+ act(() => root.unmount()); host.remove()
205
+ renderBulkTable()
206
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
207
+ })
208
+
209
+ it('keeps keyboard activation for inspection while checkboxes select', () => {
210
+ const { selected } = renderBulkTable()
211
+ key('b')
212
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
213
+ expect(selected).not.toHaveBeenCalled()
214
+ expect(host.textContent).toContain('1 selected')
215
+ key('j'); key('Enter')
216
+ expect(selected).toHaveBeenCalledTimes(1)
217
+ expect(host.textContent).toContain('1 selected')
218
+ key('Escape')
219
+ expect(host.textContent).not.toContain('1 selected')
220
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
221
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
222
+ clickButton('Clear selection')
223
+ expect(host.textContent).not.toContain('1 selected')
224
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
225
+ })
226
+
227
+ it('temporarily hides checkboxes for compare and clears selection on kind changes', () => {
228
+ const { render } = renderBulkTable()
229
+ key('b')
230
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
231
+ act(() => required<HTMLButtonElement>('button:has(.lucide-git-compare)').click())
232
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
233
+ key('Escape')
234
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
235
+ expect(host.textContent).not.toContain('1 selected')
236
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
237
+ render(deployments)
238
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
239
+ expect(host.textContent).not.toContain('1 selected')
240
+ render(widgets)
241
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
242
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
243
+ })
244
+
245
+ it('can show selection from compare without turning off the saved preference', () => {
246
+ localStorage.setItem(checkboxKey, 'true')
247
+ renderBulkTable()
248
+ act(() => required<HTMLButtonElement>('button:has(.lucide-git-compare)').click())
249
+ key('b')
250
+ expect(host.querySelector('[aria-label="Compare resources tray"]')).toBeNull()
251
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
252
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
253
+ })
254
+
255
+ it('keeps delete confirmation and clears successful selection without hiding checkboxes', () => {
256
+ const { remove } = renderBulkTable()
257
+ key('b')
258
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
259
+ clickButton('Delete')
260
+ const dialog = required<HTMLElement>('[role="dialog"]', document)
261
+ expect(dialog.textContent).toContain('Delete 1 Pod?')
262
+ expect(remove).not.toHaveBeenCalled()
263
+ clickButton('Delete 1 resource', dialog)
264
+ expect(remove).toHaveBeenCalledWith([{ kind: 'pods', group: '', namespace: 'test', name: 'example' }], expect.objectContaining({ force: false }))
265
+ act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
266
+ expect(host.textContent).not.toContain('1 selected')
267
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
268
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
269
+ })
270
+
271
+ it.each(['false', 'garbage', 'null', '1'])('ignores invalid or disabled visibility value %s', value => {
272
+ localStorage.setItem(checkboxKey, value)
273
+ renderBulkTable()
274
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
275
+ })
276
+
277
+ it('keeps toggling usable when browser storage is unavailable', () => {
278
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage unavailable') })
279
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage unavailable') })
280
+ renderBulkTable()
281
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
282
+ key('b')
283
+ expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
284
+ key('b')
285
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
286
+ })
287
+
288
+ it('does not offer selection without a bulk action callback even with saved visibility', () => {
289
+ localStorage.setItem(checkboxKey, 'true')
290
+ renderBulkTable(pods, false)
291
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
292
+ expect(host.querySelector('button .lucide-list-checks')).toBeNull()
293
+ key('b')
294
+ expect(host.querySelector('input[title="Select all"]')).toBeNull()
295
+ expect(localStorage.getItem(checkboxKey)).toBe('true')
296
+ })
@@ -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
+ })