@skyhook-io/k8s-ui 1.14.4 → 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 (85) 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 +173 -0
  5. package/src/components/charts/AreaChart.tsx +208 -53
  6. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  7. package/src/components/charts/PrometheusChartsView.tsx +205 -76
  8. package/src/components/charts/SeriesLegend.tsx +8 -3
  9. package/src/components/charts/annotations.ts +59 -0
  10. package/src/components/charts/axis.test.ts +48 -0
  11. package/src/components/charts/axis.ts +62 -0
  12. package/src/components/charts/colors.test.ts +27 -0
  13. package/src/components/charts/colors.ts +23 -0
  14. package/src/components/charts/format.test.ts +19 -0
  15. package/src/components/charts/format.ts +21 -1
  16. package/src/components/charts/index.ts +3 -1
  17. package/src/components/charts/nearestSample.test.ts +31 -0
  18. package/src/components/charts/nearestSample.ts +15 -0
  19. package/src/components/charts/types.ts +12 -0
  20. package/src/components/checks/ChecksView.tsx +20 -2
  21. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  22. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  23. package/src/components/gitops/health-provenance.test.ts +54 -0
  24. package/src/components/gitops/health-provenance.ts +24 -0
  25. package/src/components/gitops/index.ts +2 -0
  26. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  28. package/src/components/gitops/tree/merge.test.ts +34 -0
  29. package/src/components/gitops/tree/merge.ts +7 -0
  30. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  31. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  32. package/src/components/resources/ResourcesView.tsx +67 -2
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/index.ts +7 -0
  35. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  36. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  37. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  38. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  39. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  40. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  41. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  42. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  45. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  46. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  47. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  48. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  49. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  50. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
  52. package/src/components/resources/renderers/index.ts +3 -0
  53. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  54. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  55. package/src/components/resources/resource-utils.ts +18 -9
  56. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  57. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  58. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  59. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  60. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  61. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  62. package/src/components/shared/index.ts +4 -0
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/ui/Badge.tsx +5 -1
  66. package/src/components/ui/FetchResult.test.tsx +20 -2
  67. package/src/components/ui/FetchResult.tsx +33 -3
  68. package/src/components/ui/FreshnessControl.tsx +8 -2
  69. package/src/components/ui/index.ts +5 -0
  70. package/src/components/ui/monacoRuntime.ts +14 -0
  71. package/src/components/workload/WorkloadView.tsx +2 -2
  72. package/src/monaco-deep.d.ts +10 -0
  73. package/src/theme/components.css +16 -0
  74. package/src/types/core.ts +38 -11
  75. package/src/types/gitops-insights.ts +23 -0
  76. package/src/types/gitops-tree.ts +29 -0
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/navigation.test.ts +20 -1
  79. package/src/utils/navigation.ts +25 -0
  80. package/src/utils/network-policy.test.ts +42 -0
  81. package/src/utils/network-policy.ts +59 -0
  82. package/src/utils/reflector.ts +139 -0
  83. package/src/utils/yaml-file-import.test.ts +189 -0
  84. package/src/utils/yaml-file-import.ts +95 -0
  85. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -1,19 +1,53 @@
