@skyhook-io/k8s-ui 1.14.5 → 1.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditCard.tsx +18 -7
  6. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  8. package/src/components/charts/AreaChart.test.tsx +13 -0
  9. package/src/components/charts/AreaChart.tsx +28 -25
  10. package/src/components/charts/PrometheusChartsView.tsx +41 -10
  11. package/src/components/charts/axis.test.ts +16 -0
  12. package/src/components/charts/axis.ts +3 -1
  13. package/src/components/charts/format.ts +4 -0
  14. package/src/components/charts/nearestSample.test.ts +31 -0
  15. package/src/components/charts/nearestSample.ts +15 -0
  16. package/src/components/checks/ChecksView.tsx +76 -28
  17. package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
  18. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  19. package/src/components/gitops/health-provenance.test.ts +54 -0
  20. package/src/components/gitops/health-provenance.ts +24 -0
  21. package/src/components/gitops/index.ts +2 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  24. package/src/components/gitops/tree/merge.test.ts +34 -0
  25. package/src/components/gitops/tree/merge.ts +7 -0
  26. package/src/components/issues/IssuesView.tsx +35 -54
  27. package/src/components/logs/LogCore.tsx +40 -9
  28. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  29. package/src/components/resources/ResourcesView.tsx +67 -2
  30. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  31. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  32. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  33. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  36. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  37. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  38. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  39. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  40. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  41. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  42. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  43. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  46. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  47. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  48. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  49. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  50. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  51. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  52. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  53. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  54. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  55. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  56. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  57. package/src/components/resources/renderers/index.ts +3 -0
  58. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  59. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
  60. package/src/components/resources/resource-utils.ts +18 -9
  61. package/src/components/shared/CreateResourceDialog.tsx +237 -14
  62. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  63. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  64. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  65. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  66. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  67. package/src/components/shared/index.ts +4 -0
  68. package/src/components/timeline/TimelineList.tsx +14 -11
  69. package/src/components/timeline/TimelineStrip.tsx +26 -4
  70. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  71. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  72. package/src/components/topology/TopologyControls.tsx +27 -8
  73. package/src/components/topology/TopologyGraph.tsx +14 -5
  74. package/src/components/trace/ReachabilityView.tsx +17 -16
  75. package/src/components/ui/CardSection.tsx +79 -35
  76. package/src/components/ui/Collapse.test.tsx +74 -0
  77. package/src/components/ui/Collapse.tsx +115 -29
  78. package/src/components/ui/DialogPortal.tsx +7 -2
  79. package/src/components/ui/FetchResult.test.tsx +20 -2
  80. package/src/components/ui/FetchResult.tsx +33 -3
  81. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  82. package/src/components/ui/FreshnessControl.tsx +8 -2
  83. package/src/components/ui/RestrictedState.tsx +7 -4
  84. package/src/components/ui/RowActionMenu.tsx +20 -8
  85. package/src/components/ui/SelectMenu.tsx +18 -6
  86. package/src/components/ui/YamlEditor.tsx +7 -9
  87. package/src/components/ui/drawer-components.tsx +14 -21
  88. package/src/components/ui/index.ts +30 -22
  89. package/src/components/workload/WorkloadView.tsx +8 -12
  90. package/src/hooks/useAnimatedUnmount.ts +21 -3
  91. package/src/theme/components.css +20 -1
  92. package/src/types/core.ts +15 -1
  93. package/src/types/gitops-insights.ts +23 -0
  94. package/src/types/gitops-tree.ts +29 -0
  95. package/src/utils/animation.ts +93 -13
  96. package/src/utils/index.ts +1 -0
  97. package/src/utils/navigation.test.ts +20 -1
  98. package/src/utils/navigation.ts +25 -0
  99. package/src/utils/network-policy.test.ts +42 -0
  100. package/src/utils/network-policy.ts +59 -0
  101. package/src/utils/reflector.ts +139 -0
  102. package/src/utils/yaml-file-import.test.ts +189 -0
  103. package/src/utils/yaml-file-import.ts +95 -0
  104. package/src/components/logs/JsonLogLine.tsx +0 -93
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.5",
3
+ "version": "1.14.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -89,8 +89,8 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@types/node": "^26.2.0",
92
- "@types/react": "^19.2.18",
93
- "@types/react-dom": "^19.2.3",
92
+ "@types/react": "^19.3.0",
93
+ "@types/react-dom": "^19.3.0",
94
94
  "@xterm/addon-fit": "^0.11.0",
