@skyhook-io/k8s-ui 1.14.5 → 1.14.6

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 (67) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +13 -0
  5. package/src/components/charts/AreaChart.tsx +28 -25
  6. package/src/components/charts/PrometheusChartsView.tsx +12 -0
  7. package/src/components/charts/axis.test.ts +16 -0
  8. package/src/components/charts/axis.ts +3 -1
  9. package/src/components/charts/format.ts +4 -0
  10. package/src/components/charts/nearestSample.test.ts +31 -0
  11. package/src/components/charts/nearestSample.ts +15 -0
  12. package/src/components/checks/ChecksView.tsx +20 -2
  13. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  14. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  15. package/src/components/gitops/health-provenance.test.ts +54 -0
  16. package/src/components/gitops/health-provenance.ts +24 -0
  17. package/src/components/gitops/index.ts +2 -0
  18. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  19. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  20. package/src/components/gitops/tree/merge.test.ts +34 -0
  21. package/src/components/gitops/tree/merge.ts +7 -0
  22. package/src/components/resources/ResourcesView.tsx +67 -2
  23. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  24. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  25. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  26. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  27. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  28. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  29. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  30. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  31. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  32. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  33. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  34. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  35. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  36. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  37. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  38. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  40. package/src/components/resources/renderers/index.ts +3 -0
  41. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  42. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  43. package/src/components/resources/resource-utils.ts +18 -9
  44. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  45. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  46. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  47. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  50. package/src/components/shared/index.ts +4 -0
  51. package/src/components/ui/FetchResult.test.tsx +20 -2
  52. package/src/components/ui/FetchResult.tsx +33 -3
  53. package/src/components/ui/FreshnessControl.tsx +8 -2
  54. package/src/components/workload/WorkloadView.tsx +2 -2
  55. package/src/theme/components.css +16 -0
  56. package/src/types/core.ts +15 -1
  57. package/src/types/gitops-insights.ts +23 -0
  58. package/src/types/gitops-tree.ts +29 -0
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/navigation.test.ts +20 -1
  61. package/src/utils/navigation.ts +25 -0
  62. package/src/utils/network-policy.test.ts +42 -0
  63. package/src/utils/network-policy.ts +59 -0
  64. package/src/utils/reflector.ts +139 -0
  65. package/src/utils/yaml-file-import.test.ts +189 -0
  66. package/src/utils/yaml-file-import.ts +95 -0
  67. package/src/components/logs/JsonLogLine.tsx +0 -93
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.5",
3
+ "version": "1.14.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -89,8 +89,8 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@types/node": "^26.2.0",
92
- "@types/react": "^19.2.18",
93
- "@types/react-dom": "^19.2.3",
92
+ "@types/react": "^19.3.0",
93
+ "@types/react-dom": "^19.3.0",
94
94
  "@xterm/addon-fit": "^0.11.0",
95
95
  "@xterm/addon-web-links": "^0.12.0",
96
96
  "@xterm/xterm": "^6.0.0",
@@ -100,8 +100,8 @@
100
100
  "elkjs": "^0.11.1",
101
101
  "lucide-react": "^1.37.0",
102
102
  "monaco-editor": "0.55.1",
103
- "react": "^19.2.8",
104
- "react-dom": "^19.2.8",
103
+ "react": "^19.3.0",
104
+ "react-dom": "^19.3.0",
105
105
  "typescript": "^6.0.2",
106
106
  "vitest": "^4.1.11",
107
107
  "yaml": "^2.9.0"