1
- import { useCallback, useMemo, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type * as React from 'react'
3
- import { seriesColor, seriesFill, computeShortLabels } from './colors'
3
+ import { seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
4
4
  import { formatMetricValue, formatTimestamp } from './format'
5
- import type { TimeSeries, ReferenceLine } from './types'
6
-
7
- export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
5
+ import { layoutAnnotations } from './annotations'
6
+ import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
7
+ import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
8
+ import { nearestSample } from './nearestSample'
9
+
10
+ // Below this rendered width the annotation label pills would cover most of the
11
+ // plot once the 1000-unit viewBox is scaled down; the lines stay and the label
12
+ // text moves into the hover tooltip.
13
+ export const ANNOTATION_LABEL_MIN_WIDTH_PX = 420
14
+ const ANNOTATION_HOVER_TOLERANCE = 8
15
+
16
+ export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full' }: {
8
17
  series: TimeSeries[]
9
18
  color: string
10
19
  fillColor: string
11
20
  unit: string
12
21
  referenceLines?: ReferenceLine[]
22
+ /** Vertical markers; only those inside the X domain render. */
23
+ annotations?: ChartAnnotation[]
24
+ /** X axis window in unix seconds. Defaults to the sample extent. */
25
+ domain?: { start: number; end: number }
26
+ /** Expected positive sample interval; omitted evaluations break paths and cannot supply hover values. */
27
+ stepSeconds?: number
28
+ /**
29
+ * Display name per series, parallel to `series`. Pass it when the chart
30
+ * shows a subset of a larger result so names stay distinguishable across
31
+ * the whole result; defaults to names derived from the labels given.
32
+ */
33
+ seriesLabels?: string[]
34
+ /**
35
+ * 'full' is the fixed wide layout every existing caller gets. 'auto' switches
36
+ * to the compact layout (two ticks per axis, larger text, taller plot) when
37
+ * the rendered chart is narrower than ANNOTATION_LABEL_MIN_WIDTH_PX;
38
+ * 'compact' forces it. 'dashboard' uses readable, medium-width axes and
39
+ * keeps text and plot height stable as its container resizes.
40
+ */
41
+ layout?: 'full' | 'auto' | 'compact' | 'dashboard'
13
42
  }) {
14
43
  const svgRef = useRef<SVGSVGElement>(null)
44
+ const wrapperRef = useRef<HTMLDivElement>(null)
15
45
  const [hoverX, setHoverX] = useState<number | null>(null)
46
+ const [labelsFit, setLabelsFit] = useState(true)
47
+ const [renderedWidth, setRenderedWidth] = useState(0)
16
48
  const multiSeries = series.length > 1
49
+ const compact = layout === 'compact' || (layout === 'auto' && !labelsFit) || (layout === 'dashboard' && renderedWidth > 0 && renderedWidth < 300)
50
+ const countAxis = isCountUnit(unit)
17
51
 
18
52
  const chartData = useMemo(() => {
19
53
  if (!series.length) return null
@@ -21,17 +55,23 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
21
55
  let minTs = Infinity
22
56
  let maxTs = -Infinity
23
57
  let maxVal = 0
58
+ let minVal = 0
24
59
 
25
60
  for (const s of series) {
26
61
  for (const dp of s.dataPoints) {
27
62
  if (dp.timestamp < minTs) minTs = dp.timestamp
28
63
  if (dp.timestamp > maxTs) maxTs = dp.timestamp
29
64
  if (dp.value != null && dp.value > maxVal) maxVal = dp.value
65
+ if (dp.value != null && dp.value < minVal) minVal = dp.value
30
66
  }
31
67
  }
32
68
 
69
+ if (domain && Number.isFinite(domain.start) && Number.isFinite(domain.end) && domain.end > domain.start) {
70
+ minTs = domain.start
71
+ maxTs = domain.end
72
+ }
33
73
  if (minTs === maxTs) maxTs = minTs + 60
34
- if (maxVal === 0) {
74
+ if (maxVal === 0 && minVal === 0) {
35
75
  // Unit-appropriate floor so the Y-axis isn't misleadingly large.
36
76
  maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
37
77
  }
@@ -44,21 +84,28 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
44
84
  }
45
85
  }
46
86
 
47
- const padding = maxVal * 0.1
48
- const yMax = maxVal + padding
87
+ const padding = Math.max(maxVal, -minVal) * 0.1
88
+ // A count axis ends on a whole step so its ticks are integers; other
89
+ // units keep headroom above the maximum.
90
+ const yMax = countAxis && minVal >= 0 ? integerAxisTop(maxVal, compact ? 1 : 4) : maxVal + padding
91
+ const yMin = minVal < 0 ? minVal - padding : 0
49
92
 
50
- return { minTs, maxTs, yMax, series }
51
- }, [series, unit, referenceLines])
93
+ return { minTs, maxTs, yMax, yMin, series }
94
+ }, [series, unit, referenceLines, domain, countAxis, compact])
52
95
 
