@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
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { getSubtitle } from './GitOpsTreeGraph'
4
+ import type { GitOpsTreeNode } from '../../../types'
5
+
6
+ function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
7
+ return {
8
+ id: 'node-1',
9
+ ref: { kind: 'Service', name: 'podinfo', namespace: 'demo-flux' },
10
+ role: 'declared',
11
+ tool: 'fluxcd',
12
+ ...extras,
13
+ }
14
+ }
15
+
16
+ describe('getSubtitle', () => {
17
+ it('shows what a healthy resource exposes, not that it is healthy', () => {
18
+ // The status dot and the left border stripe already paint a healthy node
19
+ // green, so the word adds nothing and the ports are the only thing here the
20
+ // rest of the card can't say.
21
+ expect(getSubtitle(node({
22
+ sync: 'Synced',
23
+ health: 'Healthy',
24
+ info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
25
+ }))).toBe('Synced • ClusterIP :9898 +1 more')
26
+
27
+ expect(getSubtitle(node({
28
+ ref: { kind: 'Pod', name: 'podinfo-6b4f8c9d7-xk2mv', namespace: 'demo-flux' },
29
+ role: 'generated',
30
+ health: 'Healthy',
31
+ info: [{ name: 'Phase', value: 'Running' }],
32
+ }))).toBe('Running')
33
+
34
+ expect(getSubtitle(node({
35
+ ref: { kind: 'Ingress', name: 'podinfo', namespace: 'demo-flux' },
36
+ sync: 'Synced',
37
+ health: 'Healthy',
38
+ info: [{ name: 'Host', value: 'podinfo.example.com' }],
39
+ }))).toBe('Synced • podinfo.example.com')
40
+ })
41
+
42
+ it('keeps every health value the stripe colour cannot identify on its own', () => {
43
+ // Progressing and Suspended are both yellow, Degraded and Missing both red.
44
+ // Drop the word and the two become indistinguishable.
45
+ expect(getSubtitle(node({
46
+ sync: 'Synced',
47
+ health: 'Progressing',
48
+ info: [{ name: 'Service', value: 'LoadBalancer :80' }],
49
+ }))).toBe('Synced • Progressing • LoadBalancer :80')
50
+
51
+ expect(getSubtitle(node({
52
+ ref: { kind: 'Deployment', name: 'podinfo', namespace: 'demo-flux' },
53
+ sync: 'OutOfSync',
54
+ health: 'Degraded',
55
+ info: [{ name: 'Ready', value: '2/3' }],
56
+ }))).toBe('OutOfSync • Degraded • 2/3')
57
+
58
+ expect(getSubtitle(node({
59
+ health: 'Unknown',
60
+ info: [{ name: 'Service', value: 'ClusterIP :80' }],
61
+ }))).toBe('Unknown • ClusterIP :80')
62
+ })
63
+
64
+ it('still reads as Healthy when there is nothing to say instead', () => {
65
+ // Kinds infoFromTopology doesn't cover, and remote Argo destinations, reach
66
+ // the tree with no info line at all. Those must not lose their status line.
67
+ expect(getSubtitle(node({ sync: 'Synced', health: 'Healthy' }))).toBe('Synced • Healthy')
68
+ expect(getSubtitle(node({ health: 'Healthy' }))).toBe('Healthy')
69
+ expect(getSubtitle(node({ sync: 'OutOfSync', health: 'Missing' }))).toBe('OutOfSync • Missing')
70
+ })
71
+
72
+ it('lets lifecycle and grouping own the line outright', () => {
73
+ expect(getSubtitle(node({
74
+ role: 'group',
75
+ sync: 'Synced',
76
+ health: 'Healthy',
77
+ info: [{ name: 'Phase', value: 'Running' }],
78
+ }))).toBe('Click to expand')
79
+
80
+ expect(getSubtitle(node({
81
+ sync: 'Synced',
82
+ health: 'Healthy',
83
+ info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
84
+ data: { deletionTimestamp: '2026-09-11T13:09:43Z' },
85
+ }))).toBe('Pending deletion')
86
+ })
87
+
88
+ it('falls back to the namespace only when the node states nothing else', () => {
89
+ expect(getSubtitle(node())).toBe('demo-flux')
90
+ expect(getSubtitle(node({ ref: { kind: 'ClusterRole', name: 'podinfo', namespace: '' } }))).toBe('')
91
+ })
92
+ })
@@ -25,6 +25,7 @@ import { displayKind } from '../../../types'
25
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
26
26
  import { formatCompactAge } from '../../../utils/format'