@@ -1,9 +1,10 @@
1
- import { ClipboardCheck, ArrowRight, Check } from 'lucide-react'
1
+ import { ClipboardCheck, ArrowRight, Check, AlertTriangle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { SEVERITY_TEXT, SEVERITY_DOT } from '../../utils/badge-colors'
4
4
  import { SEVERITY_FILL_CLASS, SEVERITY_TEXT_CLASS } from '../checks/severity'
5
5
 
6
6
  export interface AuditCardData {
7
+ missingInputs?: string[]
7
8
  passing: number
8
9
  /** Raw compatibility counts; warning renders as Medium and danger as High. */
9
10
  warning: number
@@ -17,16 +18,17 @@ interface AuditCardProps {
17
18
  onNavigate: () => void
18
19
  }
19
20
 
20
- type SeverityLevel = 'success' | 'high' | 'medium'
21
+ type SeverityLevel = 'success' | 'high' | 'medium' | 'neutral'
21
22
 
22
23
  function getSeverityLevel(data: AuditCardData): SeverityLevel {
23
- if (data.warning + data.danger === 0) return 'success'
24
+ if (data.warning + data.danger === 0) return data.missingInputs?.length || data.passing === 0 ? 'neutral' : 'success'
24
25
  const highRatio = data.danger / (data.warning + data.danger)
25
26
  if (highRatio > 0.2) return 'high'
26
27
  return 'medium'
27
28
  }
28
29
 
29
30
  const ACCENT_BG: Record<SeverityLevel, string> = {
31
+ neutral: 'bg-theme-elevated',
30
32
  success: 'bg-green-500/10',
31
33
  high: 'bg-orange-500/10',
32
34
  medium: 'bg-yellow-500/10',
@@ -35,9 +37,10 @@ const ACCENT_BG: Record<SeverityLevel, string> = {
35
37
  export function AuditCard({ data, onNavigate }: AuditCardProps) {
36
38
  const total = data.passing + data.warning + data.danger
37
39
  const issueCount = data.warning + data.danger
38
- const allPassing = issueCount === 0
40
+ const incomplete = (data.missingInputs?.length ?? 0) > 0
41
+ const allPassing = issueCount === 0 && total > 0 && !incomplete
39
42
  const level = getSeverityLevel(data)
40
- const accentColor = level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
43
+ const accentColor = level === 'neutral' ? 'text-theme-text-secondary' : level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
41
44
  const accentBg = ACCENT_BG[level]
42
45
 
43
46
  return (
@@ -56,7 +59,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
56
59
  </span>
57
60
  ) : (
58
61
  <span className={clsx('badge-sm', accentBg, accentColor)}>
59
- <Check className="w-3 h-3" />
62
+ {allPassing ? <Check className="w-3 h-3" /> : <ClipboardCheck className="w-3 h-3" />}
60
63
  </span>
61
64
  )}
62
65
  </div>
@@ -71,6 +74,12 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
71
74
  <span className="text-xs text-theme-text-tertiary">{total} checks across {Object.keys(data.categories).length} categories</span>
72
75
  )}
73
76
  </div>
77
+ ) : issueCount === 0 ? (
78
+ <div className="flex flex-col items-center gap-2 text-center">
79
+ {incomplete ? <AlertTriangle className="w-8 h-8 text-warning-text" /> : <ClipboardCheck className="w-8 h-8 text-theme-text-tertiary" />}
80
+ <span className="text-sm font-medium text-theme-text-primary">{incomplete ? 'Some checks could not run' : 'No resources evaluated'}</span>
81
+ <span className="text-xs text-theme-text-secondary">{incomplete ? 'No findings in the available data.' : 'Check the selected scope and check settings.'}</span>
82
+ </div>
74
83
  ) : (
75
84
  <>
76
85
  {/* Distribution bar — only show when we have passing data for context */}
@@ -107,7 +116,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
107
116
  )}
108
117
  </div>
109
118
  ) : (
110
- <span className={clsx('text-xs font-semibold', SEVERITY_TEXT.success)}>All passing</span>
119
+ <span className={clsx('text-xs font-semibold', incomplete ? 'text-theme-text-secondary' : SEVERITY_TEXT.success)}>{incomplete ? 'No findings' : 'All passing'}</span>
111
120
  )}
112
121
  </div>
113
122
  )
@@ -117,6 +126,8 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
117
126
  )}
118
127
  </div>
119
128
 
