@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
@@ -1,7 +1,8 @@
1
- import { useState, useMemo } from 'react'
2
- import { ShieldCheck, ChevronDown, ChevronRight, CheckCircle2, XCircle, Search } from 'lucide-react'
1
+ import { useState, useMemo, useId } from 'react'
2
+ import { ShieldCheck, CheckCircle2, XCircle, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../../ui/Collapse'
5
6
  import { Input } from '../../ui/Input'
6
7
  import { formatAge } from '../resource-utils'
7
8
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
@@ -15,6 +16,10 @@ interface ClusterComplianceReportRendererProps {
15
16
  export function ClusterComplianceReportRenderer({ data }: ClusterComplianceReportRendererProps) {
16
17
  const [expandedSection, setExpandedSection] = useState(true)
17
18
  const [expandedControls, setExpandedControls] = useState<Set<string>>(new Set())
19
+ const sectionDisclosure = useDisclosure(expandedSection)
20
+ // Control rows are mapped inline, so they can't each call useDisclosure;
21
+ // one generated prefix plus the control id keeps aria-controls unique.
22
+ const controlPanelBase = useId()
18
23
  const [searchTerm, setSearchTerm] = useState('')
19
24
  const [showFailedOnly, setShowFailedOnly] = useState(false)
20
25
 
@@ -127,13 +132,14 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
127
132
  {sortedChecks.length > 0 && (
128
133
  <Section title="Controls">
129
134
  <button
135
+ {...sectionDisclosure.buttonProps}
130
136
  onClick={() => setExpandedSection(!expandedSection)}
131
137
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
132
138
  >
133
- {expandedSection ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
139
+ <CollapseChevron open={expandedSection} className="w-3.5 h-3.5" />
134
140
  {sortedChecks.length} controls
135
141
  </button>
136
- {expandedSection && (
142
+ <Collapse open={expandedSection} unmountOnExit id={sectionDisclosure.panelId}>
137
143
  <>
138
144
  {/* Search and filter */}
139
145
  <div className="flex items-center gap-2 mb-2">
@@ -174,10 +180,14 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
174
180
  {filteredChecks.map((check: any) => {
175
181
  const hasFail = (check.totalFail || 0) > 0
176
182
  const controlDef = controlMap.get(check.id)
177
- const isExpanded = expandedControls.has(check.id)
183
+ // One predicate for header, caret and panel: a control with no
184
+ // definition has nothing to show, so it never reads as expanded.
185
+ const isExpanded = expandedControls.has(check.id) && !!controlDef
178
186
  return (
179
187
  <div key={check.id}>
180
188
  <button
189
+ aria-expanded={isExpanded}
190
+ aria-controls={disclosurePanelId(controlPanelBase, check.id)}
181
191
  onClick={() => toggleControl(check.id)}
182
192
  className="w-full flex items-center gap-2 py-1.5 px-1 rounded hover:bg-theme-hover/50 text-left"
183
193
  >
@@ -193,22 +203,18 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
193
203
  <span className="text-[10px] text-theme-text-tertiary shrink-0">
194
204
  {check.totalFail || 0}F / {check.totalPass || 0}P
195
205
  </span>
196
- {isExpanded ? (
197
- <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" />
198
- ) : (
199
- <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
200
- )}
206
+ <CollapseChevron open={isExpanded} className="w-3 h-3" />
201
207
  </button>
202
- {isExpanded && controlDef && (
208
+ <Collapse open={isExpanded} mountLazily id={disclosurePanelId(controlPanelBase, check.id)}>
203
209
  <div className="ml-6 mr-1 mb-2 p-2.5 bg-theme-elevated/50 rounded border border-theme-border/50 space-y-2">
204
210
  <div className="text-[10px] font-mono text-theme-text-tertiary">{check.id}</div>
205
- {controlDef.description && (
211
+ {controlDef?.description && (
206
212
  <div>
207
213
  <div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-0.5">Description</div>
208
214
  <div className="text-xs text-theme-text-secondary">{controlDef.description}</div>
209
215
  </div>
210
216
  )}
211
- {controlDef.checks?.length > 0 && (
217
+ {controlDef?.checks?.length > 0 && (
212
218
  <div>
213
219
  <div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-0.5">Check IDs</div>
214
220
  <div className="flex flex-wrap gap-1">
@@ -219,7 +225,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
219
225
  </div>
220
226
  )}
221
227
  </div>
222
- )}
228
+ </Collapse>
223
229
  </div>
224
230
  )
225
231
  })}
@@ -230,7 +236,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
230
236
  )}
231
237
  </div>
232
238
  </>
233
- )}
239
+ </Collapse>
234
240
  </Section>
235
241
  )}
236
242
  </>
@@ -1,6 +1,8 @@
1
+ import { useState } from 'react'
1
2
  import { clsx } from 'clsx'
2
3
  import { Layers, GitBranch, FileText, Boxes, ScrollText } from 'lucide-react'
3
4
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
4
6
  import { CodeViewer } from '../../ui/CodeViewer'
5
7
  import { kindToPlural } from '../../../utils/navigation'
6
8
 
@@ -122,16 +124,7 @@ function CompositionBody({ data, onNavigate, revision }: CompositionRendererProp
122
124
  />
123
125
  )}
124
126
  </PropertyList>
125
- {step.input && (
126
- <details className="mt-2">
127
- <summary className="text-xs text-theme-text-tertiary cursor-pointer hover:text-theme-text-secondary">
128
- Show input
129
- </summary>
130
- <div className="mt-1">
131
- <CodeViewer code={JSON.stringify(step.input, null, 2)} language="json" maxHeight="200px" />
132
- </div>
133
- </details>
134
- )}
127
+ {step.input && <StepInputDisclosure input={step.input} />}
135
128
  </div>
136
129
  )
137
130
  })}
@@ -216,3 +209,30 @@ export function CompositionRevisionRenderer({ data, onNavigate }: CompositionRev
216
209
  </>
217
210
  )
218
211
  }
