@skyhook-io/k8s-ui 1.14.5 → 1.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditCard.tsx +18 -7
  6. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  8. package/src/components/charts/AreaChart.test.tsx +13 -0
  9. package/src/components/charts/AreaChart.tsx +28 -25
  10. package/src/components/charts/PrometheusChartsView.tsx +41 -10
  11. package/src/components/charts/axis.test.ts +16 -0
  12. package/src/components/charts/axis.ts +3 -1
  13. package/src/components/charts/format.ts +4 -0
  14. package/src/components/charts/nearestSample.test.ts +31 -0
  15. package/src/components/charts/nearestSample.ts +15 -0
  16. package/src/components/checks/ChecksView.tsx +76 -28
  17. package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
  18. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  19. package/src/components/gitops/health-provenance.test.ts +54 -0
  20. package/src/components/gitops/health-provenance.ts +24 -0
  21. package/src/components/gitops/index.ts +2 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  24. package/src/components/gitops/tree/merge.test.ts +34 -0
  25. package/src/components/gitops/tree/merge.ts +7 -0
  26. package/src/components/issues/IssuesView.tsx +35 -54
  27. package/src/components/logs/LogCore.tsx +40 -9
  28. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  29. package/src/components/resources/ResourcesView.tsx +67 -2
  30. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  31. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  32. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  33. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  36. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  37. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  38. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  39. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  40. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  41. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  42. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  43. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  46. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  47. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  48. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  49. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  50. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  51. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  52. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  53. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  54. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  55. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  56. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  57. package/src/components/resources/renderers/index.ts +3 -0
  58. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  59. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
  60. package/src/components/resources/resource-utils.ts +18 -9
  61. package/src/components/shared/CreateResourceDialog.tsx +237 -14
  62. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  63. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  64. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  65. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  66. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  67. package/src/components/shared/index.ts +4 -0
  68. package/src/components/timeline/TimelineList.tsx +14 -11
  69. package/src/components/timeline/TimelineStrip.tsx +26 -4
  70. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  71. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  72. package/src/components/topology/TopologyControls.tsx +27 -8
  73. package/src/components/topology/TopologyGraph.tsx +14 -5
  74. package/src/components/trace/ReachabilityView.tsx +17 -16
  75. package/src/components/ui/CardSection.tsx +79 -35
  76. package/src/components/ui/Collapse.test.tsx +74 -0
  77. package/src/components/ui/Collapse.tsx +115 -29
  78. package/src/components/ui/DialogPortal.tsx +7 -2
  79. package/src/components/ui/FetchResult.test.tsx +20 -2
  80. package/src/components/ui/FetchResult.tsx +33 -3
  81. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  82. package/src/components/ui/FreshnessControl.tsx +8 -2
  83. package/src/components/ui/RestrictedState.tsx +7 -4
  84. package/src/components/ui/RowActionMenu.tsx +20 -8
  85. package/src/components/ui/SelectMenu.tsx +18 -6
  86. package/src/components/ui/YamlEditor.tsx +7 -9
  87. package/src/components/ui/drawer-components.tsx +14 -21
  88. package/src/components/ui/index.ts +30 -22
  89. package/src/components/workload/WorkloadView.tsx +8 -12
  90. package/src/hooks/useAnimatedUnmount.ts +21 -3
  91. package/src/theme/components.css +20 -1
  92. package/src/types/core.ts +15 -1
  93. package/src/types/gitops-insights.ts +23 -0
  94. package/src/types/gitops-tree.ts +29 -0
  95. package/src/utils/animation.ts +93 -13
  96. package/src/utils/index.ts +1 -0
  97. package/src/utils/navigation.test.ts +20 -1
  98. package/src/utils/navigation.ts +25 -0
  99. package/src/utils/network-policy.test.ts +42 -0
  100. package/src/utils/network-policy.ts +59 -0
  101. package/src/utils/reflector.ts +139 -0
  102. package/src/utils/yaml-file-import.test.ts +189 -0
  103. package/src/utils/yaml-file-import.ts +95 -0
  104. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -5,6 +5,7 @@ import { formatMetricValue, formatTimestamp } from './format'
