@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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +41 -10
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +76 -28
- package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +237 -14
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +8 -12
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +20 -1
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/animation.ts +93 -13
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- 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 = () =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
<
|
|
592
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
571
593
|
</div>
|
|
572
594
|
|
|
573
|
-
{/* Kept mounted (not `open &&`) so the
|
|
574
|
-
|
|
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
|
-
<
|
|
578
|
-
<div className="
|
|
579
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
649
|
-
<div className="
|
|
650
|
-
|
|
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)
|
|
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
|
-
{
|
|
967
|
+
{shouldRender && placement &&
|
|
939
968
|
createPortal(
|
|
940
969
|
<div
|
|
941
970
|
data-cluster-menu
|
|
942
971
|
role="listbox"
|
|
943
972
|
aria-multiselectable
|
|
944
|
-
|
|
945
|
-
|
|
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)
|
|
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
|
-
{
|
|
1073
|
+
{shouldRender && placement &&
|
|
1032
1074
|
createPortal(
|
|
1033
1075
|
<div
|
|
1034
1076
|
role="menu"
|
|
1035
|
-
|
|
1036
|
-
|
|
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) => (
|