95
95
  "@xterm/addon-web-links": "^0.12.0",
96
96
  "@xterm/xterm": "^6.0.0",
@@ -100,8 +100,8 @@
100
100
  "elkjs": "^0.11.1",
101
101
  "lucide-react": "^1.37.0",
102
102
  "monaco-editor": "0.55.1",
103
- "react": "^19.2.8",
104
- "react-dom": "^19.2.8",
103
+ "react": "^19.3.0",
104
+ "react-dom": "^19.3.0",
105
105
  "typescript": "^6.0.2",
106
106
  "vitest": "^4.1.11",
107
107
  "yaml": "^2.9.0"
@@ -27,6 +27,12 @@ import argoCdLogo from "../../assets/gitops/argocd.png";
27
27
  import fluxLogo from "../../assets/gitops/flux.svg";
28
28
  import { StatusDot, mapHealthToTone } from "../ui/status-tone";
29
29
  import { Tooltip } from "../ui/Tooltip";
30
+ import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
31
+ import {
32
+ TRANSITION_MENU,
33
+ overlayExitMs,
34
+ overlayTransitionStyle,
35
+ } from "../../utils/animation";
30
36
  import { EmptyState } from "../ui/EmptyState";
31
37
  import { ResourceRefBadge } from "../ui/drawer-components";
32
38
  import { TopologyGraph } from "../topology/TopologyGraph";