5
5
  import { layoutAnnotations } from './annotations'
6
6
  import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
7
7
  import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
8
+ import { nearestSample } from './nearestSample'
8
9
 
9
10
  // Below this rendered width the annotation label pills would cover most of the
10
11
  // plot once the 1000-unit viewBox is scaled down; the lines stay and the label
@@ -12,7 +13,7 @@ import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
12
13
  export const ANNOTATION_LABEL_MIN_WIDTH_PX = 420
13
14
  const ANNOTATION_HOVER_TOLERANCE = 8
14
15
 
15
- export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, layout = 'full' }: {
16
+ export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full' }: {
16
17
  series: TimeSeries[]
17
18
  color: string
18
19
  fillColor: string
@@ -22,6 +23,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
22
23
  annotations?: ChartAnnotation[]
23
24
  /** X axis window in unix seconds. Defaults to the sample extent. */
24
25
  domain?: { start: number; end: number }
26
+ /** Expected positive sample interval; omitted evaluations break paths and cannot supply hover values. */
27
+ stepSeconds?: number
25
28
  /**
26
29
  * Display name per series, parallel to `series`. Pass it when the chart
27
30
  * shows a subset of a larger result so names stay distinguishable across
@@ -32,16 +35,18 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
32
35
  * 'full' is the fixed wide layout every existing caller gets. 'auto' switches
33
36
  * to the compact layout (two ticks per axis, larger text, taller plot) when
34
37
  * the rendered chart is narrower than ANNOTATION_LABEL_MIN_WIDTH_PX;
35
- * 'compact' forces it.
38
+ * 'compact' forces it. 'dashboard' uses readable, medium-width axes and
39
+ * keeps text and plot height stable as its container resizes.
36
40
  */
37
- layout?: 'full' | 'auto' | 'compact'
41
+ layout?: 'full' | 'auto' | 'compact' | 'dashboard'
38
42
  }) {
39
43
  const svgRef = useRef<SVGSVGElement>(null)
40
44
  const wrapperRef = useRef<HTMLDivElement>(null)
41
45
  const [hoverX, setHoverX] = useState<number | null>(null)
42
46
  const [labelsFit, setLabelsFit] = useState(true)
47
+ const [renderedWidth, setRenderedWidth] = useState(0)
43
48
  const multiSeries = series.length > 1
44
- const compact = layout === 'compact' || (layout === 'auto' && !labelsFit)
49
+ const compact = layout === 'compact' || (layout === 'auto' && !labelsFit) || (layout === 'dashboard' && renderedWidth > 0 && renderedWidth < 300)
45
50
  const countAxis = isCountUnit(unit)
46
51
 
47
52
  const chartData = useMemo(() => {
@@ -91,14 +96,14 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
91
96
  // Layout constants. marginLeft sized for the widest expected Y-tick label
92
97
  // ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
93
98
  // need extra viewBox-space to survive the down-scale.
94
- const base = chartLayout(compact)
99
+ const base = chartLayout(compact, layout === 'dashboard', renderedWidth)
95
100
  const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
96
101
  const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
97
102
  const yLabels = yValues.map(val => formatMetricValue(val, unit))
98
103
  // The compact layout's larger text makes a label like "161.5 MiB" wider
99
104
  // than the fixed margin, and SVG clips it; grow the margin to the widest
100
105
  // label there. The full layout keeps its fixed margin for existing callers.
101
- const marginLeft = compact
106
+ const marginLeft = compact || layout === 'dashboard'
102
107
  ? Math.max(base.marginLeft, Math.ceil(Math.max(0, ...yLabels.map(label => label.length)) * fontSize * 0.62 + 12))
103
108
  : base.marginLeft
104
109
  const plotWidth = width - marginLeft - marginRight
@@ -128,10 +133,10 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
128
133
  const ts = minTs + ((maxTs - minTs) / xIntervals) * i
129
134
  // In the compact layout the two labels sit at the plot edges and would
130
135
  // otherwise spill past the viewBox.
131
- const anchor: 'start' | 'middle' | 'end' = !compact ? 'middle' : i === 0 ? 'start' : 'end'
136
+ const anchor: 'start' | 'middle' | 'end' = !(compact || layout === 'dashboard') ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
132
137
  return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
133
138
  })
134
- }, [chartData, xIntervals, compact])
139
+ }, [chartData, xIntervals, compact, layout, marginLeft, plotWidth])
135
140
 