212
+
213
+ // Per-step input viewer. A native <details> looked nothing like the rest of
214
+ // the drawer's disclosures and skipped the shared open/close motion. The
215
+ // viewer renders on first open: one highlighted JSON pane per pipeline step
216
+ // is real work to do for steps nobody expands.
217
+ function StepInputDisclosure({ input }: { input: unknown }) {
218
+ const [open, setOpen] = useState(false)
219
+ const { panelId, buttonProps } = useDisclosure(open)
220
+ return (
221
+ <div className="mt-2">
222
+ <button
223
+ {...buttonProps}
224
+ type="button"
225
+ onClick={() => setOpen((v) => !v)}
226
+ className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-secondary"
227
+ >
228
+ <CollapseChevron open={open} className="h-3 w-3" />
229
+ Show input
230
+ </button>
231
+ <Collapse open={open} mountLazily id={panelId}>
232
+ <div className="mt-1">
233
+ <CodeViewer code={JSON.stringify(input, null, 2)} language="json" maxHeight="200px" />
234
+ </div>
235
+ </Collapse>
236
+ </div>
237
+ )
238
+ }
@@ -1,7 +1,8 @@
1
- import { useState, useMemo } from 'react'
2
- import { ShieldCheck, ChevronDown, ChevronRight, CheckCircle2, XCircle } from 'lucide-react'
1
+ import { useState, useMemo, useId } from 'react'
2
+ import { ShieldCheck, CheckCircle2, XCircle } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../../ui/Collapse'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER, TrivyAlertBanner } from './trivy-shared'
7
8
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
@@ -65,6 +66,10 @@ function groupChecks(checks: any[]): GroupedCheck[] {
65
66
  export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererProps) {
66
67
  const [expandedSection, setExpandedSection] = useState(true)
67
68
  const [expandedChecks, setExpandedChecks] = useState<Set<string>>(new Set())
69
+ const sectionDisclosure = useDisclosure(expandedSection)
70
+ // Check rows are mapped inline, so they can't each call useDisclosure; one
71
+ // generated prefix plus the check id keeps aria-controls unique.
72
+ const checkPanelBase = useId()
68
73
 
69
74
  const report = data.report || {}
70
75
  const summary = report.summary || {}
@@ -149,19 +154,22 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
149
154
  {grouped.length > 0 && (
150
155
  <Section title="Checks">
151
156
  <button
157
+ {...sectionDisclosure.buttonProps}
152
158
  onClick={() => setExpandedSection(!expandedSection)}
153
159
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
154
160
  >
155
- {expandedSection ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
161
+ <CollapseChevron open={expandedSection} className="w-3.5 h-3.5" />
156
162
  {grouped.length} unique checks{containerCount > 0 ? ` (${containerCount} containers scanned)` : ''}
157
163
  </button>
158
- {expandedSection && (
164
+ <Collapse open={expandedSection} unmountOnExit id={sectionDisclosure.panelId}>
159
165
  <div className="space-y-0.5">
160
166
  {grouped.map((group) => {
161
167
  const isExpanded = expandedChecks.has(group.checkID)
162
168
  return (
163
169
  <div key={group.checkID}>
164
170
  <button
171
+ aria-expanded={isExpanded}
172
+ aria-controls={disclosurePanelId(checkPanelBase, group.checkID)}
165
173
  onClick={() => toggleCheck(group.checkID)}
166
174
  className="w-full flex items-center gap-2 py-1.5 px-1 rounded hover:bg-theme-hover/50 text-left"
167
175
  >
@@ -177,13 +185,9 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
177
185
  {group.count > 1 && (
178
186
  <span className="text-[10px] text-theme-text-tertiary shrink-0">{group.count} containers</span>
179
187
  )}
180
- {isExpanded ? (
181
- <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" />
182
- ) : (
183
- <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
184
- )}
188
+ <CollapseChevron open={isExpanded} className="w-3 h-3" />
185
189
  </button>
186
- {isExpanded && (
190
+ <Collapse open={isExpanded} mountLazily id={disclosurePanelId(checkPanelBase, group.checkID)}>
187
191
  <div className="ml-6 mr-1 mb-2 p-2.5 bg-theme-elevated/50 rounded border border-theme-border/50 space-y-2">
188
192
  <div className="text-[10px] font-mono text-theme-text-tertiary">{group.checkID}</div>
189
193
  {group.description && (
@@ -209,12 +213,12 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
209
213
  </div>
210
214
  )}
211
215
  </div>
212
- )}
216
+ </Collapse>
213
217
  </div>
214
218
  )
215
219
  })}
216
220
  </div>
217
- )}
221
+ </Collapse>
218
222
  </Section>
219
223
  )}
220
224
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
- import { KeyRound, ChevronDown, ChevronRight } from 'lucide-react'
2
+ import { KeyRound } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
7
8
  import { pluralize } from '../../../utils/pluralize'
@@ -16,6 +17,7 @@ const INITIAL_SHOW_COUNT = 50
16
17
  export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendererProps) {
17
18
  const [showAll, setShowAll] = useState(false)
18
19
  const [expanded, setExpanded] = useState(true)
20
+ const { panelId, buttonProps } = useDisclosure(expanded)
19
21
 
20
22
  const report = data.report || {}
21
23
  const summary = report.summary || {}
@@ -70,13 +72,17 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
70
72
  {secrets.length > 0 && (
71
73
  <Section title="Exposed Secrets">
72
74
  <button
75
+ {...buttonProps}
73
76
  onClick={() => setExpanded(!expanded)}
74
77
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
75
78
  >
76
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
79
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
77
80
  {pluralize(secrets.length, 'secret')}
78
81
  </button>
79
- {expanded && (
82
+ {/* The table can run to hundreds of rows; unmount it while closed
83
+ so a collapsed report stays cheap. Filters live above, so
84
+ nothing is lost. */}
85
+ <Collapse open={expanded} unmountOnExit id={panelId}>
80
86
  <div className="overflow-x-auto -mx-1">
81
87
  <table className="w-full text-xs">
82
88
  <thead>
@@ -113,7 +119,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
113
119
  </button>
114
120
  )}
115
121
  </div>
116
- )}
122
+ </Collapse>
117
123
  </Section>
118
124
  )}