27
27
  import { radarHealthNote } from '../health-provenance'
28
+ import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
28
29
  import { getTopologyIcon } from '../../../utils/resource-icons'
29
30
  import { Tooltip } from '../../ui/Tooltip'
30
31
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
@@ -592,6 +593,15 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
592
593
  // (ReplicaSets, Pods) are always Radar's read; marking each would be
593
594
  // noise.
594
595
  const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
596
+ // A Service's subtitle names its first port and counts the rest, so without a
597
+ // hover the ones behind "+1 more" can't be reached from the graph at all. Same
598
+ // affordance the topology graph's Service node carries, same renderer. Stood
599
+ // down when the card already explains an unhealthy verdict: that reason is
600
+ // what the hover should say, and only one tooltip is ever visible anyway.
601
+ const portsTooltip = kind === 'Service' && !cause
602
+ ? servicePortsTooltip((node.data?.ports as ServicePortEntry[] | undefined) ?? [])
603
+ : null
604
+ const subtitle = getSubtitle(node)
595
605
 
596
606
  const card = (
597
607
  <div
@@ -654,7 +664,13 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
654
664
  own page". The subtitle text alone wasn't enough — users were
655
665
  treating the count as an immutable fact rather than a button. */}
656
666
  <div className="mt-0.5 flex items-center gap-1 text-xs text-theme-text-secondary">
657
- <span className="truncate">{getSubtitle(node)}</span>
667
+ {portsTooltip ? (
668
+ <Tooltip content={portsTooltip} position="bottom" wrapperClassName="min-w-0">
669
+ <span className="cursor-help truncate">{subtitle}</span>
670
+ </Tooltip>
671
+ ) : (
672
+ <span className="truncate">{subtitle}</span>
673
+ )}
658
674
  {(node.role === 'group' || gitopsTool) && <ChevronRight className="ml-auto h-3 w-3 shrink-0 text-theme-text-tertiary" />}
659
675
  </div>