136
141
  const paths = useMemo(() => {
137
142
  if (!chartData) return []
@@ -155,6 +160,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
155
160
  // rather than bridging across missing data or dropping to y=0. Each run
156
161
  // becomes its own path segment; a run needs >=2 points to render a line.
157
162
  let run: { x: number; y: number }[] = []
163
+ let previousTimestamp: number | undefined
158
164
  let runIdx = 0
159
165
  const flush = () => {
160
166
  if (run.length >= 2) {
@@ -177,15 +183,18 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
177
183
  for (const dp of s.dataPoints) {
178
184
  if (dp.value == null) {
179
185
  flush()
186
+ previousTimestamp = undefined
180
187
  continue
181
188
  }
189
+ if (stepSeconds !== undefined && previousTimestamp !== undefined && dp.timestamp - previousTimestamp > stepSeconds * 1.5) flush()
182
190
  run.push({ x: toX(dp.timestamp), y: toY(dp.value) })
191
+ previousTimestamp = dp.timestamp
183
192
  }
184
193
  flush()
185
194
  })
186
195
 
187
196
  return segments
188
- }, [chartData])
197
+ }, [chartData, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, fillColor, stepSeconds])
189
198
 
190
199
  const placedAnnotations = useMemo(() => {
191
200
  if (!chartData || !annotations?.length) return []
@@ -195,12 +204,16 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
195
204
  { minTs: chartData.minTs, maxTs: chartData.maxTs },
196
205
  { left: marginLeft, right: width - marginRight },
197
206
  )
198
- }, [chartData, annotations])
207
+ }, [chartData, annotations, width, marginLeft, marginRight])
199
208
 
200
209
  useEffect(() => {
201
210
  const el = wrapperRef.current
202
- if (!el || (placedAnnotations.length === 0 && layout !== 'auto') || typeof ResizeObserver === 'undefined') return
203
- const update = () => setLabelsFit(el.getBoundingClientRect().width >= ANNOTATION_LABEL_MIN_WIDTH_PX)
211
+ if (!el || (placedAnnotations.length === 0 && layout !== 'auto' && layout !== 'dashboard') || typeof ResizeObserver === 'undefined') return
212
+ const update = () => {
213
+ const measuredWidth = el.getBoundingClientRect().width
214
+ setLabelsFit(measuredWidth >= ANNOTATION_LABEL_MIN_WIDTH_PX)
215
+ if (layout === 'dashboard') setRenderedWidth(measuredWidth)
216
+ }
204
217
  update()
205
218
  const observer = new ResizeObserver(update)
206
219
  observer.observe(el)
@@ -236,18 +249,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
236
249
  if (ts < seriesMin - tolerance || ts > seriesMax + tolerance) {
237
250
  return null
238
251
  }
239
- // Only snap to finite points — a gap (undefined value) has no value to
240
- // show and would render NaN in the tooltip / a dot at y=NaN.
241
- let closest: { timestamp: number; value: number } | null = null
242
- let closestDist = Infinity
243
- for (const dp of dps) {
244
- if (dp.value == null) continue
245
- const dist = Math.abs(dp.timestamp - ts)
246
- if (dist < closestDist) {
247
- closestDist = dist
248
- closest = { timestamp: dp.timestamp, value: dp.value }
249
- }
250
- }
252
+ const closest = nearestSample(dps, ts, stepSeconds)
251
253
  if (!closest) return null
252
254
  return {
253
255
  label: shortLabels[vi],
@@ -263,7 +265,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
263
265
  )
264
266
 
265
267
  return { ts, x: clampedX, points, nearbyAnnotations }
266
- }, [hoverX, chartData, placedAnnotations, seriesLabels])
268
+ }, [hoverX, chartData, placedAnnotations, seriesLabels, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, stepSeconds])
267
269
 
