@skyhook-io/k8s-ui 1.14.6 → 1.14.8

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 (50) hide show
  1. package/package.json +1 -1
  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/AuditFindingsTable.tsx +23 -27
  6. package/src/components/charts/PrometheusChartsView.tsx +29 -10
  7. package/src/components/checks/ChecksView.tsx +56 -26
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
  9. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
  11. package/src/components/issues/IssuesView.tsx +35 -54
  12. package/src/components/logs/LogCore.tsx +40 -9
  13. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  14. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  15. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  16. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  17. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  18. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  19. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  20. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  21. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  22. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  23. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
  25. package/src/components/shared/CreateResourceDialog.tsx +7 -4
  26. package/src/components/timeline/TimelineList.tsx +14 -11
  27. package/src/components/timeline/TimelineStrip.tsx +26 -4
  28. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  29. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  30. package/src/components/topology/K8sResourceNode.tsx +4 -2
  31. package/src/components/topology/TopologyControls.tsx +27 -8
  32. package/src/components/topology/TopologyGraph.tsx +14 -5
  33. package/src/components/trace/ReachabilityView.tsx +17 -16
  34. package/src/components/ui/CardSection.tsx +79 -35
  35. package/src/components/ui/Collapse.test.tsx +61 -0
  36. package/src/components/ui/Collapse.tsx +115 -29
  37. package/src/components/ui/DialogPortal.tsx +7 -2
  38. package/src/components/ui/Disclosure.test.tsx +26 -0
  39. package/src/components/ui/Disclosure.tsx +69 -0
  40. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  41. package/src/components/ui/RestrictedState.tsx +7 -4
  42. package/src/components/ui/RowActionMenu.tsx +20 -8
  43. package/src/components/ui/SelectMenu.tsx +18 -6
  44. package/src/components/ui/YamlEditor.tsx +7 -9
  45. package/src/components/ui/drawer-components.tsx +14 -21
  46. package/src/components/ui/index.ts +31 -22
  47. package/src/components/workload/WorkloadView.tsx +6 -10
  48. package/src/hooks/useAnimatedUnmount.ts +21 -3
  49. package/src/theme/components.css +0 -19
  50. package/src/utils/animation.ts +93 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.6",
3
+ "version": "1.14.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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,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
  }
@@ -1,5 +1,7 @@
1
+ import { useState } from "react";
1
2
  import { clsx } from "clsx";
2
3
  import { BarChart3, Wifi, WifiOff, Loader2 } from "lucide-react";
4
+ import { Collapse, CollapseChevron, useDisclosure } from "../ui/Collapse";
3
5
  import { AreaChart } from "./AreaChart";
4
6
  import { PaneLoader } from "../ui/PaneLoader";
5
7
  import { MetricsSummary } from "./MetricsSummary";
@@ -339,19 +341,36 @@ export function PrometheusChartsView({
339
341
  {metrics.hint}
340
342
  </p>
341
343
  )}
342
- {metrics?.query && (
343
- <details className="mt-3 w-full max-w-lg text-left">
344
- <summary className="cursor-pointer text-xs text-theme-text-quaternary hover:text-theme-text-tertiary">
345
- Diagnostics: show PromQL query
346
- </summary>
347
- <div className="mt-2 break-all rounded border border-theme-border bg-theme-base p-2 font-mono text-xs text-theme-text-secondary">
348
- {metrics.query}
349
- </div>
350
- </details>
351
- )}
344
+ {metrics?.query && <QueryDiagnostics query={metrics.query} />}
352
345
  </div>
353
346
  )}
354
347
  </div>
355
348
  </div>
356
349
  );
357
350
  }