@@ -2884,6 +2890,10 @@ function ApplicationScopeSelector({
2884
2890
  onFocus: (owner: WorkloadFocus) => void;
2885
2891
  }) {
2886
2892
  const [open, setOpen] = useState(false);
2893
+ const { shouldRender, isOpen } = useAnimatedUnmount(
2894
+ open,
2895
+ overlayExitMs("menu"),
2896
+ );
2887
2897
  const [query, setQuery] = useState("");
2888
2898
  const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
2889
2899
  const selectedKey = selectedWorkload ? workloadKey(selectedWorkload) : null;
@@ -2944,10 +2954,19 @@ function ApplicationScopeSelector({
2944
2954
  aria-hidden
2945
2955
  />
2946
2956
  </button>
2947
- {open && (
2957
+ {shouldRender && (
2948
2958
  <div
2949
2959
  role="listbox"
2950
- className="absolute left-0 top-full z-50 mt-1 w-[min(32rem,calc(100vw-2rem))] overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-md"
2960
+ inert={!open}
2961
+ className={clsx(
2962
+ "absolute left-0 top-full z-50 mt-1 w-[min(32rem,calc(100vw-2rem))] origin-top-left overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-md",
2963
+ TRANSITION_MENU,
2964
+ isOpen
2965
+ ? "opacity-100 translate-y-0 scale-100"
2966
+ : "opacity-0 -translate-y-1 scale-[0.97]",
2967
+ !open && "pointer-events-none",
2968
+ )}
2969
+ style={overlayTransitionStyle(isOpen, "menu")}
2951
2970
  onMouseLeave={() => onFocus(null)}
2952
2971
  >
2953
2972
  <div className="border-b border-theme-border p-1">
@@ -3371,6 +3390,10 @@ function EnvSwitcher({
3371
3390
  onSwitch?: (appKey: string) => void;
3372
3391
  }) {
3373
3392
  const [open, setOpen] = useState(false);
3393
+ const { shouldRender, isOpen } = useAnimatedUnmount(
3394
+ open,
3395
+ overlayExitMs("menu"),
3396
+ );
3374
3397
  const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
3375
3398
 
3376
3399
  const envInstances = useMemo(
@@ -3475,10 +3498,19 @@ function EnvSwitcher({
3475
3498
  </button>
3476
3499
  {evidenceChip}
3477
3500
  {lagChip}
3478
- {open && (
3501
+ {shouldRender && (
3479
3502
  <div
3480
3503
  role="listbox"
3481
- className="absolute left-0 top-full z-50 mt-1 max-h-80 w-80 overflow-y-auto rounded-md border border-theme-border bg-theme-surface p-1 shadow-theme-md"
3504
+ inert={!open}
3505
+ className={clsx(
3506
+ "absolute left-0 top-full z-50 mt-1 max-h-80 w-80 origin-top-left overflow-y-auto rounded-md border border-theme-border bg-theme-surface p-1 shadow-theme-md",
3507
+ TRANSITION_MENU,
3508
+ isOpen
3509
+ ? "opacity-100 translate-y-0 scale-100"
3510
+ : "opacity-0 -translate-y-1 scale-[0.97]",
3511
+ !open && "pointer-events-none",
3512
+ )}
3513
+ style={overlayTransitionStyle(isOpen, "menu")}
3482
3514
  >
3483
3515
  {instances.map((inst) => {
3484
3516
  const isActive = inst.appKey === activeKey;
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
2
2
  import type { ReactNode } from 'react'
3
3
  import { ChevronRight, Layers, Boxes, HeartPulse, Shapes, Globe, Tag } from 'lucide-react'
4
+ import { CollapseChevron } from '../ui/Collapse'
4
5
  import { clsx } from 'clsx'
5
6
  import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
7
  import { Tooltip } from '../ui/Tooltip'
@@ -497,7 +498,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
497
498
  <Tooltip content={applicationRuntimeLabel(r.members.map((member) => member.row), r.workloadClass, r.health)} delay={150}>
498
499
  <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
499
500
  </Tooltip>
500
- <ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
501
+ <CollapseChevron open={r.expanded} className="h-3.5 w-3.5" />
501
502
  <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
502
503
  {r.workloadClass !== 'job' && <BatchSignalChip signal={firstBatchSignal(r.members.map((m) => m.row))} />}
503
504
  <Tooltip
@@ -1,8 +1,9 @@
1
1
  import { useState } from 'react'
2
- import { ClipboardCheck, ChevronRight, ShieldAlert, AlertTriangle, ArrowRight } from 'lucide-react'
2
+ import { ClipboardCheck, ShieldAlert, AlertTriangle, ArrowRight } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
5
5
  import { SEVERITY_TEXT_CLASS } from '../checks/severity'
6
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
6
7
 
7
8
  export interface AuditFinding {
8
9
  kind: string
@@ -36,6 +37,7 @@ interface AuditAlertsProps {
36
37
  */
37
38
  export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
38
39
  const [expanded, setExpanded] = useState(false)
40
+ const { panelId, buttonProps } = useDisclosure(expanded)
39
41
 
40
42
  if (findings.length === 0) return null
41
43
 
@@ -45,11 +47,12 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
45
47
  return (
46
48
  <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
47
49
  <button
50
+ {...buttonProps}
48
51
  type="button"
49
52
  onClick={() => setExpanded(!expanded)}
50
53
  className="flex w-full items-center gap-2 rounded-md text-left transition-colors hover:text-theme-text-primary"
51
54
  >
52
- <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
55
+ <CollapseChevron open={expanded} className="w-4 h-4" />
53
56
  <ClipboardCheck className="w-4 h-4 text-theme-text-secondary" />
54
57
  <span className="text-sm font-semibold text-theme-text-primary">Audit Findings</span>
55
58
  <div className="flex items-center gap-2 ml-1">
@@ -62,43 +65,38 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
62
65
  </div>
63
66
  </button>
64
67
 
65
- <div
66
- className="grid transition-[grid-template-rows] duration-200 ease-out"
67
- style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
68
- >
69
- <div className="overflow-hidden">
70
- <div className="flex flex-col gap-0.5 pt-3">
71
- {findings.map((f, i) => {
72
- const isHigh = f.severity === 'danger'
73
- return (
74
- <div key={`${f.checkID}-${i}`} className="flex items-start gap-2 py-1">
75
- {isHigh ? (
76
- <ShieldAlert className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.high)} />
77
- ) : (
78
- <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.medium)} />
79
- )}
80
- <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
81
- <span className="text-xs text-theme-text-secondary">{f.message}</span>
82
- <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
83
- {f.category}
84
- </span>
85
- </div>
68
+ <Collapse open={expanded} id={panelId}>
69
+ <div className="flex flex-col gap-0.5 pt-3">
70
+ {findings.map((f, i) => {
71
+ const isHigh = f.severity === 'danger'
72
+ return (
73
+ <div key={`${f.checkID}-${i}`} className="flex items-start gap-2 py-1">
74
+ {isHigh ? (
75
+ <ShieldAlert className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.high)} />
76
+ ) : (
77
+ <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.medium)} />
78
+ )}
79
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
80
+ <span className="text-xs text-theme-text-secondary">{f.message}</span>
81
+ <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
82
+ {f.category}
83
+ </span>
86
84
  </div>
87
- )
88
- })}
89
- {onViewAll && (
90
- <button
91
- type="button"
92
- onClick={(e) => { e.stopPropagation(); onViewAll() }}
93
- className="mt-1 flex items-center gap-1 py-1 text-xs text-accent-text transition-colors hover:text-accent"
94
- >
95
- View all findings
96
- <ArrowRight className="w-3 h-3" />
97
- </button>
98
- )}
99
- </div>
85
+ </div>
86
+ )
87
+ })}
88
+ {onViewAll && (
89
+ <button
90
+ type="button"
91
+ onClick={(e) => { e.stopPropagation(); onViewAll() }}
92
+ className="mt-1 flex items-center gap-1 py-1 text-xs text-accent-text transition-colors hover:text-accent"
93
+ >
94
+ View all findings
95
+ <ArrowRight className="w-3 h-3" />
96
+ </button>
97
+ )}
100
98
  </div>
101
- </div>
99
+ </Collapse>
102
100
  </section>
103
101
  )
104
102
  }
@@ -1,9 +1,10 @@
1
- import { ClipboardCheck, ArrowRight, Check } from 'lucide-react'
1
+ import { ClipboardCheck, ArrowRight, Check, AlertTriangle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { SEVERITY_TEXT, SEVERITY_DOT } from '../../utils/badge-colors'
4
4
  import { SEVERITY_FILL_CLASS, SEVERITY_TEXT_CLASS } from '../checks/severity'
5
5
 
6
6
  export interface AuditCardData {
7
+ missingInputs?: string[]
7
8
  passing: number
8
9
  /** Raw compatibility counts; warning renders as Medium and danger as High. */
9
10
  warning: number
@@ -17,16 +18,17 @@ interface AuditCardProps {
17
18
  onNavigate: () => void
18
19
  }
19
20
 
20
- type SeverityLevel = 'success' | 'high' | 'medium'
21
+ type SeverityLevel = 'success' | 'high' | 'medium' | 'neutral'
21
22
 
22
23
  function getSeverityLevel(data: AuditCardData): SeverityLevel {
23
- if (data.warning + data.danger === 0) return 'success'
24
+ if (data.warning + data.danger === 0) return data.missingInputs?.length || data.passing === 0 ? 'neutral' : 'success'
24
25
  const highRatio = data.danger / (data.warning + data.danger)
25
26
  if (highRatio > 0.2) return 'high'
26
27
  return 'medium'
27
28
  }
28
29
 
29
30
  const ACCENT_BG: Record<SeverityLevel, string> = {
31
+ neutral: 'bg-theme-elevated',
30
32
  success: 'bg-green-500/10',
31
33
  high: 'bg-orange-500/10',
32
34
  medium: 'bg-yellow-500/10',
@@ -35,9 +37,10 @@ const ACCENT_BG: Record<SeverityLevel, string> = {
35
37
  export function AuditCard({ data, onNavigate }: AuditCardProps) {
36
38
  const total = data.passing + data.warning + data.danger
37
39
  const issueCount = data.warning + data.danger
38
- const allPassing = issueCount === 0
40
+ const incomplete = (data.missingInputs?.length ?? 0) > 0
41
+ const allPassing = issueCount === 0 && total > 0 && !incomplete
39
42
  const level = getSeverityLevel(data)
40
- const accentColor = level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
43
+ const accentColor = level === 'neutral' ? 'text-theme-text-secondary' : level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
41
44
  const accentBg = ACCENT_BG[level]
42
45
 
43
46
  return (
@@ -56,7 +59,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
56
59
  </span>
57
60
  ) : (
58
61
  <span className={clsx('badge-sm', accentBg, accentColor)}>
59
- <Check className="w-3 h-3" />
62
+ {allPassing ? <Check className="w-3 h-3" /> : <ClipboardCheck className="w-3 h-3" />}
60
63
  </span>
61
64
  )}
62
65
  </div>
@@ -71,6 +74,12 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
71
74
  <span className="text-xs text-theme-text-tertiary">{total} checks across {Object.keys(data.categories).length} categories</span>
72
75
  )}
73
76
  </div>
77
+ ) : issueCount === 0 ? (
78
+ <div className="flex flex-col items-center gap-2 text-center">
79
+ {incomplete ? <AlertTriangle className="w-8 h-8 text-warning-text" /> : <ClipboardCheck className="w-8 h-8 text-theme-text-tertiary" />}
80
+ <span className="text-sm font-medium text-theme-text-primary">{incomplete ? 'Some checks could not run' : 'No resources evaluated'}</span>
81
+ <span className="text-xs text-theme-text-secondary">{incomplete ? 'No findings in the available data.' : 'Check the selected scope and check settings.'}</span>
82
+ </div>
74
83
  ) : (
75
84
  <>
76
85
  {/* Distribution bar — only show when we have passing data for context */}
@@ -107,7 +116,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
107
116
  )}
108
117
  </div>
109
118
  ) : (
110
- <span className={clsx('text-xs font-semibold', SEVERITY_TEXT.success)}>All passing</span>
119
+ <span className={clsx('text-xs font-semibold', incomplete ? 'text-theme-text-secondary' : SEVERITY_TEXT.success)}>{incomplete ? 'No findings' : 'All passing'}</span>
111
120
  )}
112
121
  </div>
113
122
  )
@@ -117,6 +126,8 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
117
126
  )}
118
127
  </div>
119
128
 
129
+ {incomplete && issueCount > 0 && <p className="px-4 pb-2 text-xs text-warning-text">Some checks could not run.</p>}
130
+
120
131
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end">
121
132
  <span className={clsx(
122
133
  'flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors',
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { AuditCard } from './AuditCard'
4
+ import { ChecksView } from '../checks/ChecksView'
5
+ import { FilterLocationProvider } from '../../filter-state'
6
+
7
+ describe('audit coverage presentation', () => {
8
+ for (const [passing, missingInputs, expected] of [
9
+ [0, [], 'No resources evaluated'],
10
+ [0, ['secrets'], 'Some checks could not run'],
11
+ [4, ['secrets'], 'Some checks could not run'],
12
+ [4, [], 'All checks passing'],
13
+ ] as const) {
14
+ it(`shows ${expected} with ${passing} passing and ${missingInputs.length} unavailable inputs`, () => {
15
+ const html = renderToString(<AuditCard data={{ passing, missingInputs: [...missingInputs], warning: 0, danger: 0, categories: {} }} onNavigate={() => {}} />)
16
+ expect(html).toContain(expected)
17
+ if (missingInputs.length || passing === 0) expect(html).not.toContain('All checks passing')
18
+ })
19
+ }
20
+ it('keeps a coverage warning alongside real findings', () => {
21
+ const html = renderToString(<AuditCard data={{ passing: 1, missingInputs: ['secrets'], warning: 1, danger: 0, categories: { Security: { passing: 1, warning: 0, danger: 0 } } }} onNavigate={() => {}} />)
22
+ expect(html).toContain('Some checks could not run')
23
+ expect(html).not.toContain('All passing')
24
+ })
25
+ for (const [evaluated, missingInputs, expected] of [
26
+ [0, [], 'No resources evaluated'],
27
+ [0, ['secrets'], 'No findings in the available data'],
28
+ [4, ['secrets'], 'No findings in the available data'],
29
+ [4, [], 'Every audited resource passed'],
30
+ ] as const) {
31
+ it(`Checks: ${expected}`, () => {
32
+ const html = renderToString(
33
+ <FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
34
+ <ChecksView checks={[]} catalog={{}} anyData evaluated={evaluated} missingInputs={[...missingInputs]} />
35
+ </FilterLocationProvider>,
36
+ )
37
+ expect(html).toContain(expected)
38
+ if (missingInputs.length) {
39
+ expect(html).toContain('Unavailable inputs:')
40
+ expect(html).toContain('secrets')
41
+ expect(html).not.toContain('Every audited resource passed')
42
+ }
43
+ })
44
+ }
45
+ })
@@ -1,5 +1,5 @@
1
- import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'
2
- import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
1
+ import { useState, useMemo, useRef, useEffect, useId, type Dispatch, type SetStateAction } from 'react'
2
+ import { ShieldAlert, AlertTriangle, CheckCircle2, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { AuditFinding } from './AuditAlerts'
5
5
  import { BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
@@ -8,6 +8,7 @@ import { SearchBox } from '../ui/SearchBox'
8
8
  import { FilterPill } from '../ui/FilterPill'
9
9
  import { pluralize } from '../../utils/pluralize'
10
10
  import { SEVERITY_TEXT_CLASS } from '../checks/severity'
11
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../ui/Collapse'
11
12
 
12
13
  const CATEGORIES = ['Security', 'Reliability', 'Efficiency'] as const
13
14
  const RAW_SEVERITIES = ['danger', 'warning'] as const
@@ -68,6 +69,9 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
68
69
  const [searchTerm, setSearchTerm] = useState('')
69
70
  const [expanded, setExpanded] = useState<Set<string>>(new Set())
70
71
  const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
72
+ // Namespace rows are mapped inline, so they can't each call useDisclosure;
73
+ // one generated prefix plus the namespace keeps aria-controls unique.
74
+ const nsPanelBase = useId()
71
75
  const [groupByNS, setGroupByNS] = useState(false)
72
76
 
73
77
  const toggleInSet = (setter: Dispatch<SetStateAction<Set<string>>>, value: string) => {
@@ -328,6 +332,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
328
332
  role="button"
329
333
  tabIndex={0}
330
334
  aria-expanded={nsExpanded}
335
+ aria-controls={disclosurePanelId(nsPanelBase, ns)}
331
336
  onClick={() => toggleNS(ns)}
332
337
  onKeyDown={(e) => {
333
338
  if (e.target !== e.currentTarget) return
@@ -335,7 +340,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
335
340
  }}
336
341
  className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
337
342
  >
338
- <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary shrink-0 transition-transform duration-200', nsExpanded && 'rotate-90')} />
343
+ <CollapseChevron open={nsExpanded} className="w-4 h-4" />
339
344
  <span className="text-sm font-semibold text-theme-text-primary">{ns}</span>
340
345
  <span className="text-xs text-theme-text-tertiary">{pluralize(nsGroups.length, 'resource')}</span>
341
346
  <span className="flex-1" />
@@ -347,18 +352,13 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
347
352
  <ContextMenu items={[{ label: `Hide ${ns} namespace`, onClick: () => onHideNamespace(ns) }]} />
348
353
  )}
349
354
  </div>
350
- <div
351
- className="grid transition-[grid-template-rows] duration-200 ease-out"
352
- style={{ gridTemplateRows: nsExpanded ? '1fr' : '0fr' }}
353
- >
354
- <div className="overflow-hidden">
355
- <div className="pl-4">
356
- {nsGroups.map(g => (
357
- <ResourceGroupRow key={`${g.kind}/${g.namespace}/${g.name}`} group={g} checks={checks} expanded={expanded} onToggle={toggle} onResourceClick={onResourceClick} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
358
- ))}
359
- </div>
355
+ <Collapse open={nsExpanded} id={disclosurePanelId(nsPanelBase, ns)}>
356
+ <div className="pl-4">
357
+ {nsGroups.map(g => (
358
+ <ResourceGroupRow key={`${g.kind}/${g.namespace}/${g.name}`} group={g} checks={checks} expanded={expanded} onToggle={toggle} onResourceClick={onResourceClick} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
359
+ ))}
360
360
  </div>
361
- </div>
361
+ </Collapse>
362
362
  </div>
363
363
  )
364
364
  })}
@@ -441,13 +441,14 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
441
441
  const key = `${g.kind}/${g.namespace}/${g.name}`
442
442
  const isExpanded = expanded.has(key)
443
443
  const hasHigh = g.danger > 0
444
+ const { panelId, buttonProps } = useDisclosure(isExpanded)
444
445
 
445
446
  return (
446
447
  <div>
447
448
  <div
449
+ {...buttonProps}
448
450
  role="button"
449
451
  tabIndex={0}
450
- aria-expanded={isExpanded}
451
452
  onClick={() => onToggle(key)}
452
453
  onKeyDown={(e) => {
453
454
  if (e.target !== e.currentTarget) return
@@ -455,7 +456,7 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
455
456
  }}
456
457
  className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
457
458
  >
458
- <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary shrink-0 transition-transform duration-200', isExpanded && 'rotate-90')} />
459
+ <CollapseChevron open={isExpanded} className="w-3.5 h-3.5" />
459
460
  {hasHigh ? (
460
461
  <ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT_CLASS.high)} />
461
462
  ) : (
@@ -485,18 +486,13 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
485
486
  <ContextMenu items={[{ label: `Hide ${g.namespace} namespace`, onClick: () => onHideNamespace(g.namespace) }]} />
486
487
  )}
487
488
  </div>
488
- <div
489
- className="grid transition-[grid-template-rows] duration-200 ease-out"
490
- style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
491
- >
492
- <div className="overflow-hidden">
493
- <div className="pl-11 pb-1">
494
- {g.findings.map((f, i) => (
495
- <FindingDetail key={`${f.checkID}-${i}`} finding={f} meta={checks?.[f.checkID]} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
496
- ))}
497
- </div>
489
+ <Collapse open={isExpanded} id={panelId}>
490
+ <div className="pl-11 pb-1">
491
+ {g.findings.map((f, i) => (
492
+ <FindingDetail key={`${f.checkID}-${i}`} finding={f} meta={checks?.[f.checkID]} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
493
+ ))}
498
494
  </div>
499
- </div>
495
+ </Collapse>
500
496
  </div>
501
497
  )
502
498
  }
@@ -102,6 +102,19 @@ describe('AreaChart domain and axes', () => {
102
102
  expect(attrs(render({ series: [series([[0, 1], [3600, 5]])] }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(1)
103
103
  })
104
104
 
105
+ it('breaks omitted evaluations only when the caller supplies the interval', () => {
106
+ const sparse = [series([[0, 1], [15, 2], [105, 3], [120, 4]])]
107
+ const paths = (stepSeconds?: number) => attrs(render({ series: sparse, stepSeconds }), 'path', 'fill').filter(fill => fill === 'none')
108
+ expect(paths(15)).toHaveLength(2)
109
+ expect(paths()).toHaveLength(1)
110
+ })
111
+
112
+ it('preserves explicit gaps, zeros and isolated-sample behavior with a known interval', () => {
113
+ const gaps = [series([[0, 0], [15, 0], [30, null], [45, 1], [60, 2]])]
114
+ expect(attrs(render({ series: gaps, stepSeconds: 15 }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(2)
115
+ expect(attrs(render({ series: [series([[0, 1]])], stepSeconds: 15 }), 'path', 'fill')).toHaveLength(0)
116
+ })
117
+
105
118
  it('renders nothing for an empty series list', () => {
106
119
  expect(render({ series: [] })).toBe('')
107
120
  })