268
270
  const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
269
271
  const svg = svgRef.current
@@ -286,6 +288,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
286
288
  ref={svgRef}
287
289
  viewBox={`0 0 ${width} ${height}`}
288
290
  className="w-full h-full"
291
+ style={layout === 'dashboard' ? { height: 240 } : undefined}
289
292
  preserveAspectRatio="xMidYMid meet"
290
293
  data-chart-layout={compact ? 'compact' : 'full'}
291
294
  >
@@ -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";
@@ -157,6 +159,8 @@ export interface PrometheusChartsViewProps {
157
159
  statusError?: string;
158
160
  onConnect: () => void;
159
161
  connecting: boolean;
162
+ /** Discovery is running server-side (not triggered by this view); show progress, not the CTA. */
163
+ discovering?: boolean;
160
164
  // Selection (controlled by host)
161
165
  category: PrometheusMetricCategory;
162
166
  onCategoryChange: (c: PrometheusMetricCategory) => void;
@@ -177,6 +181,7 @@ export function PrometheusChartsView({
177
181
  statusError,
178
182
  onConnect,
179
183
  connecting,
184
+ discovering = false,
180
185
  category,
181
186
  onCategoryChange,
182
187
  range,
@@ -208,6 +213,15 @@ export function PrometheusChartsView({
208
213
  if (!metricsLoading && !metricsError && !series.length) return null;
209
214
  }
210
215
 
216
+ if (!isConnected && discovering) {
217
+ return (
218
+ <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
219
+ <Loader2 className="mr-2 h-5 w-5 animate-spin" />
220
+ Discovering Prometheus…
221
+ </div>
222
+ );
223
+ }
224
+
211
225
  if (!isConnected) {
212
226
  return (
213
227
  <div className="flex flex-col items-center justify-center gap-4 py-12">
@@ -327,19 +341,36 @@ export function PrometheusChartsView({
327
341
  {metrics.hint}
328
342
  </p>
329
343
  )}
330
- {metrics?.query && (
331
- <details className="mt-3 w-full max-w-lg text-left">
332
- <summary className="cursor-pointer text-xs text-theme-text-quaternary hover:text-theme-text-tertiary">
333
- Diagnostics: show PromQL query
334
- </summary>
335
- <div className="mt-2 break-all rounded border border-theme-border bg-theme-base p-2 font-mono text-xs text-theme-text-secondary">
336
- {metrics.query}
337
- </div>
338
- </details>
339
- )}
344
+ {metrics?.query && <QueryDiagnostics query={metrics.query} />}
340
345
  </div>
341
346
  )}
342
347
  </div>
343
348
  </div>
344
349
  );
345
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
+ }
@@ -29,4 +29,20 @@ describe('integer axes', () => {
29
29
  expect(compact.fontSize).toBeGreaterThan(full.fontSize)
30
30
  expect(compact.height / compact.width).toBeGreaterThan(full.height / full.width)
31
31
  })
32
+
33
+ it('keeps dashboard axes readable without changing existing layouts', () => {
34
+ const dashboard = chartLayout(false, true)
35
+ expect([dashboard.width, dashboard.height, dashboard.fontSize]).toEqual([600, 240, 14])
36
+ expect(chartLayout(true, true)).toEqual(chartLayout(true))
37
+ expect(chartLayout(false).width).toBe(1000)
38
+ })
39
+ it('fits dashboard plots to their container without scaling text or height', () => {
40
+ for (const width of [280, 380, 550, 1050]) {
41
+ const layout = chartLayout(width < 420, true, width)
42
+ expect(layout.width).toBe(width)
43
+ expect(layout.height).toBe(240)
44
+ expect(layout.fontSize).toBe(12)
45
+ }
46
+ expect(chartLayout(false, false, 500)).toEqual(chartLayout(false))
47
+ })
32
48
  })