351
+
352
+ // The PromQL behind an empty chart, for the reader who wants to run it
353
+ // themselves. Lives on the shared disclosure so it opens like every other
354
+ // fold in the app instead of the browser's default <details> jump.
355
+ function QueryDiagnostics({ query }: { query: string }) {
356
+ const [open, setOpen] = useState(false);
357
+ const { panelId, buttonProps } = useDisclosure(open);
358
+ return (
359
+ <div className="mt-3 w-full max-w-lg text-left">
360
+ <button
361
+ {...buttonProps}
362
+ type="button"
363
+ onClick={() => setOpen((v) => !v)}
364
+ className="flex items-center gap-1 text-xs text-theme-text-quaternary hover:text-theme-text-tertiary"
365
+ >
366
+ <CollapseChevron open={open} className="h-3 w-3" />
367
+ Diagnostics: show PromQL query
368
+ </button>
369
+ <Collapse open={open} id={panelId}>
370
+ <div className="mt-2 break-all rounded border border-theme-border bg-theme-base p-2 font-mono text-xs text-theme-text-secondary">
371
+ {query}
372
+ </div>
373
+ </Collapse>
374
+ </div>
375
+ );
376
+ }
@@ -1,7 +1,8 @@
1
- import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
1
+ import { useEffect, useId, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ChevronRight, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
3
+ import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
4
  import { AlertBanner, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
5
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../ui/Collapse'
5
6
  import { useFilterState, defineFilterSchema } from '../../filter-state'
6
7
  import type { CheckMeta, CheckReference } from '../audit'
7
8
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
@@ -14,6 +15,8 @@ import {
14
15
  SEVERITY_SOLID_CLASS,
15
16
  SEVERITY_TEXT_CLASS,
16
17
  } from './severity'
18
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
19
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
17
20
 
18
21
  const CATEGORIES: readonly string[] = ['Security', 'Reliability', 'Efficiency']
19
22
 
@@ -537,6 +540,7 @@ export function CheckCardShell({
537
540
  const Container = as
538
541
  const sev = normalizeCheckSeverity(severity)
539
542
  const SeverityIcon = CHECK_SEVERITY_ICON[sev]
543
+ const { panelId, buttonProps } = useDisclosure(open)
540
544
  return (
541
545
  <Container
542
546
  className={[
@@ -557,9 +561,9 @@ export function CheckCardShell({
557
561
  open/closed. Collapsed: neutral row + rail. Expanded: severity-tinted
558
562
  band + solid pill — the tint is a focus signal, not per-row alarm. */}
559
563
  <div
564
+ {...buttonProps}
560
565
  role="button"
561
566
  tabIndex={0}
562
- aria-expanded={open}
563
567
  onClick={onToggle}
564
568
  onKeyDown={(e) => {
565
569
  if (e.target !== e.currentTarget) return
@@ -585,18 +589,15 @@ export function CheckCardShell({
585
589
  {SEVERITY_LABEL[sev]}
586
590
  </span>
587
591
  {renderActions?.()}
588
- <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
592
+ <CollapseChevron open={open} className="h-4 w-4" />
589
593
  </div>
590
594
 
591
- {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
592
- collapse too, matching IssueRow; inert when closed so SR + tab skip
593
- the clipped content. Body sits on the card surface (not a recessed grey
595
+ {/* Kept mounted (Collapse's default, not `open &&`) so the collapse
596
+ animates too. Body sits on the card surface (not a recessed grey
594
597
  panel) so its text keeps enough contrast. */}
595
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
596
- <div className="overflow-hidden" inert={!open || undefined}>
597
- <div className="flex flex-col divide-y divide-theme-border/70 border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">{children}</div>
598
- </div>
599
- </div>
598
+ <Collapse open={open} id={panelId}>
599
+ <div className="flex flex-col divide-y divide-theme-border/70 border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">{children}</div>
600
+ </Collapse>
600
601
  </Container>
601
602
  )
602
603
  }
@@ -631,6 +632,9 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
631
632
  const [showAllClusters, setShowAllClusters] = useState(false)
632
633
  const shown = showAllClusters ? groups : groups.slice(0, clusterCap)
633
634
  const hiddenClusters = groups.length - shown.length
635
+ // Group rows are mapped inline, so they can't each call useDisclosure; one
636
+ // generated prefix plus the group id keeps aria-controls unique.
637
+ const panelBase = useId()
634
638
 
635
639
  return (
636
640
  <section className="flex flex-col gap-1.5">
@@ -643,6 +647,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
643
647
  <button
644
648
  type="button"
645
649
  aria-expanded={isOpen}
650
+ aria-controls={disclosurePanelId(panelBase, group.id)}
646
651
  onClick={() =>
647
652
  setOpenClusters((prev) => {
648
653
  const next = new Set(prev)
@@ -653,7 +658,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
653
658
  }
654
659
  className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-theme-hover/50"
655
660
  >
656
- <ChevronDown className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`} />
661
+ <CollapseChevron open={isOpen} className="h-3.5 w-3.5" />
657
662
  <span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">{group.label}</span>
658
663
  {group.environment && (
659
664
  <span className="shrink-0 rounded bg-theme-elevated px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-secondary ring-1 ring-theme-border">
@@ -663,11 +668,9 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
663
668
  <span className="flex-1" />
664
669
  <span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">{group.count}</span>
665
670
  </button>
666
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
667
- <div className="overflow-hidden" inert={!isOpen || undefined}>
668
- <div className="px-2.5 pb-2 pl-7">{renderGroupBody(group)}</div>
669
- </div>
670
- </div>
671
+ <Collapse open={isOpen} id={disclosurePanelId(panelBase, group.id)}>
672
+ <div className="px-2.5 pb-2 pl-7">{renderGroupBody(group)}</div>
673
+ </Collapse>
671
674
  </li>
672
675
  )
673
676
  })}
@@ -906,6 +909,10 @@ function ClusterFilter({
906
909
  onClear: () => void
907
910
  }) {
908
911
  const [menu, setMenu] = useState<{ top: number; left: number } | null>(null)
912
+ // `menu` is the logical open state; the placement outlives it so the exit
913
+ // transition plays where the menu was. Re-measured on the next open.
914
+ const placementRef = useRef<{ top: number; left: number } | null>(null)
915
+ const { shouldRender, isOpen } = useAnimatedUnmount(menu != null, overlayExitMs('menu'))
909
916
  const btnRef = useRef<HTMLButtonElement>(null)
910
917
  useEffect(() => {
911
918
  if (!menu) return
@@ -933,9 +940,13 @@ function ClusterFilter({
933
940
  return
934
941
  }
935
942
  const r = btnRef.current?.getBoundingClientRect()
936
- if (r) setMenu({ top: r.bottom + 4, left: r.left })
943
+ if (r) {
944
+ placementRef.current = { top: r.bottom + 4, left: r.left }
945
+ setMenu(placementRef.current)
946
+ }
937
947
  }
938
948
  const active = selected.size > 0
949
+ const placement = menu ?? placementRef.current
939
950
 
940
951
  return (
941
952
  <>
@@ -953,14 +964,20 @@ function ClusterFilter({
953
964
  Clusters
954
965
  <ChevronDown className="h-3 w-3" />
955
966
  </button>
956
- {menu &&
967
+ {shouldRender && placement &&
957
968
  createPortal(
958
969
  <div
959
970
  data-cluster-menu
960
971
  role="listbox"
961
972
  aria-multiselectable
962
- className="fixed z-[60] max-h-80 w-64 overflow-auto rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
963
- style={{ top: menu.top, left: menu.left }}
973
+ inert={!menu}
974
+ className={[
975
+ 'fixed z-[60] max-h-80 w-64 origin-top-left overflow-auto rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
976
+ TRANSITION_MENU,
977
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
978
+ !menu && 'pointer-events-none',
979
+ ].filter(Boolean).join(' ')}
980
+ style={{ top: placement.top, left: placement.left, ...overlayTransitionStyle(isOpen, 'menu') }}
964
981
  >
965
982
  {active && (
966
983
  <button
@@ -1003,6 +1020,9 @@ function ClusterFilter({
1003
1020
  // it; position captured at open time, any scroll/resize closes it.
1004
1021
  function RowMenu({ items }: { items: { label: string; onClick: () => void }[] }) {
1005
1022
  const [menu, setMenu] = useState<{ top: number; right: number } | null>(null)
1023
+ // See ClusterFilter: placement outlives the logical open state for the exit.
1024
+ const placementRef = useRef<{ top: number; right: number } | null>(null)
1025
+ const { shouldRender, isOpen } = useAnimatedUnmount(menu != null, overlayExitMs('menu'))
1006
1026
  const btnRef = useRef<HTMLButtonElement>(null)
1007
1027
 
1008
1028
  useEffect(() => {
@@ -1029,8 +1049,12 @@ function RowMenu({ items }: { items: { label: string; onClick: () => void }[] })
1029
1049
  return
1030
1050
  }
1031
1051
  const r = btnRef.current?.getBoundingClientRect()
1032
- if (r) setMenu({ top: r.bottom + 4, right: window.innerWidth - r.right })
1052
+ if (r) {
1053
+ placementRef.current = { top: r.bottom + 4, right: window.innerWidth - r.right }
1054
+ setMenu(placementRef.current)
1055
+ }
1033
1056
  }
1057
+ const placement = menu ?? placementRef.current
1034
1058
 
1035
1059
  return (
1036
1060
  <>
@@ -1046,12 +1070,18 @@ function RowMenu({ items }: { items: { label: string; onClick: () => void }[] })
1046
1070
  >
1047
1071
  <MoreHorizontal className="h-4 w-4" />
1048
1072
  </button>
1049
- {menu &&
1073
+ {shouldRender && placement &&
1050
1074
  createPortal(
1051
1075
  <div
1052
1076
  role="menu"
1053
- className="fixed z-[60] min-w-48 rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
1054
- style={{ top: menu.top, right: menu.right }}
1077
+ inert={!menu}
1078
+ className={[
1079
+ 'fixed z-[60] min-w-48 origin-top-right rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
1080
+ TRANSITION_MENU,
1081
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
1082
+ !menu && 'pointer-events-none',
1083
+ ].filter(Boolean).join(' ')}
1084
+ style={{ top: placement.top, right: placement.right, ...overlayTransitionStyle(isOpen, 'menu') }}
1055
1085
  onClick={(e) => e.stopPropagation()}
1056
1086
  >
1057
1087
  {items.map((it, i) => (
@@ -1,5 +1,6 @@
1
1
  import { useEffect, type ComponentType, type ReactNode } from 'react'
2
- import { ArrowDownUp, ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
2
+ import { ArrowDownUp, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
3
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
3
4
  import { PaneLoader } from '../ui/PaneLoader'
4
5
 
5
6
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
@@ -257,6 +258,10 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
257
258
  documentTitleSuffix,
258
259
  children,
259
260
  } = props
261
+ // One predicate for header, caret and panel: without content there is
262
+ // nothing to open, so the header must not announce expanded.
263
+ const helmValuesShown = !!helmValuesOpen && !!helmValuesContent
264
+ const helmValuesDisclosure = useDisclosure(helmValuesShown)
260
265
 
261
266
  // Document title side effect — opt-in so hub-web can take ownership of
262
267
  // its own title format.
@@ -481,16 +486,12 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
481
486
  {helmValues && onToggleHelmValues && (
482
487
  <div className="shrink-0 border-b border-theme-border bg-theme-base">
483
488
  <button
489
+ {...helmValuesDisclosure.buttonProps}
484
490
  type="button"
485
491
  onClick={onToggleHelmValues}
486
492
  className="flex w-full items-center gap-2 px-4 py-2 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
487
- aria-expanded={helmValuesOpen}
488
493
  >
489
- {helmValuesOpen ? (
490
- <ChevronDown className="h-3.5 w-3.5 shrink-0" />
491
- ) : (
492
- <ChevronRight className="h-3.5 w-3.5 shrink-0" />
493
- )}
494
+ <CollapseChevron open={helmValuesShown} className="h-3.5 w-3.5" />
494
495
  <Settings className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
495
496
  <span className="font-medium text-theme-text-primary">Helm values</span>
496
497
  <span className="tabular-nums text-theme-text-tertiary">
@@ -500,11 +501,13 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
500
501
  <span className="text-[10px] uppercase tracking-wider text-theme-text-tertiary">parameters</span>
501
502
  )}
502
503
  </button>
503
- {helmValuesOpen && helmValuesContent && (
504
+ {/* The values viewer is a full code pane; keep it out of the tree
505
+ while closed rather than paying for it on every detail open. */}
506
+ <Collapse open={helmValuesShown} unmountOnExit id={helmValuesDisclosure.panelId}>
504
507
  <div className="border-t border-theme-border bg-theme-surface px-4 py-3">
505
508
  {helmValuesContent}
506
509
  </div>
507
- )}
510
+ </Collapse>
508
511
  </div>
509
512
  )}
510
513
  </>