53
96
  // Layout constants. marginLeft sized for the widest expected Y-tick label
54
97
  // ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
55
98
  // need extra viewBox-space to survive the down-scale.
56
- const width = 1000
57
- const height = 300
58
- const marginLeft = 84
59
- const marginRight = 40
60
- const marginTop = 10
61
- const marginBottom = 30
99
+ const base = chartLayout(compact, layout === 'dashboard', renderedWidth)
100
+ const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
101
+ const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
102
+ const yLabels = yValues.map(val => formatMetricValue(val, unit))
103
+ // The compact layout's larger text makes a label like "161.5 MiB" wider
104
+ // than the fixed margin, and SVG clips it; grow the margin to the widest
105
+ // label there. The full layout keeps its fixed margin for existing callers.
106
+ const marginLeft = compact || layout === 'dashboard'
107
+ ? Math.max(base.marginLeft, Math.ceil(Math.max(0, ...yLabels.map(label => label.length)) * fontSize * 0.62 + 12))
108
+ : base.marginLeft
62
109
  const plotWidth = width - marginLeft - marginRight
63
110
  const plotHeight = height - marginTop - marginBottom
64
111
 
@@ -71,31 +118,31 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
71
118
  }
72
119
  const toY = (val: number) => {
73
120
  if (!chartData) return marginTop + plotHeight
74
- return marginTop + plotHeight - (val / chartData.yMax) * plotHeight
121
+ return marginTop + plotHeight - ((val - chartData.yMin) / (chartData.yMax - chartData.yMin)) * plotHeight
75
122
  }
76
123
 
77
124
  const yTicks = useMemo(() => {
78
125
  if (!chartData) return []
79
- const { yMax } = chartData
80
- const count = 4
81
- return Array.from({ length: count + 1 }, (_, i) => {
82
- const val = (yMax / count) * i
83
- return { val, y: toY(val), label: formatMetricValue(val, unit) }
84
- })
85
- }, [chartData, unit])
126
+ return yValues.map((val, i) => ({ val, y: toY(val), label: yLabels[i] }))
127
+ }, [chartData, yValues, yLabels])
86
128
 
87
129
  const xTicks = useMemo(() => {
88
130
  if (!chartData) return []
89
131
  const { minTs, maxTs } = chartData
90
- const count = 6
91
- return Array.from({ length: count + 1 }, (_, i) => {
92
- const ts = minTs + ((maxTs - minTs) / count) * i
93
- return { ts, x: toX(ts), label: formatTimestamp(ts) }
132
+ return Array.from({ length: xIntervals + 1 }, (_, i) => {
133
+ const ts = minTs + ((maxTs - minTs) / xIntervals) * i
134
+ // In the compact layout the two labels sit at the plot edges and would
135
+ // otherwise spill past the viewBox.
136
+ const anchor: 'start' | 'middle' | 'end' = !(compact || layout === 'dashboard') ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
137
+ return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
94
138
  })
95
- }, [chartData])
139
+ }, [chartData, xIntervals, compact, layout, marginLeft, plotWidth])
96
140
 