@@ -18,7 +18,9 @@ export interface ChartLayout {
18
18
  // The compact layout trades tick density for legible text: a 400-unit
19
19
  // viewBox scaled into a ~280px pane keeps 14-unit text near 10px, where the
20
20
  // full layout's 11-unit text in a 1000-unit viewBox would be 3px.
21
- export function chartLayout(compact: boolean): ChartLayout {
21
+ export function chartLayout(compact: boolean, dashboard = false, renderedWidth = 0): ChartLayout {
22
+ if (dashboard && renderedWidth > 0) return { width: renderedWidth, height: 240, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 12, yIntervals: compact ? 1 : 2, xIntervals: compact ? 1 : renderedWidth >= 760 ? 4 : 2 }
23
+ if (dashboard && !compact) return { width: 600, height: 240, marginLeft: 90, marginRight: 24, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 2, xIntervals: 2 }
22
24
  return compact
23
25
  ? { width: 400, height: 260, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 1, xIntervals: 1 }
24
26
  : { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
@@ -7,6 +7,10 @@ export function formatMetricValue(value: number, unit: string): string {
7
7
  if (value < 0) return `-${formatMetricValue(-value, unit)}`
8
8
 
9
9
  switch (unit) {
10
+ case 'percent':
11
+ return `${value.toFixed(value < 1 ? 2 : 1)}%`
12
+ case 'requests/s':
13
+ return `${value.toFixed(value < 1 ? 3 : 1)} req/s`
10
14
  case 'cores': {
11
15
  if (value < 0.0001) return '< 0.1m'
12
16
  if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nearestSample } from './nearestSample'
3
+
4
+ const points = [
5
+ { timestamp: 0, value: 0 },
6
+ { timestamp: 15, value: 2 },
7
+ { timestamp: 105, value: 3 },
8
+ { timestamp: 120, value: 4 },
9
+ ]
10
+
11
+ describe('nearestSample', () => {
12
+ it('withholds hover values inside missing evaluations, including a single missing slot', () => {
13
+ expect(nearestSample(points, 60, 15)).toBeNull()
14
+ expect(nearestSample([{ timestamp: 0, value: 1 }, { timestamp: 30, value: 2 }], 15, 15)).toBeNull()
15
+ expect(nearestSample([...points, { timestamp: 135, value: null }], 135, 15)).toBeNull()
16
+ })
17
+
18
+ it('keeps measured zero and only snaps within half an evaluation interval', () => {
19
+ expect(nearestSample(points, 0, 15)).toEqual(points[0])
20
+ expect(nearestSample(points, 110, 15)).toEqual(points[2])
21
+ expect(nearestSample(points, -8, 15)).toBeNull()
22
+ expect(nearestSample(points, 128, 15)).toBeNull()
23
+ })
24
+
25
+ it('preserves unrestricted nearest-finite behavior for generic charts', () => {
26
+ expect(nearestSample(points, 60)).toEqual(points[1])
27
+ expect(nearestSample([...points, { timestamp: 135, value: null }], 135)).toEqual(points[3])
28
+ expect(nearestSample([], 0)).toBeNull()
29
+ expect(nearestSample([{ timestamp: 0, value: null }], 0)).toBeNull()
30
+ })
31
+ })
@@ -0,0 +1,15 @@
1
+ import type { TimeSeriesPoint } from './types'
2
+
3
+ export function nearestSample(points: TimeSeriesPoint[], timestamp: number, stepSeconds?: number): { timestamp: number; value: number } | null {
4
+ let closest: { timestamp: number; value: number } | null = null
5
+ let distance = Infinity
6
+ for (const point of points) {
7
+ if (point.value == null) continue
8
+ const candidateDistance = Math.abs(point.timestamp - timestamp)
9
+ if (candidateDistance < distance) {
10
+ distance = candidateDistance
11
+ closest = { timestamp: point.timestamp, value: point.value }
12
+ }
13
+ }
14
+ return stepSeconds !== undefined && distance > stepSeconds / 2 ? null : closest
15
+ }
@@ -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'
4
- import { CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
3
+ import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
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
 
@@ -56,6 +59,9 @@ export interface ChecksViewProps {
56
59
  catalog: Record<string, CheckMeta>
57
60
  /** True when at least one source returned audit data. */
58
61
  anyData: boolean
62
+ /** Evaluations and unavailable inputs for the scope supplied by the host. */
63
+ evaluated?: number
64
+ missingInputs?: string[]
59
65
  /** Deep-link href for a resource (host routing). Omit for non-link text. */
60
66
  resourceHref?: (ref: CheckResourceRef) => string
61
67
  /** In-app resource navigation (client-side). Takes precedence over href. */
@@ -106,7 +112,7 @@ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
106
112
  q: { param: 'q', type: 'text' },
107
113
  })
108
114
 
109
- export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
115
+ export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
110
116
  // Severity / category / framework / search live in the URL (shareable,
111
117
  // bookmarkable audit links) via the shared filter-state contract. The cluster
112
118
  // facet is deliberately NOT here — it's a host-controlled seam (see
@@ -345,6 +351,14 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
345
351
  </div>
346
352
  </div>
347
353
 
354
+ {missingInputs.length > 0 && (
355
+ <AlertBanner
356
+ variant="warning"
357
+ title="Some checks could not run"
358
+ message={<>Findings cover only available inputs. Unavailable inputs: {missingInputs.join(', ')}.</>}
359
+ />
360
+ )}
361
+
348
362
  {fleetChecks.length === 0 ? (
349
363
  hasFilters ? (
350
364
  <EmptyState
@@ -362,6 +376,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
362
376
  </button>
363
377
  }
364
378
  />
379
+ ) : missingInputs.length > 0 ? (
380
+ <EmptyState
381
+ headline="No findings in the available data"
382
+ body="The audit is incomplete. This does not mean all checks passed."
383
+ />
384
+ ) : evaluated === 0 ? (
385
+ <EmptyState headline="No resources evaluated" body="No resources were evaluated with the current scope and check settings." />
365
386
  ) : anyData ? (
366
387
  <EmptyState
367
388
  tone="healthy"
@@ -519,6 +540,7 @@ export function CheckCardShell({
519
540
  const Container = as
520
541
  const sev = normalizeCheckSeverity(severity)
521
542
  const SeverityIcon = CHECK_SEVERITY_ICON[sev]
543
+ const { panelId, buttonProps } = useDisclosure(open)
522
544
  return (
523
545
  <Container
524
546
  className={[
@@ -539,9 +561,9 @@ export function CheckCardShell({
539
561
  open/closed. Collapsed: neutral row + rail. Expanded: severity-tinted
540
562
  band + solid pill — the tint is a focus signal, not per-row alarm. */}
541
563
  <div
564
+ {...buttonProps}
542
565
  role="button"
543
566
  tabIndex={0}
544
- aria-expanded={open}
545
567
  onClick={onToggle}
546
568
  onKeyDown={(e) => {
547
569
  if (e.target !== e.currentTarget) return
@@ -567,18 +589,15 @@ export function CheckCardShell({
567
589
  {SEVERITY_LABEL[sev]}
568
590
  </span>
569
591
  {renderActions?.()}
570
- <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" />
571
593
  </div>
572
594
 
573
- {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
574
- collapse too, matching IssueRow; inert when closed so SR + tab skip
575
- 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
576
597
  panel) so its text keeps enough contrast. */}
577
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
578
- <div className="overflow-hidden" inert={!open || undefined}>
579
- <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>
580
- </div>
581
- </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>
582
601
  </Container>
583
602
  )
584
603
  }
@@ -613,6 +632,9 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
613
632
  const [showAllClusters, setShowAllClusters] = useState(false)
614
633
  const shown = showAllClusters ? groups : groups.slice(0, clusterCap)
615
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()
616
638
 
617
639
  return (
618
640
  <section className="flex flex-col gap-1.5">
@@ -625,6 +647,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
625
647
  <button
626
648
  type="button"
627
649
  aria-expanded={isOpen}
650
+ aria-controls={disclosurePanelId(panelBase, group.id)}
628
651
  onClick={() =>
629
652
  setOpenClusters((prev) => {
630
653
  const next = new Set(prev)
@@ -635,7 +658,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
635
658
  }
636
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"
637
660
  >
638
- <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" />
639
662
  <span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">{group.label}</span>
640
663
  {group.environment && (
641
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">
@@ -645,11 +668,9 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
645
668
  <span className="flex-1" />
646
669
  <span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">{group.count}</span>
647
670
  </button>
648
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
649
- <div className="overflow-hidden" inert={!isOpen || undefined}>
650
- <div className="px-2.5 pb-2 pl-7">{renderGroupBody(group)}</div>
651
- </div>
652
- </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>
653
674
  </li>
654
675
  )
655
676
  })}
@@ -888,6 +909,10 @@ function ClusterFilter({
888
909
  onClear: () => void
889
910
  }) {
890
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'))
891
916
  const btnRef = useRef<HTMLButtonElement>(null)
892
917
  useEffect(() => {
893
918
  if (!menu) return
@@ -915,9 +940,13 @@ function ClusterFilter({
915
940
  return
916
941
  }
917
942
  const r = btnRef.current?.getBoundingClientRect()
918
- 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
+ }
919
947
  }
920
948
  const active = selected.size > 0
949
+ const placement = menu ?? placementRef.current
921
950
 
922
951
  return (
923
952
  <>
@@ -935,14 +964,20 @@ function ClusterFilter({
935
964
  Clusters
936
965
  <ChevronDown className="h-3 w-3" />
937
966
  </button>
938
- {menu &&
967
+ {shouldRender && placement &&
939
968
  createPortal(
940
969
  <div
941
970
  data-cluster-menu
942
971
  role="listbox"
943
972
  aria-multiselectable
944
- className="fixed z-[60] max-h-80 w-64 overflow-auto rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
945
- 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') }}
946
981
  >
947
982
  {active && (
948
983
  <button
@@ -985,6 +1020,9 @@ function ClusterFilter({
985
1020
  // it; position captured at open time, any scroll/resize closes it.
986
1021
  function RowMenu({ items }: { items: { label: string; onClick: () => void }[] }) {
987
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'))
988
1026
  const btnRef = useRef<HTMLButtonElement>(null)
989
1027
 
990
1028
  useEffect(() => {
@@ -1011,8 +1049,12 @@ function RowMenu({ items }: { items: { label: string; onClick: () => void }[] })
1011
1049
  return
1012
1050
  }
1013
1051
  const r = btnRef.current?.getBoundingClientRect()
1014
- 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
+ }
1015
1056
  }
1057
+ const placement = menu ?? placementRef.current
1016
1058
 
1017
1059
  return (
1018
1060
  <>
@@ -1028,12 +1070,18 @@ function RowMenu({ items }: { items: { label: string; onClick: () => void }[] })
1028
1070
  >
1029
1071
  <MoreHorizontal className="h-4 w-4" />
1030
1072
  </button>
1031
- {menu &&
1073
+ {shouldRender && placement &&
1032
1074
  createPortal(
1033
1075
  <div
1034
1076
  role="menu"
1035
- className="fixed z-[60] min-w-48 rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
1036
- 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') }}
1037
1085
  onClick={(e) => e.stopPropagation()}
1038
1086
  >
1039
1087
  {items.map((it, i) => (