119
125
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
- import { Info, CheckCircle, XCircle, AlertCircle, ChevronRight, ChevronDown, FileCode, AlertTriangle, Layers } from 'lucide-react'
2
+ import { Info, CheckCircle, XCircle, AlertCircle, FileCode, AlertTriangle, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { getGenericResourceStatus } from '../generic-status'
6
7
  import type { HealthLevel } from '../resource-utils'
7
8
 
@@ -244,19 +245,23 @@ interface NestedObjectViewerProps {
244
245
 
245
246
  function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps) {
246
247
  const [expanded, setExpanded] = useState(depth < 1)
248
+ const { panelId, buttonProps } = useDisclosure(expanded)
247
249
 
248
250
  if (Array.isArray(value)) {
249
251
  return (
250
252
  <div className="text-sm">
251
253
  <button
254
+ {...buttonProps}
252
255
  onClick={() => setExpanded(!expanded)}
253
256
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
254
257
  >
255
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
258
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
256
259
  <span className="font-medium">{name}</span>
257
260
  <span className="text-xs text-theme-text-tertiary">({value.length} items)</span>
258
261
  </button>
259
- {expanded && (
262
+ {/* Recursive and unbounded: closed branches unmount so a deep object
263
+ costs only the rows the user has actually opened. */}
264
+ <Collapse open={expanded} unmountOnExit id={panelId}>
260
265
  <div className="ml-5 mt-1 space-y-1">
261
266
  {value.map((item, i) => {
262
267
  if (typeof item === 'object' && item !== null) {
@@ -269,7 +274,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
269
274
  )
270
275
  })}
271
276
  </div>
272
- )}
277
+ </Collapse>
273
278
  </div>
274
279
  )
275
280
  }
@@ -282,14 +287,15 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
282
287
  return (
283
288
  <div className="text-sm">
284
289
  <button
290
+ {...buttonProps}
285
291
  onClick={() => setExpanded(!expanded)}
286
292
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
287
293
  >
288
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
294
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
289
295
  <span className="font-medium">{name}</span>
290
296
  <span className="text-xs text-theme-text-tertiary">({entries.length} fields)</span>
291
297
  </button>
292
- {expanded && (
298
+ <Collapse open={expanded} unmountOnExit id={panelId}>
293
299
  <div className="ml-5 mt-1 space-y-1">
294
300
  {simpleEntries.map(([k, v]) => (
295
301
  <div key={k} className="flex items-start gap-2 text-xs">
@@ -301,7 +307,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
301
307
  <NestedObjectViewer key={k} name={formatFieldName(k)} value={v} depth={depth + 1} />
302
308
  ))}
303
309
  </div>
304
- )}
310
+ </Collapse>
305
311
  </div>
306
312
  )
307
313
  }
@@ -1,8 +1,9 @@
1
1
  import type React from 'react'
2
- import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks, ChevronDown, ChevronRight } from 'lucide-react'
2
+ import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { useState } from 'react'
5
5
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
6
+ import { Collapse, CollapseChevron } from '../../ui/Collapse'
6
7
  import {
7
8
  getPolicyReportSummary,
8
9
  getPolicyReportResults,
@@ -60,7 +61,7 @@ function ResultRow({ result }: { result: any }) {
60
61
  onClick={() => hasMessage && setExpanded(!expanded)}
61
62
  >
62
63
  {hasMessage ? (
63
- expanded ? <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" /> : <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
64
+ <CollapseChevron open={expanded} className="w-3 h-3" />
64
65
  ) : (
65
66
  <span className="w-3 shrink-0" />
66
67
  )}
@@ -80,7 +81,8 @@ function ResultRow({ result }: { result: any }) {
80
81
  <span className="text-theme-text-tertiary truncate">/ {result.rule}</span>
81
82
  )}
82
83
  </div>
83
- {expanded && message && (
84
+ {/* A report can hold hundreds of rows; each detail renders on first open. */}
85
+ <Collapse open={expanded && hasMessage} mountLazily>
84
86
  <div className="px-2 pb-2 pl-7">
85
87
  <div className="text-xs text-theme-text-secondary break-all card-inner">
86
88
  {message}
@@ -97,7 +99,7 @@ function ResultRow({ result }: { result: any }) {
97
99
  </div>
98
100
  )}
99
101
  </div>
100
- )}
102
+ </Collapse>
101
103
  </div>