97
141
  const paths = useMemo(() => {
98
142
  if (!chartData) return []
143
+ // The area fills toward zero, not the plot floor, so a series that dips
144
+ // below zero is shaded on the correct side of the axis.
145
+ const baseline = toY(0)
99
146
  const segments: {
100
147
  linePath: string
101
148
  areaPath: string
@@ -113,13 +160,14 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
113
160
  // rather than bridging across missing data or dropping to y=0. Each run
114
161
  // becomes its own path segment; a run needs >=2 points to render a line.
115
162
  let run: { x: number; y: number }[] = []
163
+ let previousTimestamp: number | undefined
116
164
  let runIdx = 0
117
165
  const flush = () => {
118
166
  if (run.length >= 2) {
119
167
  const linePath = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
120
168
  const areaPath = linePath +
121
- ` L${run[run.length - 1].x},${marginTop + plotHeight}` +
122
- ` L${run[0].x},${marginTop + plotHeight} Z`
169
+ ` L${run[run.length - 1].x},${baseline}` +
170
+ ` L${run[0].x},${baseline} Z`
123
171
  segments.push({
124
172
  linePath,
125
173
  areaPath,
@@ -135,15 +183,42 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
135
183
  for (const dp of s.dataPoints) {
136
184
  if (dp.value == null) {
137
185
  flush()
186
+ previousTimestamp = undefined
138
187
  continue
139
188
  }
189
+ if (stepSeconds !== undefined && previousTimestamp !== undefined && dp.timestamp - previousTimestamp > stepSeconds * 1.5) flush()
140
190
  run.push({ x: toX(dp.timestamp), y: toY(dp.value) })
191
+ previousTimestamp = dp.timestamp
141
192
  }
142
193
  flush()
143
194
  })
144
195
 
145
196
  return segments
146
- }, [chartData])
197
+ }, [chartData, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, fillColor, stepSeconds])
198
+
199
+ const placedAnnotations = useMemo(() => {
200
+ if (!chartData || !annotations?.length) return []
201
+ return layoutAnnotations(
202
+ annotations,
203
+ toX,
204
+ { minTs: chartData.minTs, maxTs: chartData.maxTs },
205
+ { left: marginLeft, right: width - marginRight },
206
+ )
207
+ }, [chartData, annotations, width, marginLeft, marginRight])
208
+
209
+ useEffect(() => {
210
+ const el = wrapperRef.current
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
+ }
217
+ update()
218
+ const observer = new ResizeObserver(update)
219
+ observer.observe(el)
220
+ return () => observer.disconnect()
221
+ }, [placedAnnotations.length, layout])
147
222
 
148
223
  // Hover: only emit a tooltip row when the hovered timestamp lies within
149
224
  // the series' actual sample range (with 2× median-step tolerance). Without
@@ -159,9 +234,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
159
234
  .map((s, i) => ({ s, i }))
160
235
  .filter(({ s }) => s.dataPoints.filter(dp => dp.value != null).length >= 2)
161
236
 
162
- const fullLabels = validSeries.map(({ s, i }) =>
163
- s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
164
- )
237
+ const allLabels = seriesLabels ?? seriesDisplayLabels(chartData.series)
238
+ const fullLabels = validSeries.map(({ i }) => allLabels[i] ?? `series-${i}`)
165
239
  const shortLabels = computeShortLabels(fullLabels)
166
240
 
167
241
  const points = validSeries.map(({ s, i }, vi) => {
@@ -175,18 +249,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
175
249
  if (ts < seriesMin - tolerance || ts > seriesMax + tolerance) {
176
250
  return null
177
251
  }
178
- // Only snap to finite points — a gap (undefined value) has no value to
179
- // show and would render NaN in the tooltip / a dot at y=NaN.
180
- let closest: { timestamp: number; value: number } | null = null
181
- let closestDist = Infinity
182
- for (const dp of dps) {
183
- if (dp.value == null) continue
184
- const dist = Math.abs(dp.timestamp - ts)
185
- if (dist < closestDist) {
186
- closestDist = dist
187
- closest = { timestamp: dp.timestamp, value: dp.value }
188
- }
189
- }
252
+ const closest = nearestSample(dps, ts, stepSeconds)
190
253
  if (!closest) return null
191
254
  return {
192
255
  label: shortLabels[vi],
@@ -197,8 +260,12 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
197
260
  }
198
261
  }).filter((p): p is NonNullable<typeof p> => p !== null)
