@skyhook-io/k8s-ui 1.18.0 → 1.18.2
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 +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- 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 +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- 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 +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -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 +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -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 +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -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[]
|
|
@@ -76,6 +79,18 @@ export interface ResourcesSidebarProps {
|
|
|
76
79
|
/** Task destinations keyed by category name (e.g. "CloudNativePG"). A category
|
|
77
80
|
* with a workspace stays visible even when it has no resources. */
|
|
78
81
|
categoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
|
|
82
|
+
/** Display settings held by the parent, for a sidebar that unmounts between
|
|
83
|
+
* uses (the phone drawer) and should not reset them each time it opens. */
|
|
84
|
+
displayState?: SidebarDisplayState
|
|
85
|
+
onDisplayStateChange?: (state: SidebarDisplayState) => void
|
|
86
|
+
/** Escape in a non-empty kind filter clears it without letting the key
|
|
87
|
+
* reach an enclosing dialog, which would close. */
|
|
88
|
+
containEscape?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SidebarDisplayState {
|
|
92
|
+
showEmptyKinds: boolean
|
|
93
|
+
favoritesExpanded: boolean
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
// Persisted across remounts so collapsed categories survive tab switches
|
|
@@ -143,6 +158,7 @@ interface ResourceTypeButtonProps {
|
|
|
143
158
|
/** `null` means "count not loaded yet" — rendered as a placeholder so
|
|
144
159
|
* the badge doesn't flicker to "0" while the API call is in flight. */
|
|
145
160
|
count: number | null
|
|
161
|
+
coverage?: ResourceCoverage
|
|
146
162
|
isSelected: boolean
|
|
147
163
|
/** Keyboard-highlight state (arrow nav in the filter input). */
|
|
148
164
|
isHighlighted?: boolean
|
|
@@ -154,7 +170,7 @@ interface ResourceTypeButtonProps {
|
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
|
|
157
|
-
function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
|
|
173
|
+
function ResourceTypeButton({ resource, count, coverage, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
|
|
158
174
|
const Icon = getResourceIcon(resource.kind, resource.group)
|
|
159
175
|
return (
|
|
160
176
|
<button
|
|
@@ -214,13 +230,14 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
|
|
|
214
230
|
</span>
|
|
215
231
|
</Tooltip>
|
|
216
232
|
) : (
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
<Tooltip disabled={!resourceCountHint(coverage)} content={resourceCountHint(coverage)} position="left">
|
|
234
|
+
<span className={clsx(
|
|
235
|
+
'text-xs py-0.5 px-1 min-w-8 rounded text-center font-mono',
|
|
236
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
237
|
+
)}>
|
|
238
|
+
{resourceCountLabel(count, coverage)}
|
|
239
|
+
</span>
|
|
240
|
+
</Tooltip>
|
|
224
241
|
)}
|
|
225
242
|
</div>
|
|
226
243
|
</button>
|
|
@@ -234,6 +251,7 @@ export function ResourcesSidebar({
|
|
|
234
251
|
onKindChange,
|
|
235
252
|
apiResources,
|
|
236
253
|
resourceCounts,
|
|
254
|
+
resourceCoverage,
|
|
237
255
|
resourceUnavailable,
|
|
238
256
|
resourceForbidden,
|
|
239
257
|
pinned = [],
|
|
@@ -244,6 +262,9 @@ export function ResourcesSidebar({
|
|
|
244
262
|
basePath,
|
|
245
263
|
onKindNavigated,
|
|
246
264
|
categoryWorkspaces,
|
|
265
|
+
displayState,
|
|
266
|
+
onDisplayStateChange,
|
|
267
|
+
containEscape,
|
|
247
268
|
}: ResourcesSidebarProps) {
|
|
248
269
|
// Wraps kind selection to also navigate when basePath/onNavigate are provided
|
|
249
270
|
const selectKind = (kind: SelectedKindInfo) => {
|
|
@@ -261,8 +282,13 @@ export function ResourcesSidebar({
|
|
|
261
282
|
() => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
|
|
262
283
|
)
|
|
263
284
|
useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
|
|
264
|
-
const [
|
|
265
|
-
const
|
|
285
|
+
const [ownDisplayState, setOwnDisplayState] = useState<SidebarDisplayState>(() => ({ showEmptyKinds: false, favoritesExpanded: pinned.length > 0 }))
|
|
286
|
+
const { showEmptyKinds, favoritesExpanded } = displayState ?? ownDisplayState
|
|
287
|
+
const setDisplayState = (patch: Partial<SidebarDisplayState>) => {
|
|
288
|
+
const next = { showEmptyKinds, favoritesExpanded, ...patch }
|
|
289
|
+
if (onDisplayStateChange) onDisplayStateChange(next)
|
|
290
|
+
else setOwnDisplayState(next)
|
|
291
|
+
}
|
|
266
292
|
const favoritesDisclosure = useDisclosure(favoritesExpanded)
|
|
267
293
|
// Category sections are mapped inline, so they can't each call useDisclosure;
|
|
268
294
|
// one generated prefix plus the category name keeps aria-controls unique.
|
|
@@ -401,20 +427,25 @@ export function ResourcesSidebar({
|
|
|
401
427
|
(sum, resource) => sum + (counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? 0),
|
|
402
428
|
0
|
|
403
429
|
)
|
|
430
|
+
const lowerBound = category.resources.some(resource =>
|
|
431
|
+
isLowerBound(resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]))
|
|
432
|
+
const totalLabel = boundedCountLabel(total, lowerBound)
|
|
404
433
|
|
|
405
|
-
// Filter resources: hide only
|
|
406
|
-
//
|
|
434
|
+
// Filter resources: hide only empty kinds. An unknown count stays visible
|
|
435
|
+
// as a dash, and a partial zero as one too, so count coverage gaps do not
|
|
436
|
+
// masquerade as emptiness.
|
|
407
437
|
const visibleResources = category.resources.filter(resource => {
|
|
408
438
|
const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
|
|
409
439
|
const isSelectedResource =
|
|
410
440
|
(effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
|
|
411
441
|
(effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
|
|
412
|
-
const
|
|
442
|
+
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
443
|
+
const shouldShow = isSelectedResource || count === null || !isEmptyCount(count ?? 0, resourceCoverage?.[key]) || showEmptyKinds
|
|
413
444
|
if (!shouldShow) totalHiddenKinds++
|
|
414
445
|
return shouldShow
|
|
415
446
|
})
|
|
416
447
|
|
|
417
|
-
return { ...category, total, visibleResources }
|
|
448
|
+
return { ...category, total, totalLabel, visibleResources }
|
|
418
449
|
})
|
|
419
450
|
|
|
420
451
|
// Sort: the category whose workspace is open first, then categories with
|
|
@@ -435,7 +466,7 @@ export function ResourcesSidebar({
|
|
|
435
466
|
})
|
|
436
467
|
|
|
437
468
|
return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
|
|
438
|
-
}, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
|
|
469
|
+
}, [categories, counts, resourceCoverage, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
|
|
439
470
|
|
|
440
471
|
// Filter sidebar categories/kinds by the kind search term
|
|
441
472
|
const filteredCategories = useMemo(() => {
|
|
@@ -518,6 +549,7 @@ export function ResourcesSidebar({
|
|
|
518
549
|
|
|
519
550
|
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
520
551
|
if (e.key === 'Escape') {
|
|
552
|
+
if (containEscape && kindFilter) e.stopPropagation()
|
|
521
553
|
setKindFilter('')
|
|
522
554
|
setHighlightedIndex(-1)
|
|
523
555
|
;(e.target as HTMLInputElement).blur()
|
|
@@ -535,7 +567,7 @@ export function ResourcesSidebar({
|
|
|
535
567
|
setKindFilter('')
|
|
536
568
|
onKindNavigated?.()
|
|
537
569
|
}
|
|
538
|
-
}, [visibleResults.length, highlightedResult, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
570
|
+
}, [visibleResults.length, highlightedResult, selectKind, onKindNavigated, containEscape, kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
539
571
|
|
|
540
572
|
const isKindHighlighted = useCallback((name: string, group: string) => {
|
|
541
573
|
return highlightedKind?.name === name && highlightedKind?.group === group
|
|
@@ -592,7 +624,7 @@ export function ResourcesSidebar({
|
|
|
592
624
|
<div className="mb-2">
|
|
593
625
|
<button
|
|
594
626
|
{...favoritesDisclosure.buttonProps}
|
|
595
|
-
onClick={() =>
|
|
627
|
+
onClick={() => setDisplayState({ favoritesExpanded: !favoritesExpanded })}
|
|
596
628
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
|
|
597
629
|
>
|
|
598
630
|
<CollapseChevron open={favoritesExpanded} className="w-3 h-3" />
|
|
@@ -621,6 +653,7 @@ export function ResourcesSidebar({
|
|
|
621
653
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
622
654
|
resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
|
|
623
655
|
count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
|
|
656
|
+
coverage={resourceCoverage?.[p.group ? `${p.group}/${p.kind}` : p.kind]}
|
|
624
657
|
isSelected={isResourceSelected}
|
|
625
658
|
isHighlighted={highlighted}
|
|
626
659
|
isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
|
|
@@ -653,8 +686,8 @@ export function ResourcesSidebar({
|
|
|
653
686
|
<CollapseChevron open={isExpanded} className="w-3 h-3" />
|
|
654
687
|
<span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
|
|
655
688
|
{!isExpanded && (
|
|
656
|
-
<span className=
|
|
657
|
-
{category.
|
|
689
|
+
<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">
|
|
690
|
+
{category.totalLabel}
|
|
658
691
|
</span>
|
|
659
692
|
)}
|
|
660
693
|
</button>
|
|
@@ -692,6 +725,7 @@ export function ResourcesSidebar({
|
|
|
692
725
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
693
726
|
resource={resource}
|
|
694
727
|
count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
|
|
728
|
+
coverage={resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]}
|
|
695
729
|
isSelected={showSelected}
|
|
696
730
|
isHighlighted={highlighted}
|
|
697
731
|
isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
|
|
@@ -748,12 +782,14 @@ export function ResourcesSidebar({
|
|
|
748
782
|
</span>
|
|
749
783
|
</Tooltip>
|
|
750
784
|
) : (
|
|
751
|
-
<
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
785
|
+
<Tooltip disabled={!resourceCountHint(resourceCoverage?.[kindKey])} content={resourceCountHint(resourceCoverage?.[kindKey])} position="left">
|
|
786
|
+
<span className={clsx(
|
|
787
|
+
'badge font-mono',
|
|
788
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
789
|
+
)}>
|
|
790
|
+
{resourceCountLabel(count, resourceCoverage?.[kindKey])}
|
|
791
|
+
</span>
|
|
792
|
+
</Tooltip>
|
|
757
793
|
)}
|
|
758
794
|
</button>
|
|
759
795
|
)
|
|
@@ -763,7 +799,7 @@ export function ResourcesSidebar({
|
|
|
763
799
|
{/* Toggle for showing/hiding empty kinds and groups */}
|
|
764
800
|
{hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
|
|
765
801
|
<button
|
|
766
|
-
onClick={() =>
|
|
802
|
+
onClick={() => setDisplayState({ showEmptyKinds: !showEmptyKinds })}
|
|
767
803
|
className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
|
|
768
804
|
>
|
|
769
805
|
{showEmptyKinds ? (
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { VirtuosoMockContext } from 'react-virtuoso'
|
|
5
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ResourcesView } from './ResourcesView'
|
|
7
|
+
import { getCellFilterValue, serializeColumnFilters } from './resource-utils'
|
|
8
|
+
import { getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
9
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
10
|
+
import type { APIResource } from '../../types'
|
|
11
|
+
import type { PrinterTable } from './printer-columns'
|
|
12
|
+
|
|
13
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
14
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
15
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
16
|
+
let root: ReturnType<typeof createRoot>
|
|
17
|
+
let host: HTMLDivElement
|
|
18
|
+
|
|
19
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
|
|
20
|
+
|
|
21
|
+
const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused' }
|
|
22
|
+
const mr = { providerConfigRef: { name: 'default' } }
|
|
23
|
+
const xr = { resourceRefs: [] }
|
|
24
|
+
const claim = { resourceRef: { kind: 'Archive', name: 'xr' }, compositionRef: { name: 'archive' } }
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
['managed', 'test.crossplane.io', mr, false],
|
|
28
|
+
['foreign-group managed', 'example.com', mr, false],
|
|
29
|
+
['composite default', 'example.com', xr, false],
|
|
30
|
+
['composite printer', 'example.com', xr, true],
|
|
31
|
+
['claim printer', 'example.com', claim, true],
|
|
32
|
+
['ref-less early composite printer', 'example.com', {}, true],
|
|
33
|
+
['ref-less early claim default', 'example.com', {}, false],
|
|
34
|
+
] as const)('keeps pause context and primary evidence on the real %s table path', async (_name, group, spec, printer) => {
|
|
35
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
36
|
+
const kind: APIResource = { group, version: 'v1', kind: 'Archive', name: 'archives', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
37
|
+
const resource = {
|
|
38
|
+
apiVersion: `${group}/v1`, kind: 'Archive',
|
|
39
|
+
metadata: { name: 'acknowledged', namespace: 'test', uid: 'fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
40
|
+
spec, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] },
|
|
41
|
+
}
|
|
42
|
+
const printerTable: PrinterTable | undefined = printer ? {
|
|
43
|
+
kind: kind.name, group,
|
|
44
|
+
columns: [
|
|
45
|
+
{ name: 'Synced', jsonPath: ".status.conditions[?(@.type=='Synced')].status", type: 'string' },
|
|
46
|
+
{ name: 'Ready', jsonPath: ".status.conditions[?(@.type=='Ready')].status", type: 'string' },
|
|
47
|
+
],
|
|
48
|
+
cells: { fixture: ['False', 'False'] },
|
|
49
|
+
} : undefined
|
|
50
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
51
|
+
namespaces={['test']}
|
|
52
|
+
apiResources={[kind]}
|
|
53
|
+
resourceCounts={{ Archive: 1 }}
|
|
54
|
+
selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
|
|
55
|
+
locationPathname="/resources/archives"
|
|
56
|
+
locationSearch={`?apiGroup=${group}`}
|
|
57
|
+
onNavigate={() => {}}
|
|
58
|
+
printerTable={printerTable}
|
|
59
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
60
|
+
const row = host.querySelector('tbody tr')!
|
|
61
|
+
expect(row.textContent).toContain('acknowledged')
|
|
62
|
+
expect(row.textContent?.match(/Paused/g)).toHaveLength(1)
|
|
63
|
+
if (printer) {
|
|
64
|
+
const printed = [...row.querySelectorAll('td')].filter(cell => cell.textContent?.trim() === 'False')
|
|
65
|
+
expect(printed).toHaveLength(2)
|
|
66
|
+
expect(printed[0].querySelector('.status-neutral')).not.toBeNull()
|
|
67
|
+
expect(printed[1].querySelector('.status-unhealthy')).not.toBeNull()
|
|
68
|
+
expect(row.textContent).not.toContain('Unavailable')
|
|
69
|
+
} else {
|
|
70
|
+
expect(row.textContent).toContain('Unavailable')
|
|
71
|
+
expect(row.textContent).not.toContain('Out of sync')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
it('preserves curated package install failure ahead of a recorded pause in the table and filter', async () => {
|
|
77
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
78
|
+
const group = 'pkg.crossplane.io'
|
|
79
|
+
const kind: APIResource = { group, version: 'v1', kind: 'Provider', name: 'providers', namespaced: false, isCrd: true, verbs: ['get', 'list'] }
|
|
80
|
+
const resource = {
|
|
81
|
+
apiVersion: `${group}/v1`, kind: 'Provider',
|
|
82
|
+
metadata: { name: 'install-failure', uid: 'package-fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
83
|
+
spec: { package: 'example.invalid/provider:v1' },
|
|
84
|
+
status: { conditions: [pause, { type: 'Installed', status: 'False', reason: 'UnpackFailed' }, { type: 'Healthy', status: 'True' }] },
|
|
85
|
+
}
|
|
86
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
87
|
+
namespaces={[]}
|
|
88
|
+
apiResources={[kind]}
|
|
89
|
+
resourceCounts={{ Provider: 1 }}
|
|
90
|
+
selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
|
|
91
|
+
locationPathname="/resources/providers"
|
|
92
|
+
locationSearch={`?apiGroup=${group}`}
|
|
93
|
+
onNavigate={() => {}}
|
|
94
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
95
|
+
const row = host.querySelector('tbody tr')!
|
|
96
|
+
expect(row.textContent).toContain('UnpackFailed')
|
|
97
|
+
expect(row.querySelector('.status-unhealthy')).not.toBeNull()
|
|
98
|
+
expect(row.textContent).not.toContain('Readiness unknown')
|
|
99
|
+
expect(getCellFilterValue(resource, 'status', 'providers')).toBe('UnpackFailed')
|
|
100
|
+
expect(getCellFilterValue(resource, 'status', 'providers')).toBe(getResourceStatus('providers', resource)?.text)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
['ref-less generic', 'example.com', 'Archive', 'archives', {}, 'Ready (reported)', 'Ready (reported)'],
|
|
106
|
+
['managed-name collision', 'acm.aws.upbound.io', 'Certificate', 'certificates', mr, 'Ready (reported)', 'Ready (reported)'],
|
|
107
|
+
['curated Knative', 'serving.knative.dev', 'Revision', 'revisions', {}, 'Ready', 'Scaled to Zero'],
|
|
108
|
+
] as const)('routes the actual %s status filter through the existing column policy', async (_name, group, kindName, plural, spec, filterText, cellText) => {
|
|
109
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
110
|
+
const kind: APIResource = { group, version: 'v1', kind: kindName, name: plural, namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
111
|
+
const resource = {
|
|
112
|
+
apiVersion: `${group}/v1`, kind: kindName,
|
|
113
|
+
metadata: { name: 'kept', namespace: 'test', uid: 'kept-fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
114
|
+
spec, status: { conditions: [pause, { type: 'Ready', status: 'True' }, { type: 'Active', status: 'False', reason: 'NoTraffic' }] },
|
|
115
|
+
}
|
|
116
|
+
const excluded = { ...resource, metadata: { ...resource.metadata, name: 'excluded', uid: 'excluded-fixture' }, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
|
|
117
|
+
const search = `?${new URLSearchParams({ apiGroup: group, filters: serializeColumnFilters({ status: [filterText] }) })}`
|
|
118
|
+
window.history.replaceState(null, '', `/resources/${plural}${search}`)
|
|
119
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
120
|
+
namespaces={['test']}
|
|
121
|
+
apiResources={[kind]}
|
|
122
|
+
resourceCounts={{ [kindName]: 2 }}
|
|
123
|
+
selectedKindQuery={{ isLoading: false, data: [resource, excluded], resourceName: plural, group }}
|
|
124
|
+
locationPathname={`/resources/${plural}`}
|
|
125
|
+
locationSearch={search}
|
|
126
|
+
onNavigate={() => {}}
|
|
127
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
128
|
+
const rows = host.querySelectorAll('tbody tr')
|
|
129
|
+
expect(rows).toHaveLength(1)
|
|
130
|
+
expect(rows[0].textContent).toContain('kept')
|
|
131
|
+
expect(rows[0].textContent).toContain(cellText)
|
|
132
|
+
expect(rows[0].textContent).not.toContain('excluded')
|
|
133
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { VirtuosoMockContext } from 'react-virtuoso'
|
|
5
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ResourcesView } from './ResourcesView'
|
|
7
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
8
|
+
import type { APIResource } from '../../types'
|
|
9
|
+
|
|
10
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
12
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
13
|
+
let root: ReturnType<typeof createRoot>
|
|
14
|
+
let host: HTMLDivElement
|
|
15
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
|
|
16
|
+
|
|
17
|
+
it('shows failed rollout and suspension together in the actual Canary table', async () => {
|
|
18
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
19
|
+
const kind: APIResource = { group: 'flagger.app', version: 'v1beta1', kind: 'Canary', name: 'canaries', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
20
|
+
const data = { apiVersion: 'flagger.app/v1beta1', kind: 'Canary', metadata: { name: 'storefront', namespace: 'test' }, spec: { suspend: true, targetRef: { kind: 'Deployment', name: 'storefront' }, analysis: { iterations: 2 } }, status: { phase: 'Failed' } }
|
|
21
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ Canary: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/canaries" locationSearch="?apiGroup=flagger.app" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
22
|
+
const table = host.querySelector('table')!
|
|
23
|
+
expect(table).not.toBeNull()
|
|
24
|
+
expect(table.textContent).toContain('Rollout State')
|
|
25
|
+
expect(table.textContent).toContain('Failed')
|
|
26
|
+
expect(table.querySelector('.status-unhealthy')).not.toBeNull()
|
|
27
|
+
expect(table.textContent).toContain('Blue/green')
|
|
28
|
+
expect(table.textContent).toContain('Not weighted')
|
|
29
|
+
expect(table.textContent).toContain('Suspend Requested')
|
|
30
|
+
expect(table.textContent).toContain('Yes')
|
|
31
|
+
})
|
|
@@ -3,6 +3,7 @@ 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'
|
|
6
|
+
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
6
7
|
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
7
8
|
import type { APIResource } from '../../types'
|
|
8
9
|
|
|
@@ -252,7 +253,7 @@ it('can show selection from compare without turning off the saved preference', (
|
|
|
252
253
|
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
253
254
|
})
|
|
254
255
|
|
|
255
|
-
it('
|
|
256
|
+
it('confirms bulk deletion and clears successful selection without hiding checkboxes', () => {
|
|
256
257
|
const { remove } = renderBulkTable()
|
|
257
258
|
key('b')
|
|
258
259
|
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
@@ -268,6 +269,32 @@ it('keeps delete confirmation and clears successful selection without hiding che
|
|
|
268
269
|
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
269
270
|
})
|
|
270
271
|
|
|
272
|
+
it('requires a new bulk confirmation after submission without clearing uncertain selection', async () => {
|
|
273
|
+
const { remove, render } = renderBulkTable()
|
|
274
|
+
const other = { ...pod, metadata: { ...pod.metadata, name: 'remaining', uid: 'remaining-id' } }
|
|
275
|
+
render(pods, [pod, other])
|
|
276
|
+
key('b')
|
|
277
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
278
|
+
clickButton('Delete')
|
|
279
|
+
const dialog = required<HTMLElement>('[role="dialog"]', document)
|
|
280
|
+
clickButton('Delete 2 resources', dialog)
|
|
281
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
282
|
+
expect(remove.mock.calls[0]?.[0]).toHaveLength(2)
|
|
283
|
+
expect(dialog.closest('[inert]')).not.toBeNull()
|
|
284
|
+
expect(host.textContent).toContain('2 selected')
|
|
285
|
+
|
|
286
|
+
render(pods, [other])
|
|
287
|
+
await act(async () => { await new Promise(resolve => setTimeout(resolve, 300)) })
|
|
288
|
+
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
|
289
|
+
expect(host.textContent).toContain('1 selected')
|
|
290
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
291
|
+
clickButton('Delete')
|
|
292
|
+
const nextDialog = required<HTMLElement>('[role="dialog"]', document)
|
|
293
|
+
expect(nextDialog.textContent).toContain('Delete 1 Pod?')
|
|
294
|
+
clickButton('Delete 1 resource', nextDialog)
|
|
295
|
+
expect(remove.mock.calls[1]?.[0]).toEqual([{ kind: 'pods', group: '', namespace: 'test', name: 'remaining' }])
|
|
296
|
+
})
|
|
297
|
+
|
|
271
298
|
it.each(['false', 'garbage', 'null', '1'])('ignores invalid or disabled visibility value %s', value => {
|
|
272
299
|
localStorage.setItem(checkboxKey, value)
|
|
273
300
|
renderBulkTable()
|
|
@@ -294,3 +321,71 @@ it('does not offer selection without a bulk action callback even with saved visi
|
|
|
294
321
|
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
295
322
|
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
296
323
|
})
|
|
324
|
+
|
|
325
|
+
it('closes single-delete confirmation on submission but keeps the inspected resource until known success', () => {
|
|
326
|
+
const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
|
|
327
|
+
const close = vi.fn()
|
|
328
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
329
|
+
act(() => root.render(<ResourceActionsBar
|
|
330
|
+
resource={{ kind: 'pods', namespace: 'test', name: 'example' }}
|
|
331
|
+
data={pod}
|
|
332
|
+
onDelete={remove}
|
|
333
|
+
onClose={close}
|
|
334
|
+
/>))
|
|
335
|
+
act(() => required<SVGElement>('svg[class*="trash"]').closest<HTMLButtonElement>('button')!.click())
|
|
336
|
+
const dialog = required<HTMLElement>('[role="dialog"]', document)
|
|
337
|
+
clickButton('Delete', dialog)
|
|
338
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
339
|
+
expect(dialog.closest('[inert]')).not.toBeNull()
|
|
340
|
+
expect(close).not.toHaveBeenCalled()
|
|
341
|
+
act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
|
|
342
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('preserves rows selected after a bulk delete was submitted', () => {
|
|
346
|
+
const { remove, render } = renderBulkTable()
|
|
347
|
+
key('b')
|
|
348
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
349
|
+
clickButton('Delete')
|
|
350
|
+
clickButton('Delete 1 resource', required<HTMLElement>('[role="dialog"]', document))
|
|
351
|
+
const other = { ...pod, metadata: { ...pod.metadata, name: 'remaining', uid: 'remaining-id' } }
|
|
352
|
+
render(pods, [pod, other])
|
|
353
|
+
act(() => required<HTMLInputElement>('input[title="Deselect all"]').click())
|
|
354
|
+
expect(host.textContent).toContain('2 selected')
|
|
355
|
+
act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
|
|
356
|
+
expect(host.textContent).toContain('1 selected')
|
|
357
|
+
render(pods, [other])
|
|
358
|
+
expect(host.textContent).toContain('1 selected')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it.each(['name', 'uid'])('does not close a newly inspected resource after an earlier delete succeeds (%s)', change => {
|
|
362
|
+
const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
|
|
363
|
+
const close = vi.fn()
|
|
364
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
365
|
+
const render = (name: string, uid: string, deleting = false) => act(() => root.render(<ResourceActionsBar
|
|
366
|
+
resource={{ kind: 'pods', namespace: 'test', name }}
|
|
367
|
+
data={{ ...pod, metadata: { ...pod.metadata, name, uid } }}
|
|
368
|
+
onDelete={remove}
|
|
369
|
+
onClose={close}
|
|
370
|
+
isDeleting={deleting}
|
|
371
|
+
/>))
|
|
372
|
+
render('example', 'original')
|
|
373
|
+
act(() => required<HTMLButtonElement>('button[aria-label="Delete resource"]').click())
|
|
374
|
+
clickButton('Delete', required<HTMLElement>('[role="dialog"]', document))
|
|
375
|
+
render(change === 'name' ? 'other' : 'example', 'replacement', true)
|
|
376
|
+
expect(required<HTMLButtonElement>('button[aria-label="Deleting resource"]').disabled).toBe(true)
|
|
377
|
+
act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
|
|
378
|
+
expect(close).not.toHaveBeenCalled()
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('ignores a delete success callback after its action bar is unmounted', () => {
|
|
382
|
+
const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
|
|
383
|
+
const close = vi.fn()
|
|
384
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
385
|
+
act(() => root.render(<ResourceActionsBar resource={{ kind: 'pods', namespace: 'test', name: 'example' }} data={pod} onDelete={remove} onClose={close} />))
|
|
386
|
+
act(() => required<HTMLButtonElement>('button[aria-label="Delete resource"]').click())
|
|
387
|
+
clickButton('Delete', required<HTMLElement>('[role="dialog"]', document))
|
|
388
|
+
act(() => root.render(<div>Another view</div>))
|
|
389
|
+
act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
|
|
390
|
+
expect(close).not.toHaveBeenCalled()
|
|
391
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { VirtuosoMockContext } from 'react-virtuoso'
|
|
5
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ResourcesView } from './ResourcesView'
|
|
7
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
8
|
+
import type { APIResource } from '../../types'
|
|
9
|
+
|
|
10
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
12
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
13
|
+
let root: ReturnType<typeof createRoot>
|
|
14
|
+
let host: HTMLDivElement
|
|
15
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
|
|
16
|
+
|
|
17
|
+
it('shows reconciliation and labeled Pod snapshots in the actual collector table', async () => {
|
|
18
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
19
|
+
const kind: APIResource = { group: 'opentelemetry.io', version: 'v1beta1', kind: 'OpenTelemetryCollector', name: 'opentelemetrycollectors', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
20
|
+
const data = { apiVersion: 'opentelemetry.io/v1beta1', kind: 'OpenTelemetryCollector', metadata: { name: 'collector', namespace: 'test' }, spec: { mode: 'deployment', managementState: 'managed' }, status: { conditions: [{ type: 'Ready', status: 'False', reason: 'ReconcileError' }], scale: { statusReplicas: '0/1' }, version: '0.162.0' } }
|
|
21
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ OpenTelemetryCollector: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/opentelemetrycollectors" locationSearch="?apiGroup=opentelemetry.io" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
22
|
+
const table = host.querySelector('table')!
|
|
23
|
+
expect(table).not.toBeNull()
|
|
24
|
+
expect(table.textContent).toContain('Reconciliation')
|
|
25
|
+
expect(table.textContent).toContain('Reconcile failed')
|
|
26
|
+
expect(table.textContent).toContain('Reported Pods')
|
|
27
|
+
expect(table.textContent).toContain('0/1')
|
|
28
|
+
expect(table.textContent).toContain('0.162.0')
|
|
29
|
+
})
|
|
@@ -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
|
+
})
|