129
+ {incomplete && issueCount > 0 && <p className="px-4 pb-2 text-xs text-warning-text">Some checks could not run.</p>}
130
+
120
131
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end">
121
132
  <span className={clsx(
122
133
  'flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors',
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { AuditCard } from './AuditCard'
4
+ import { ChecksView } from '../checks/ChecksView'
5
+ import { FilterLocationProvider } from '../../filter-state'
6
+
7
+ describe('audit coverage presentation', () => {
8
+ for (const [passing, missingInputs, expected] of [
9
+ [0, [], 'No resources evaluated'],
10
+ [0, ['secrets'], 'Some checks could not run'],
11
+ [4, ['secrets'], 'Some checks could not run'],
12
+ [4, [], 'All checks passing'],
13
+ ] as const) {
14
+ it(`shows ${expected} with ${passing} passing and ${missingInputs.length} unavailable inputs`, () => {
15
+ const html = renderToString(<AuditCard data={{ passing, missingInputs: [...missingInputs], warning: 0, danger: 0, categories: {} }} onNavigate={() => {}} />)
16
+ expect(html).toContain(expected)
17
+ if (missingInputs.length || passing === 0) expect(html).not.toContain('All checks passing')
18
+ })
19
+ }
20
+ it('keeps a coverage warning alongside real findings', () => {
21
+ const html = renderToString(<AuditCard data={{ passing: 1, missingInputs: ['secrets'], warning: 1, danger: 0, categories: { Security: { passing: 1, warning: 0, danger: 0 } } }} onNavigate={() => {}} />)
22
+ expect(html).toContain('Some checks could not run')
23
+ expect(html).not.toContain('All passing')
24
+ })
25
+ for (const [evaluated, missingInputs, expected] of [
26
+ [0, [], 'No resources evaluated'],
27
+ [0, ['secrets'], 'No findings in the available data'],
28
+ [4, ['secrets'], 'No findings in the available data'],
29
+ [4, [], 'Every audited resource passed'],
30
+ ] as const) {
31
+ it(`Checks: ${expected}`, () => {
32
+ const html = renderToString(
33
+ <FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
34
+ <ChecksView checks={[]} catalog={{}} anyData evaluated={evaluated} missingInputs={[...missingInputs]} />
35
+ </FilterLocationProvider>,
36
+ )
37
+ expect(html).toContain(expected)
38
+ if (missingInputs.length) {
39
+ expect(html).toContain('Unavailable inputs:')
40
+ expect(html).toContain('secrets')
41
+ expect(html).not.toContain('Every audited resource passed')
42
+ }
43
+ })
44
+ }
45
+ })
@@ -102,6 +102,19 @@ describe('AreaChart domain and axes', () => {
102
102
  expect(attrs(render({ series: [series([[0, 1], [3600, 5]])] }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(1)
103
103
  })
104
104
 
105
+ it('breaks omitted evaluations only when the caller supplies the interval', () => {
106
+ const sparse = [series([[0, 1], [15, 2], [105, 3], [120, 4]])]
107
+ const paths = (stepSeconds?: number) => attrs(render({ series: sparse, stepSeconds }), 'path', 'fill').filter(fill => fill === 'none')
108
+ expect(paths(15)).toHaveLength(2)
109
+ expect(paths()).toHaveLength(1)
110
+ })
111
+
112
+ it('preserves explicit gaps, zeros and isolated-sample behavior with a known interval', () => {
113
+ const gaps = [series([[0, 0], [15, 0], [30, null], [45, 1], [60, 2]])]
114
+ expect(attrs(render({ series: gaps, stepSeconds: 15 }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(2)
115
+ expect(attrs(render({ series: [series([[0, 1]])], stepSeconds: 15 }), 'path', 'fill')).toHaveLength(0)
116
+ })
117
+
105
118
  it('renders nothing for an empty series list', () => {
106
119
  expect(render({ series: [] })).toBe('')
107
120
  })
@@ -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
  >
@@ -157,6 +157,8 @@ export interface PrometheusChartsViewProps {
157
157
  statusError?: string;
158
158
  onConnect: () => void;
159
159
  connecting: boolean;
160
+ /** Discovery is running server-side (not triggered by this view); show progress, not the CTA. */
161
+ discovering?: boolean;
160
162
  // Selection (controlled by host)
161
163
  category: PrometheusMetricCategory;
162
164
  onCategoryChange: (c: PrometheusMetricCategory) => void;
@@ -177,6 +179,7 @@ export function PrometheusChartsView({
177
179
  statusError,
178
180
  onConnect,
179
181
  connecting,
182
+ discovering = false,
180
183
  category,
181
184
  onCategoryChange,
182
185
  range,
@@ -208,6 +211,15 @@ export function PrometheusChartsView({
208
211
  if (!metricsLoading && !metricsError && !series.length) return null;
209
212
  }
210
213
 
214
+ if (!isConnected && discovering) {
215
+ return (
216
+ <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
217
+ <Loader2 className="mr-2 h-5 w-5 animate-spin" />
218
+ Discovering Prometheus…
219
+ </div>
220
+ );
221
+ }
222
+
211
223
  if (!isConnected) {
212
224
  return (
213
225
  <div className="flex flex-col items-center justify-center gap-4 py-12">
@@ -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,7 @@
1
1
  import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
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'
4
+ import { AlertBanner, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
5
5
  import { useFilterState, defineFilterSchema } from '../../filter-state'
6
6
  import type { CheckMeta, CheckReference } from '../audit'
7
7
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
@@ -56,6 +56,9 @@ export interface ChecksViewProps {
56
56
  catalog: Record<string, CheckMeta>
57
57
  /** True when at least one source returned audit data. */
58
58
  anyData: boolean
59
+ /** Evaluations and unavailable inputs for the scope supplied by the host. */
60
+ evaluated?: number
61
+ missingInputs?: string[]
59
62
  /** Deep-link href for a resource (host routing). Omit for non-link text. */
60
63
  resourceHref?: (ref: CheckResourceRef) => string
61
64
  /** In-app resource navigation (client-side). Takes precedence over href. */
@@ -106,7 +109,7 @@ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
106
109
  q: { param: 'q', type: 'text' },
107
110
  })
108
111
 
109
- export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
112
+ export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
110
113
  // Severity / category / framework / search live in the URL (shareable,
111
114
  // bookmarkable audit links) via the shared filter-state contract. The cluster
112
115
  // facet is deliberately NOT here — it's a host-controlled seam (see
@@ -345,6 +348,14 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
345
348
  </div>
346
349
  </div>
347
350
 
351
+ {missingInputs.length > 0 && (
352
+ <AlertBanner
353
+ variant="warning"
354
+ title="Some checks could not run"
355
+ message={<>Findings cover only available inputs. Unavailable inputs: {missingInputs.join(', ')}.</>}
356
+ />
357
+ )}
358
+
348
359
  {fleetChecks.length === 0 ? (
349
360
  hasFilters ? (
350
361
  <EmptyState
@@ -362,6 +373,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
362
373
  </button>
363
374
  }
364
375
  />
376
+ ) : missingInputs.length > 0 ? (
377
+ <EmptyState
378
+ headline="No findings in the available data"
379
+ body="The audit is incomplete. This does not mean all checks passed."
380
+ />
381
+ ) : evaluated === 0 ? (
382
+ <EmptyState headline="No resources evaluated" body="No resources were evaluated with the current scope and check settings." />
365
383
  ) : anyData ? (
366
384
  <EmptyState
367
385
  tone="healthy"
@@ -4,6 +4,7 @@ import { PaneLoader } from '../ui/PaneLoader'
4
4
 
5
5
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
6
6
  import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
7
+ import { GitOpsHealthSourceNotice } from './GitOpsHealthSourceNotice'
7
8
  import { Tooltip } from '../ui/Tooltip'
8
9
  import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
9
10
 
@@ -138,6 +139,14 @@ export interface GitOpsDetailLayoutProps {
138
139
  // on the OSS-specific CodeViewer component.
139
140
  helmValuesContent?: ReactNode
140
141
 
142
+ // GitOpsHealthSourceNotice inputs: where its "Why?" link goes, and what
143
+ // (if anything) to say after the remote-destination sentence. Both are
144
+ // host decisions; the notice itself keys off `insight.summary`.
145
+ healthDocsUrl?: string
146
+ remoteDestinationHint?: ReactNode
147
+ // Opens the host's Argo CD settings, when it has such a place.
148
+ onOpenSettings?: () => void
149
+
141
150
  // Action buttons — Argo + Flux. Exactly one applies for any given CR.
142
151
  isArgoApp: boolean
143
152
  isFlux: boolean
@@ -222,6 +231,9 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
222
231
  helmValuesOpen,
223
232
  onToggleHelmValues,
224
233
  helmValuesContent,
234
+ healthDocsUrl,
235
+ remoteDestinationHint,
236
+ onOpenSettings,
225
237
  isArgoApp,
226
238
  isFlux,
227
239
  isFluxWorkload,
@@ -458,6 +470,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
458
470
  {!fullscreen && (
459
471
  <>
460
472
  <GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} renderRevisionMeta={renderRevisionMeta} />
473
+ <GitOpsHealthSourceNotice summary={insight?.summary} changes={insight?.changes} docsUrl={healthDocsUrl} remoteDestinationHint={remoteDestinationHint} onOpenSettings={onOpenSettings} />
461
474
  <GitOpsIssuesBand
462
475
  issues={insight?.issues}
463
476
  terminating={terminating}