102
104
  )
103
105
  }
@@ -1,8 +1,8 @@
1
1
  import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { useState } from 'react'
4
- import { ChevronDown, ChevronRight } from 'lucide-react'
5
4
  import { Section } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
6
6
  import { SEVERITY_BADGE } from '../../../utils/badge-colors'
7
7
  import { isForbiddenError } from '../../../types/fetch-error'
8
8
  import { LookupFailureNote } from './LookupFailureNote'
@@ -829,6 +829,7 @@ function PassingGroup({
829
829
  }) {
830
830
  const [open, setOpen] = useState(false)
831
831
  const [showAll, setShowAll] = useState(false)
832
+ const { panelId, buttonProps } = useDisclosure(open)
832
833
 
833
834
  // Nothing to open: the count is real but the subjects did not survive the
834
835
  // server cap, so the label stands alone rather than offering an empty drawer.
@@ -843,14 +844,16 @@ function PassingGroup({
843
844
  return (
844
845
  <div className="pt-0.5">
845
846
  <button
847
+ {...buttonProps}
846
848
  type="button"
847
849
  onClick={() => setOpen(!open)}
848
850
  className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-secondary"
849
851
  >
850
- {open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
852
+ <CollapseChevron open={open} className="w-3 h-3" />
851
853
  {label}
852
854
  </button>
853
- {open && (
855
+ {/* One of these per rule; the subject list only renders once opened. */}
856
+ <Collapse open={open} mountLazily id={panelId}>
854
857
  <div className="space-y-1.5 mt-1.5">
855
858
  {visible.map((s, i) => (
856
859
  <SubjectRow
@@ -891,7 +894,7 @@ function PassingGroup({
891
894
  </div>
892
895
  )}
893
896
  </div>
894
- )}
897
+ </Collapse>
895
898
  </div>
896
899
  )
897
900
  }
@@ -1,7 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Bell, Search, ChevronRight } from 'lucide-react'
2
+ import { Bell, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { Input } from '../../ui/Input'
6
7
  import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
7
8
  import {
@@ -116,6 +117,7 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
116
117
 
117
118
  // Auto-expand when searching so matched rules are visible
118
119
  const expanded = searchTerm ? filteredRules.length > 0 : manualExpanded
120
+ const { panelId, buttonProps } = useDisclosure(expanded)
119
121
 
120
122
  // If searching and no matches, hide the group entirely
121
123
  if (searchTerm && filteredRules.length === 0) return null
@@ -123,11 +125,12 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
123
125
  return (
124
126
  <div className="card-inner">
125
127
  <button
128
+ {...buttonProps}
126
129
  onClick={() => setManualExpanded(!manualExpanded)}
127
130
  className="flex items-center justify-between w-full text-left"
128
131
  >
129
132
  <div className="flex items-center gap-2">
130
- <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
133
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
131
134
  <span className="text-sm text-theme-text-primary font-medium">{group.name}</span>
132
135
  </div>
133
136
  <div className="flex items-center gap-2">
@@ -147,20 +150,15 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
147
150
  {group.recordCount > 0 && <span>{group.recordCount} recording</span>}
148
151
  </div>
149
152
  )}
150
- <div
151
- className="grid transition-[grid-template-rows] duration-200 ease-out"
152
- style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
153
- >
154
- <div className="overflow-hidden">
155
- <div className="mt-2 space-y-2">
156
- {filteredRules.map((rule, i) => (
157
- rule.type === 'alert'
158
- ? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
159
- : <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
160
- ))}
161
- </div>
153
+ <Collapse open={expanded} id={panelId}>
154
+ <div className="mt-2 space-y-2">
155
+ {filteredRules.map((rule, i) => (
156
+ rule.type === 'alert'
157
+ ? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
158
+ : <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
159
+ ))}
162
160
  </div>
163
- </div>
161
+ </Collapse>
164
162
  </div>
165
163
  )
166
164
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Package, ChevronDown, ChevronRight, Search } from 'lucide-react'
2
+ import { Package, Search } from 'lucide-react'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
4
5
  import { Input } from '../../ui/Input'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { formatTrivyImage } from './trivy-shared'
@@ -14,6 +15,7 @@ const INITIAL_SHOW_COUNT = 100
14
15
  export function SbomReportRenderer({ data }: SbomReportRendererProps) {
15
16
  const [showAll, setShowAll] = useState(false)
16
17
  const [expanded, setExpanded] = useState(true)
18
+ const { panelId, buttonProps } = useDisclosure(expanded)
17
19
  const [searchTerm, setSearchTerm] = useState('')
18
20
 
19
21
  const report = data.report || {}
@@ -63,13 +65,17 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
63
65
  {bom.length > 0 && (
64
66
  <Section title="Components">
65
67
  <button
68
+ {...buttonProps}
66
69
  onClick={() => setExpanded(!expanded)}
67
70
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
68
71
  >
69
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
72
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
70
73
  {bom.length} components
71
74
  </button>
72
- {expanded && (
75
+ {/* The table can run to hundreds of rows; unmount it while closed
76
+ so a collapsed report stays cheap. Filters live above, so
77
+ nothing is lost. */}
78
+ <Collapse open={expanded} unmountOnExit id={panelId}>
73
79
  <div className="overflow-x-auto -mx-1">
74
80
  {/* Search */}
75
81
  <div className="flex items-center gap-2 mb-2 px-1">
@@ -131,7 +137,7 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
131
137
  </button>
132
138
  )}
133
139
  </div>
134
- )}
140
+ </Collapse>
135
141
  </Section>
136
142
  )}
137
143
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Shield, Package, ChevronDown, ChevronRight, ChevronUp, ExternalLink, Search, ArrowUpDown, Wrench } from 'lucide-react'
2
+ import { Shield, Package, ChevronDown, ChevronUp, ExternalLink, Search, ArrowUpDown, Wrench } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { Input } from '../../ui/Input'
6
7
  import { formatAge } from '../resource-utils'
7
8
  import { SEVERITY_BADGE_COLORS, SEVERITY_BAR_COLORS, SEVERITY_TEXT_COLORS, SEVERITY_ORDER, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
@@ -27,6 +28,7 @@ const COLUMNS: { key: SortColumn; label: string }[] = [
27
28
  export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendererProps) {
28
29
  const [showAll, setShowAll] = useState(false)
29
30
  const [expanded, setExpanded] = useState(true)
31
+ const { panelId, buttonProps } = useDisclosure(expanded)
30
32
  const [searchTerm, setSearchTerm] = useState('')
31
33
  const [severityFilters, setSeverityFilters] = useState<Set<string>>(new Set())
32
34
  const [fixableOnly, setFixableOnly] = useState(false)
@@ -211,13 +213,17 @@ export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendere
211
213
  {vulnerabilities.length > 0 && (
212
214
  <Section title="Vulnerabilities" icon={Package}>
213
215
  <button
216
+ {...buttonProps}
214
217
  onClick={() => setExpanded(!expanded)}
215
218
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
216
219
  >
217
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
220
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
218
221
  {vulnerabilities.length} vulnerabilities
219
222
  </button>
220
- {expanded && (
223
+ {/* The table can run to hundreds of rows; unmount it while closed
224
+ so a collapsed report stays cheap. Filters live above, so
225
+ nothing is lost. */}
226
+ <Collapse open={expanded} unmountOnExit id={panelId}>
221
227
  <div className="overflow-x-auto -mx-1">
222
228
  {/* Search and filter toolbar */}
223
229
  <div className="flex items-center gap-2 mb-2 px-1">
@@ -339,7 +345,7 @@ export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendere
339
345
  </button>
340
346
  )}
341
347
  </div>
342
- )}
348
+ </Collapse>
343
349
  </Section>
344
350
  )}
345
351
  </>