@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.
- package/README.md +13 -0
- package/package.json +4 -4
- 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/GitOpsGraphFilterRail.tsx +2 -2
- 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/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +18 -10
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- 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 +198 -93
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- 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 +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- 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 +48 -30
- 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/ui/index.ts +1 -1
- 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 +68 -14
- 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 +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -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
|
@@ -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
|
-
<
|
|
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
|
)
|
|
@@ -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"]')).
|
|
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
|
+
})
|