199
262
 
200
- return { ts, x: clampedX, points }
201
- }, [hoverX, chartData])
263
+ const nearbyAnnotations = placedAnnotations.filter(
264
+ p => Math.abs(p.x - clampedX) <= ANNOTATION_HOVER_TOLERANCE,
265
+ )
266
+
267
+ return { ts, x: clampedX, points, nearbyAnnotations }
268
+ }, [hoverX, chartData, placedAnnotations, seriesLabels, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, stepSeconds])
202
269
 
203
270
  const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
204
271
  const svg = svgRef.current
@@ -212,13 +279,18 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
212
279
  // every hook has been invoked (Rules of Hooks).
213
280
  if (!chartData) return null
214
281
 
282
+ const annotationLabelHeight = 14
283
+ const zeroLineY = chartData.yMin < 0 ? toY(0) : null
284
+
215
285
  return (
216
- <div className="relative">
286
+ <div className="relative" ref={wrapperRef}>
217
287
  <svg
218
288
  ref={svgRef}
219
289
  viewBox={`0 0 ${width} ${height}`}
220
290
  className="w-full h-full"
291
+ style={layout === 'dashboard' ? { height: 240 } : undefined}
221
292
  preserveAspectRatio="xMidYMid meet"
293
+ data-chart-layout={compact ? 'compact' : 'full'}
222
294
  >
223
295
  {/* Grid lines */}
224
296
  {yTicks.map((tick, i) => (
@@ -235,6 +307,19 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
235
307
  />
236
308
  ))}
237
309
 
310
+ {zeroLineY !== null && (
311
+ <line
312
+ data-chart-zero-line
313
+ x1={marginLeft}
314
+ y1={zeroLineY}
315
+ x2={width - marginRight}
316
+ y2={zeroLineY}
317
+ stroke="currentColor"
318
+ className="text-theme-border/60"
319
+ strokeWidth="1"
320
+ />
321
+ )}
322
+
238
323
  {/* Y axis labels */}
239
324
  {yTicks.map((tick, i) => (
240
325
  <text
@@ -243,7 +328,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
243
328
  y={tick.y + 4}
244
329
  textAnchor="end"
245
330
  className="fill-theme-text-secondary"
246
- fontSize="11"
331
+ fontSize={fontSize}
247
332
  fontFamily="ui-monospace, monospace"
248
333
  >
249
334
  {tick.label}
@@ -256,9 +341,9 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
256
341
  key={`xlabel-${i}`}
257
342
  x={tick.x}
258
343
  y={height - 4}
259
- textAnchor="middle"
344
+ textAnchor={tick.anchor}
260
345
  className="fill-theme-text-secondary"
261
- fontSize="11"
346
+ fontSize={fontSize}
262
347
  fontFamily="ui-monospace, monospace"
263
348
  >
264
349
  {tick.label}
@@ -335,6 +420,67 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
335
420
  )
336
421
  })}
337
422
 