660
676
  {chips.length > 0 && (
@@ -755,7 +771,21 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
755
771
  return chips
756
772
  }
757
773
 
758
- function getSubtitle(node: GitOpsTreeNode): string {
774
+ // The subtitle is the node's one line of prose, and sync, health and the
775
+ // backend's info line all want it. It can't be won on precedence: the backend
776
+ // derives a health for every node it can build an info line for, so a rule that
777
+ // only reaches info when health is absent never reaches it at all, and a
778
+ // Service's ports, a Pod's phase and an Ingress's host stay invisible.
779
+ //
780
+ // So all three share the line, and "Healthy" is the part that yields when the
781
+ // space is contested. It is the one health value the node already states without
782
+ // words — healthToTopology maps it onto the green status dot and the green left
783
+ // stripe, one value to one colour. Progressing and Suspended are both yellow and
784
+ // Degraded and Missing are both red, so for those the word is the only thing
785
+ // that tells them apart and it keeps its place. "Healthy" gives up the line only
786
+ // when there is a concrete info line to spend it on, so a node with nothing to
787
+ // say instead still reads as Healthy.
788
+ export function getSubtitle(node: GitOpsTreeNode): string {
759
789
  if (node.role === 'group') {
760
790
  // Action-oriented copy invites the click; "collapsed" alone reads as
761
791
  // a state description, not an affordance.
@@ -764,10 +794,10 @@ function getSubtitle(node: GitOpsTreeNode): string {
764
794
  if (isNodeTerminating(node)) {
765
795
  return 'Pending deletion'
766
796
  }
767
- if (node.sync || node.health) {
768
- return [node.sync, node.health].filter(Boolean).join(' • ')
769
- }
770
- if (node.info?.[0]?.value) return node.info[0].value
797
+ const info = node.info?.[0]?.value
798
+ const health = node.health === 'Healthy' && info ? undefined : node.health
799
+ const parts = [node.sync, health, info].filter(Boolean)
800
+ if (parts.length > 0) return parts.join(' • ')
771
801
  return node.ref.namespace || ''
772
802
  }
773
803
 
@@ -1,6 +1,7 @@
1
- import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
- import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
1
+ import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
+ import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
3
  import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
4
5
  import { Tooltip } from '../ui/Tooltip';
5
6
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
6
7
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
@@ -207,7 +208,7 @@ export function IssueRow({
207
208
  const cluster = clusterLabel?.(issue);
208
209
  const affected = affectedSummary(issue.affected);
209
210
  const { headline } = issueMessageParts(issue);
210
- const [renderDetails, setRenderDetails] = useState(open);
211
+ const { panelId, buttonProps } = useDisclosure(open);
211
212
  const Container = as;
212
213
  const severity = normalizeIssueSeverity(issue.severity);
213
214
  const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
@@ -244,17 +245,6 @@ export function IssueRow({
244
245
  </div>
245
246
  );
246
247
 
247
- useEffect(() => {
248
- if (open) {
249
- setRenderDetails(true);
250
- return;
251
- }
252
- if (!renderDetails) return;
253
-
254
- const timeout = window.setTimeout(() => setRenderDetails(false), 200);
255
- return () => window.clearTimeout(timeout);
256
- }, [open, renderDetails]);
257
-
258
248
  return (
259
249
  <Container
260
250
  className={[
@@ -276,9 +266,9 @@ export function IssueRow({
276
266
  be invalid). Collapsed: neutral row + rail. Expanded: severity-tinted
277
267
  band + solid pill — the tint is a focus signal, not per-row alarm. */}
278
268
  <div
269
+ {...buttonProps}
279
270
  role="button"
280
271
  tabIndex={0}
281
- aria-expanded={open}
282
272
  onClick={onToggle}
283
273
  onKeyDown={(e) => {
284
274
  if (e.target !== e.currentTarget) return;
@@ -357,50 +347,41 @@ export function IssueRow({
357
347
  <div className="flex shrink-0 items-center gap-3">
358
348
  {metaChips('hidden @2xl/issue:flex')}
359
349
  {renderActions?.(slotCtx)}
360
- <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
350
+ <CollapseChevron open={open} className="h-4 w-4" />
361
351
  </div>
362
352
  </div>
363
353
 
364
- {renderDetails ? (
365
- <div
366
- className={`issue-details-motion ${open ? 'issue-details-motion-open' : ''}`}
367
- onTransitionEnd={(event) => {
368
- if (event.target !== event.currentTarget) return;
369
- if (event.propertyName !== 'grid-template-rows') return;
370
- if (!open) setRenderDetails(false);
371
- }}
372
- >
373
- <div className="overflow-hidden">
374
- {/* Body sits on the card surface (not a recessed grey panel) so its
375
- text keeps enough contrast. */}
376
- <div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
377
- <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
378
- <Diagnosis issue={issue} source={diagnosisSource} />
379
- {issue.incident_parent ? (
380
- <section className="flex flex-col gap-1">
381
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
382
- {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
383
- {issue.incident_parent.confidence ? (
384
- <Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
385
- <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
386
- {issue.incident_parent.confidence} confidence
387
- </span>
388
- </Tooltip>
389
- ) : null}
390
- </h4>
391
- <ul className="flex flex-col gap-px">
392
- <ResourceLine refForLink={memberRef(issue, issue.incident_parent.ref)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
393
- </ul>
394
- </section>
395
- ) : null}
396
- <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
397
- <AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
398
- {renderDetailSection?.(slotCtx)}
399
- </div>
400
- </div>
354
+ {/* Details unmount after the close transition: a list can hold hundreds
355
+ of rows and the body is the expensive part, so closed rows stay light. */}
356
+ <Collapse open={open} unmountOnExit id={panelId}>
357
+ {/* Body sits on the card surface (not a recessed grey panel) so its
358
+ text keeps enough contrast. */}
359
+ <div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
360
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
361
+ <Diagnosis issue={issue} source={diagnosisSource} />
362
+ {issue.incident_parent ? (
363
+ <section className="flex flex-col gap-1">
364
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
365
+ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
366
+ {issue.incident_parent.confidence ? (
367
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
368
+ <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
369
+ {issue.incident_parent.confidence} confidence
370
+ </span>
371
+ </Tooltip>
372
+ ) : null}
373
+ </h4>
374
+ <ul className="flex flex-col gap-px">
375
+ <ResourceLine refForLink={memberRef(issue, issue.incident_parent.ref)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
376
+ </ul>
377
+ </section>
378
+ ) : null}
379
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
380
+ <AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
381
+ {renderDetailSection?.(slotCtx)}
401
382
  </div>
402
383
  </div>
403
- ) : null}
384
+ </Collapse>
404
385
  </Container>
405
386
  );
406
387
  }
@@ -17,6 +17,8 @@ import {
17
17
  } from '../../utils/log-format'
18
18
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
19
19
  import { copyText } from '../../utils/clipboard'
20
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
21
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
20
22
  import {
21
23
  LOG_EXPORT_FORMAT_LABELS,
22
24
  LOG_EXPORT_FORMATS,
@@ -202,6 +204,7 @@ export function LogCore({
202
204
  new Set(['error', 'warn', 'info', 'debug'])
203
205
  )
204
206
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
207
+ const downloadMenu = useAnimatedUnmount(showDownloadMenu, overlayExitMs('menu'))
205
208
  const [exportScope, setExportScope] = useState<'visible' | 'all'>('visible')
206
209
  const [exportFormat, setExportFormat] = useState<LogExportFormat>(() => {
207
210
  try {
@@ -210,7 +213,9 @@ export function LogCore({
210
213
  } catch { return 'txt' }
211
214
  })
212
215
  const [showTsMenu, setShowTsMenu] = useState(false)
216
+ const tsMenu = useAnimatedUnmount(showTsMenu, overlayExitMs('menu'))
213
217
  const [showStructuredMenu, setShowStructuredMenu] = useState(false)
218
+ const structuredMenu = useAnimatedUnmount(showStructuredMenu, overlayExitMs('menu'))
214
219
  const [structuredMode, setStructuredMode] = useState<StructuredMode>(() => {
215
220
  try {
216
221
  const v = localStorage.getItem('radar-logs-structured-mode') as StructuredMode | null
@@ -297,9 +302,11 @@ export function LogCore({
297
302
  () => ({ format: exportFormat, showTimestamps, showPodName }),
298
303
  [exportFormat, showTimestamps, showPodName],
299
304
  )
305
+ // Keyed on presence, not logical open, so the preview doesn't flip to
306
+ // "nothing to export" while the popover is still fading out.
300
307
  const exportPreview = useMemo(
301
- () => showDownloadMenu ? previewLogExport(exportEntries, exportOptions) : [],
302
- [showDownloadMenu, exportEntries, exportOptions],
308
+ () => downloadMenu.shouldRender ? previewLogExport(exportEntries, exportOptions) : [],
309
+ [downloadMenu.shouldRender, exportEntries, exportOptions],
303
310
  )
304
311
  // Offering a scope that resolves to the same lines is a choice about nothing.
305
312
  const scopeIsMeaningful = displayEntries.length !== bufferEntries.length
@@ -309,8 +316,12 @@ export function LogCore({
309
316
  // silently exporting the whole buffer on a later visit.
310
317
  const toggleExportMenu = useCallback(() => {
311
318
  setShowDownloadMenu(prev => !prev)
312
- setExportScope('visible')
313
319
  }, [])
320
+ // The reset lands once the popover has left the DOM, not at logical close,
321
+ // so the preview doesn't re-scope while the popover is still fading out.
322
+ useEffect(() => {
323
+ if (!downloadMenu.shouldRender) setExportScope('visible')
324
+ }, [downloadMenu.shouldRender])
314
325
 
315
326
  const handleExportCopy = useCallback(() => {
316
327
  closeExportMenu()
@@ -620,8 +631,14 @@ export function LogCore({
620
631
  </span>
621
632
  </button>
622
633
  </Tooltip>
623
- {showStructuredMenu && (
624
- <div className={`absolute top-full right-0 mt-1 w-56 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
634
+ {structuredMenu.shouldRender && (
635
+ <div
636
+ inert={!showStructuredMenu}
637
+ className={`absolute top-full right-0 mt-1 w-56 origin-top-right ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 ${TRANSITION_MENU} ${
638
+ structuredMenu.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]'
639
+ } ${showStructuredMenu ? '' : 'pointer-events-none'}`}
640
+ style={overlayTransitionStyle(structuredMenu.isOpen, 'menu')}
641
+ >
625
642
  <div className={`px-3 py-1.5 text-[10px] uppercase tracking-wide ${palette.textTertiary} border-b ${palette.border}`}>
626
643
  Structured display
627
644
  </div>
@@ -673,8 +690,14 @@ export function LogCore({
673
690
  </span>
674
691
  </button>
675
692
  </Tooltip>
676
- {showTsMenu && (
677
- <div className={`absolute top-full right-0 mt-1 w-44 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
693
+ {tsMenu.shouldRender && (
694
+ <div
695
+ inert={!showTsMenu}
696
+ className={`absolute top-full right-0 mt-1 w-44 origin-top-right ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 ${TRANSITION_MENU} ${
697
+ tsMenu.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]'
698
+ } ${showTsMenu ? '' : 'pointer-events-none'}`}
699
+ style={overlayTransitionStyle(tsMenu.isOpen, 'menu')}
700
+ >
678
701
  <div className={`px-3 py-1.5 text-[10px] uppercase tracking-wide ${palette.textTertiary} border-b ${palette.border}`}>
679
702
  Timestamp format
680
703
  </div>
@@ -769,8 +792,16 @@ export function LogCore({
769
792
  <Download className="w-4 h-4" />
770
793
  </button>
771
794
  </Tooltip>
772
- {showDownloadMenu && (
773
- <div role="dialog" aria-label="Export logs" className={`absolute top-full right-0 mt-1 w-[23rem] ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 p-3 space-y-2.5`}>
795
+ {downloadMenu.shouldRender && (
796
+ <div
797
+ role="dialog"
798
+ aria-label="Export logs"
799
+ inert={!showDownloadMenu}
800
+ className={`absolute top-full right-0 mt-1 w-[23rem] origin-top-right ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 p-3 space-y-2.5 ${TRANSITION_MENU} ${
801
+ downloadMenu.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]'
802
+ } ${showDownloadMenu ? '' : 'pointer-events-none'}`}
803
+ style={overlayTransitionStyle(downloadMenu.isOpen, 'menu')}
804
+ >
774
805
  <div className="flex items-center gap-3">
775
806
  <span id="log-export-scope-label" className={exportLabelCls}>Lines</span>
776
807
  {scopeIsMeaningful ? (
@@ -1,8 +1,6 @@
1
- import { useState, useMemo, useEffect, useRef, useCallback, forwardRef } from 'react'
1
+ import { useState, useMemo, useEffect, useRef, useCallback, useId, forwardRef } from 'react'
2
2
  import {
3
3
  Search,
4
- ChevronDown,
5
- ChevronRight,
6
4
  Eye,
7
5
  EyeOff,
8
6
  Pin,
@@ -10,6 +8,7 @@ import {
10
8
  X,
11
9
  } from 'lucide-react'
12
10
  import { clsx } from 'clsx'
11
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../ui/Collapse'
13
12
  import type { APIResource } from '../../types'
14
13
  import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
15
14
  import { getResourceIcon } from '../../utils/resource-icons'
@@ -213,6 +212,10 @@ export function ResourcesSidebar({
213
212
  useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
214
213
  const [showEmptyKinds, setShowEmptyKinds] = useState(false)
215
214
  const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
215
+ const favoritesDisclosure = useDisclosure(favoritesExpanded)
216
+ // Category sections are mapped inline, so they can't each call useDisclosure;
217
+ // one generated prefix plus the category name keeps aria-controls unique.
218
+ const categoryPanelBase = useId()
216
219
 
217
220
  // Ref to selected sidebar item for scrolling into view on deeplink
218
221
  const selectedSidebarRef = useRef<HTMLButtonElement>(null)
@@ -499,14 +502,11 @@ export function ResourcesSidebar({
499
502
  {/* Favorites (pinned kinds) section — always visible */}
500
503
  <div className="mb-2">
501
504
  <button
505
+ {...favoritesDisclosure.buttonProps}
502
506
  onClick={() => setFavoritesExpanded((v) => !v)}
503
507
  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"
504
508
  >
505
- {favoritesExpanded ? (
506
- <ChevronDown className="w-3 h-3" />
507
- ) : (
508
- <ChevronRight className="w-3 h-3" />
509
- )}
509
+ <CollapseChevron open={favoritesExpanded} className="w-3 h-3" />
510
510
  <span className="flex-1 text-left">Favorites</span>
511
511
  {!favoritesExpanded && pinned.length > 0 && (
512
512
  <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', pinned.length < 1000 ? 'w-8' : 'w-9')}>
@@ -514,41 +514,36 @@ export function ResourcesSidebar({
514
514
  </span>
515
515
  )}
516
516
  </button>
517
- <div className={clsx(
518
- 'grid transition-[grid-template-rows] duration-200',
519
- favoritesExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
520
- )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
521
- <div className="overflow-hidden">
522
- <div className="space-y-0.5">
523
- {pinned.length === 0 ? (
524
- <div className="px-3 py-2 text-xs text-theme-text-disabled">
525
- No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
526
- </div>
527
- ) : (
528
- pinned.map((p) => {
529
- const isResourceSelected =
530
- (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
531
- (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
532
- const highlighted = isKindHighlighted(p.name, p.group)
533
- return (
534
- <ResourceTypeButton
535
- key={`${p.name}-${p.group}`}
536
- ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
537
- resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
538
- count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
539
- isSelected={isResourceSelected}
540
- isHighlighted={highlighted}
541
- isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
542
- isPinned={true}
543
- onTogglePin={() => togglePin(p)}
544
- onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
545
- />
546
- )
547
- })
548
- )}
549
- </div>
517
+ <Collapse open={favoritesExpanded} id={favoritesDisclosure.panelId}>
518
+ <div className="space-y-0.5">
519
+ {pinned.length === 0 ? (
520
+ <div className="px-3 py-2 text-xs text-theme-text-disabled">
521
+ No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
522
+ </div>
523
+ ) : (
524
+ pinned.map((p) => {
525
+ const isResourceSelected =
526
+ (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
527
+ (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
528
+ const highlighted = isKindHighlighted(p.name, p.group)
529
+ return (
530
+ <ResourceTypeButton
531
+ key={`${p.name}-${p.group}`}
532
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
533
+ resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
534
+ count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
535
+ isSelected={isResourceSelected}
536
+ isHighlighted={highlighted}
537
+ isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
538
+ isPinned={true}
539
+ onTogglePin={() => togglePin(p)}
540
+ onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
541
+ />
542
+ )
543
+ })
544
+ )}
550
545
  </div>
551
- </div>
546
+ </Collapse>
552
547
  </div>
553
548
  {filteredCategories ? (
554
549
  // Dynamic categories from API
@@ -558,14 +553,12 @@ export function ResourcesSidebar({
558
553
  return (
559
554
  <div key={category.name} className="mb-2">
560
555
  <button
556
+ aria-expanded={isExpanded}
557
+ aria-controls={disclosurePanelId(categoryPanelBase, category.name)}
561
558
  onClick={() => toggleCategory(category.name)}
562
559
  className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
563
560
  >
564
- {isExpanded ? (
565
- <ChevronDown className="w-3 h-3" />
566
- ) : (
567
- <ChevronRight className="w-3 h-3" />
568
- )}
561
+ <CollapseChevron open={isExpanded} className="w-3 h-3" />
569
562
  <span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
570
563
  {!isExpanded && (
571
564
  <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', category.total < 1000 ? 'w-8' : 'w-9')}>
@@ -573,38 +566,33 @@ export function ResourcesSidebar({
573
566
  </span>
574
567
  )}
575
568
  </button>
576
- <div className={clsx(
577
- 'grid transition-[grid-template-rows] duration-200',
578
- isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
579
- )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
580
- <div className="overflow-hidden">
581
- <div className="space-y-0.5">
582
- {category.visibleResources.map((resource) => {
583
- const resourceIsPinned = isPinned(resource.name, resource.group)
584
- const isResourceSelected =
585
- (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
586
- (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
587
- // If the resource is pinned, let the Favorites section own the highlight
588
- const showSelected = isResourceSelected && !resourceIsPinned
589
- const highlighted = isKindHighlighted(resource.name, resource.group)
590
- return (
591
- <ResourceTypeButton
592
- key={resource.name}
593
- ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
594
- resource={resource}
595
- count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
596
- isSelected={showSelected}
597
- isHighlighted={highlighted}
598
- isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
599
- isPinned={resourceIsPinned}
600
- onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
601
- onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
602
- />
603
- )
604
- })}
605
- </div>
569
+ <Collapse open={isExpanded} id={disclosurePanelId(categoryPanelBase, category.name)}>
570
+ <div className="space-y-0.5">
571
+ {category.visibleResources.map((resource) => {
572
+ const resourceIsPinned = isPinned(resource.name, resource.group)
573
+ const isResourceSelected =
574
+ (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
575
+ (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
576
+ // If the resource is pinned, let the Favorites section own the highlight
577
+ const showSelected = isResourceSelected && !resourceIsPinned
578
+ const highlighted = isKindHighlighted(resource.name, resource.group)
579
+ return (
580
+ <ResourceTypeButton
581
+ key={resource.name}
582
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
583
+ resource={resource}
584
+ count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
585
+ isSelected={showSelected}
586
+ isHighlighted={highlighted}
587
+ isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
588
+ isPinned={resourceIsPinned}
589
+ onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
590
+ onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
591
+ />
592
+ )
593
+ })}
606
594
  </div>
607
- </div>
595
+ </Collapse>
608
596
  </div>
609
597
  )
610
598
  })