423
+ {/* Annotation markers: a vertical line per recorded instant, labelled
424
+ in stacked rows at the top of the plot. */}
425
+ {placedAnnotations.map((placed, i) => {
426
+ const labelY = marginTop + placed.row * (annotationLabelHeight + 2)
427
+ const showLabel = labelsFit && !compact && !placed.labelHidden
428
+ return (
429
+ <g
430
+ key={`annotation-${i}`}
431
+ data-chart-annotation={placed.annotation.kind}
432
+ data-chart-annotation-label={showLabel ? 'visible' : 'hidden'}
433
+ >
434
+ <line
435
+ x1={placed.x}
436
+ y1={marginTop}
437
+ x2={placed.x}
438
+ y2={marginTop + plotHeight}
439
+ stroke="currentColor"
440
+ className="text-accent"
441
+ strokeWidth="1.5"
442
+ strokeDasharray="3 3"
443
+ opacity="0.9"
444
+ />
445
+ {showLabel && (
446
+ <>
447
+ <rect
448
+ x={placed.labelX}
449
+ y={labelY}
450
+ width={placed.labelWidth}
451
+ height={annotationLabelHeight}
452
+ rx="3"
453
+ fill="currentColor"
454
+ className="text-theme-surface"
455
+ opacity="0.9"
456
+ />
457
+ <rect
458
+ x={placed.labelX}
459
+ y={labelY}
460
+ width={placed.labelWidth}
461
+ height={annotationLabelHeight}
462
+ rx="3"
463
+ fill="none"
464
+ stroke="currentColor"
465
+ className="text-accent/50"
466
+ strokeWidth="1"
467
+ />
468
+ <text
469
+ x={placed.labelX + 5}
470
+ y={labelY + annotationLabelHeight - 3.5}
471
+ fontSize="11"
472
+ fontFamily="ui-monospace, monospace"
473
+ fontWeight="500"
474
+ className="fill-accent-text"
475
+ >
476
+ {placed.labelText}
477
+ </text>
478
+ </>
479
+ )}
480
+ </g>
481
+ )
482
+ })}
483
+
338
484
  {/* Hover crosshair + dots */}
339
485
  {hoverData && (
340
486
  <>
@@ -383,6 +529,15 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
383
529
  <div className="text-theme-text-tertiary mb-1.5 font-mono">
384
530
  {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
385
531
  </div>
532
+ {hoverData.nearbyAnnotations.map((placed, i) => (
533
+ <div key={`annotation-${i}`} className="flex items-center gap-2 py-0.5">
534
+ <span className="w-2 h-2 rounded-full shrink-0 bg-accent" />
535
+ <span className="text-accent-text">Change recorded</span>
536
+ <span className="text-theme-text-primary font-mono ml-auto pl-3">
537
+ {placed.annotation.label}
538
+ </span>
539
+ </div>
540
+ ))}
386
541
  {hoverData.points.map((p, i) => (
387
542
  <div key={i} className="flex items-center gap-2 py-0.5">
388
543
  <div
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { describePodCoverage } from "./PrometheusChartsView";
4
+ import type { PrometheusResourceMetricsResult } from "./PrometheusChartsView";
5
+
6
+ function result(
7
+ patch: Partial<PrometheusResourceMetricsResult>,
8
+ ): PrometheusResourceMetricsResult {
9
+ return { unit: "cores", ...patch } as PrometheusResourceMetricsResult;
10
+ }
11
+
12
+ describe("describePodCoverage", () => {
13
+ it("names the pods a chart covers and what it therefore misses", () => {
14
+ expect(describePodCoverage(result({ pods: 3, podsTotal: 3 }))).toBe(
15
+ "3 current pods; pods replaced during the window are not included",
16
+ );
17
+ expect(describePodCoverage(result({ pods: 1, podsTotal: 1 }))).toBe(
18
+ "1 current pod; pods replaced during the window are not included",
19
+ );
20
+ });
21
+
22
+ it("says when the cap cut the list, so a short count is not read as the workload", () => {
23
+ expect(describePodCoverage(result({ pods: 50, podsTotal: 120 }))).toBe(
24
+ "first 50 of 120 current pods; pods replaced during the window are not included",
25
+ );
26
+ });
27
+
28
+ it("distinguishes a workload with no pods from a kind that has none to report", () => {
29
+ expect(describePodCoverage(result({ pods: 0 }))).toBe(
30
+ "This workload has no pods running, so there is nothing to chart",
31
+ );
32
+ // A Node chart carries no pod count at all; it gets no caption rather
33
+ // than a claim about pods.
34
+ expect(describePodCoverage(result({}))).toBeUndefined();
35
+ });
36
+ });