@skyhook-io/k8s-ui 1.14.4 → 1.14.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.4",
3
+ "version": "1.14.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { AreaChart } from './AreaChart'
4
+ import { layoutAnnotations, ANNOTATION_LABEL_ROWS } from './annotations'
5
+ import { formatTimestamp } from './format'
6
+ import type { ChartAnnotation, TimeSeries } from './types'
7
+
8
+ const t0 = 1_788_679_000
9
+
10
+ function series(points: Array<[number, number | null]>, labels: Record<string, string> = {}): TimeSeries {
11
+ return { labels, dataPoints: points.map(([offset, value]) => ({ timestamp: t0 + offset, value })) }
12
+ }
13
+
14
+ function render(props: Partial<Parameters<typeof AreaChart>[0]> & { series: TimeSeries[] }) {
15
+ return renderToStaticMarkup(
16
+ <AreaChart color="#3b82f6" fillColor="#3b82f622" unit="" {...props} />,
17
+ )
18
+ }
19
+
20
+ function attrs(html: string, tag: string, attr: string): string[] {
21
+ const out: string[] = []
22
+ const re = new RegExp(`<${tag}\\b[^>]*\\s${attr}="([^"]*)"`, 'g')
23
+ for (const match of html.matchAll(re)) out.push(match[1])
24
+ return out
25
+ }
26
+
27
+ describe('AreaChart annotations', () => {
28
+ const base = [series([[0, 1], [600, 2], [1200, 3], [1800, 2]])]
29
+
30
+ it('renders a marker and label per annotation inside the domain and none outside', () => {
31
+ const annotations: ChartAnnotation[] = [
32
+ { timestamp: t0 + 600, label: 'ConfigMap api-config', kind: 'change' },
33
+ { timestamp: t0 + 9_000, label: 'outside', kind: 'change' },
34
+ ]
35
+ const html = render({ series: base, annotations })
36
+ expect(html.match(/data-chart-annotation="change"/g)).toHaveLength(1)
37
+ expect(html).toContain('data-chart-annotation-label="visible"')
38
+ expect(html).toContain('ConfigMap api-config')
39
+ expect(html).not.toContain('outside')
40
+ })
41
+
42
+ it('stacks overlapping labels into rows and hides labels past the row limit', () => {
43
+ const toX = (ts: number) => 84 + ((ts - t0) / 1800) * 876
44
+ const crowded: ChartAnnotation[] = Array.from({ length: ANNOTATION_LABEL_ROWS + 1 }, (_, i) => ({
45
+ timestamp: t0 + i * 5,
46
+ label: `Change ${i}`,
47
+ kind: 'change',
48
+ }))
49
+ const placed = layoutAnnotations(crowded, toX, { minTs: t0, maxTs: t0 + 1800 }, { left: 84, right: 960 })
50
+ expect(placed.map(p => p.row)).toEqual([0, 1, 2, -1])
51
+ expect(placed.at(-1)?.labelHidden).toBe(true)
52
+ const far: ChartAnnotation[] = [
53
+ { timestamp: t0, label: 'first', kind: 'change' },
54
+ { timestamp: t0 + 1200, label: 'second', kind: 'change' },
55
+ ]
56
+ expect(layoutAnnotations(far, toX, { minTs: t0, maxTs: t0 + 1800 }, { left: 84, right: 960 }).map(p => p.row)).toEqual([0, 0])
57
+ const html = render({ series: base, annotations: crowded })
58
+ expect(html.match(/data-chart-annotation-label="visible"/g)).toHaveLength(ANNOTATION_LABEL_ROWS)
59
+ expect(html.match(/data-chart-annotation-label="hidden"/g)).toHaveLength(1)
60
+ })
61
+
62
+ it('keeps a long label readable by truncating it', () => {
63
+ const html = render({
64
+ series: base,
65
+ annotations: [{ timestamp: t0 + 600, label: 'ConfigMap a-very-long-configmap-name-that-goes-on', kind: 'change' }],
66
+ })
67
+ expect(html).toContain('ConfigMap a-very-long-confi…')
68
+ })
69
+ })
70
+
71
+ describe('AreaChart domain and axes', () => {
72
+ it('uses an explicit domain wider than the samples for the X axis', () => {
73
+ const html = render({
74
+ series: [series([[600, 1], [1200, 2]])],
75
+ domain: { start: t0, end: t0 + 3600 },
76
+ })
77
+ const labels = attrs(html, 'text', 'text-anchor')
78
+ expect(labels.length).toBeGreaterThan(0)
79
+ expect(html).toContain(`>${formatTimestamp(t0)}<`)
80
+ expect(html).toContain(`>${formatTimestamp(t0 + 3600)}<`)
81
+ const xs = attrs(html, 'text', 'x').map(Number).filter(x => x >= 84)
82
+ expect(Math.max(...xs)).toBeCloseTo(960, 0)
83
+ })
84
+
85
+ it('extends the Y axis below zero and draws a zero line for negative values', () => {
86
+ const html = render({ series: [series([[0, -4], [600, 2], [1200, -1]])] })
87
+ expect(html).toContain('data-chart-zero-line')
88
+ expect(html).toContain('>-4.40<')
89
+ const positive = render({ series: [series([[0, 1], [600, 2]])] })
90
+ expect(positive).not.toContain('data-chart-zero-line')
91
+ expect(positive).toContain('>0<')
92
+ })
93
+
94
+ it('breaks the line across null gaps instead of bridging them', () => {
95
+ const html = render({ series: [series([[0, 1], [300, 2], [600, null], [900, 3], [1200, 4]])] })
96
+ const lines = attrs(html, 'path', 'fill').filter(fill => fill === 'none')
97
+ expect(lines).toHaveLength(2)
98
+ })
99
+
100
+ it('renders sparse samples without a path until two finite points exist', () => {
101
+ expect(attrs(render({ series: [series([[0, 1]])] }), 'path', 'fill')).toHaveLength(0)
102
+ expect(attrs(render({ series: [series([[0, 1], [3600, 5]])] }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(1)
103
+ })
104
+
105
+ it('renders nothing for an empty series list', () => {
106
+ expect(render({ series: [] })).toBe('')
107
+ })
108
+ })
109
+
110
+ describe('AreaChart compact layout and count axes', () => {
111
+ const base = [series([[0, 1], [600, 2], [1200, 3], [1800, 2]])]
112
+
113
+ it('keeps the full layout for existing callers', () => {
114
+ const html = render({ series: base })
115
+ expect(html).toContain('viewBox="0 0 1000 300"')
116
+ expect(html).toContain('data-chart-layout="full"')
117
+ expect(attrs(html, 'text', 'text-anchor').filter(a => a === 'end')).toHaveLength(5)
118
+ })
119
+
120
+ it('draws two ticks per axis, larger text and a taller plot when compact', () => {
121
+ const html = render({
122
+ series: base,
123
+ layout: 'compact',
124
+ annotations: [{ timestamp: t0 + 600, label: 'ConfigMap api-config', kind: 'change' }],
125
+ })
126
+ expect(html).toContain('viewBox="0 0 400 260"')
127
+ expect(html).toContain('data-chart-layout="compact"')
128
+ // Two Y labels plus the right-aligned last X label.
129
+ expect(attrs(html, 'text', 'text-anchor').filter(a => a === 'end')).toHaveLength(3)
130
+ expect(attrs(html, 'text', 'text-anchor').filter(a => a === 'start')).toHaveLength(1)
131
+ expect(attrs(html, 'text', 'text-anchor').filter(a => a === 'middle')).toHaveLength(0)
132
+ expect(html).toContain('font-size="14"')
133
+ expect(html).toContain('data-chart-annotation-label="hidden"')
134
+ expect(html).toContain(`>${formatTimestamp(t0)}<`)
135
+ expect(html).toContain(`>${formatTimestamp(t0 + 1800)}<`)
136
+ })
137
+
138
+ it('widens the compact left margin so a long Y label is not clipped', () => {
139
+ const html = render({ series: [series([[0, 100 * 1024 * 1024], [600, 161.5 * 1024 * 1024]])], unit: 'bytes', layout: 'compact' })
140
+ expect(html).toContain('177.7 MiB')
141
+ const yLabelX = Math.min(...attrs(html, 'text', 'x').map(Number).filter(x => x > 0))
142
+ expect(yLabelX).toBeGreaterThan(70)
143
+ const compactSmall = render({ series: [series([[0, 1], [600, 2]])], layout: 'compact' })
144
+ expect(Math.min(...attrs(compactSmall, 'text', 'x').map(Number).filter(x => x > 0))).toBe(52)
145
+ })
146
+
147
+ it('uses integer ticks with a nice step for count series', () => {
148
+ const zero = render({ series: [series([[0, 0], [600, 0], [1200, 0]])], unit: 'count' })
149
+ expect(zero).toContain('>0<')
150
+ expect(zero).toContain('>1<')
151
+ expect(zero).not.toContain('1.10')
152
+ const restarts = render({ series: [series([[0, 6], [600, 12], [1200, 18]])], unit: 'count' })
153
+ for (const tick of ['0', '5', '10', '15', '20']) expect(restarts).toContain(`>${tick}<`)
154
+ expect(restarts).not.toContain('6.05')
155
+ const compactCount = render({ series: [series([[0, 6], [600, 18]])], unit: 'count', layout: 'compact' })
156
+ expect(attrs(compactCount, 'text', 'text-anchor').filter(a => a === 'end')).toHaveLength(3)
157
+ expect(compactCount).toContain('>20<')
158
+ expect(compactCount).not.toContain('>10<')
159
+ })
160
+ })
@@ -1,19 +1,48 @@
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'
5
+ import { layoutAnnotations } from './annotations'
6
+ import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
7
+ import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
6
8
 
7
- export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
9
+ // Below this rendered width the annotation label pills would cover most of the
10
+ // plot once the 1000-unit viewBox is scaled down; the lines stay and the label
11
+ // text moves into the hover tooltip.
12
+ export const ANNOTATION_LABEL_MIN_WIDTH_PX = 420
13
+ const ANNOTATION_HOVER_TOLERANCE = 8
14
+
15
+ export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, layout = 'full' }: {
8
16
  series: TimeSeries[]
9
17
  color: string
10
18
  fillColor: string
11
19
  unit: string
12
20
  referenceLines?: ReferenceLine[]
21
+ /** Vertical markers; only those inside the X domain render. */
22
+ annotations?: ChartAnnotation[]
23
+ /** X axis window in unix seconds. Defaults to the sample extent. */
24
+ domain?: { start: number; end: number }
25
+ /**
26
+ * Display name per series, parallel to `series`. Pass it when the chart
27
+ * shows a subset of a larger result so names stay distinguishable across
28
+ * the whole result; defaults to names derived from the labels given.
29
+ */
30
+ seriesLabels?: string[]
31
+ /**
32
+ * 'full' is the fixed wide layout every existing caller gets. 'auto' switches
33
+ * to the compact layout (two ticks per axis, larger text, taller plot) when
34
+ * the rendered chart is narrower than ANNOTATION_LABEL_MIN_WIDTH_PX;
35
+ * 'compact' forces it.
36
+ */
37
+ layout?: 'full' | 'auto' | 'compact'
13
38
  }) {
14
39
  const svgRef = useRef<SVGSVGElement>(null)
40
+ const wrapperRef = useRef<HTMLDivElement>(null)
15
41
  const [hoverX, setHoverX] = useState<number | null>(null)
42
+ const [labelsFit, setLabelsFit] = useState(true)
16
43
  const multiSeries = series.length > 1
44
+ const compact = layout === 'compact' || (layout === 'auto' && !labelsFit)
45
+ const countAxis = isCountUnit(unit)
17
46
 
18
47
  const chartData = useMemo(() => {
19
48
  if (!series.length) return null
@@ -21,17 +50,23 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
21
50
  let minTs = Infinity
22
51
  let maxTs = -Infinity
23
52
  let maxVal = 0
53
+ let minVal = 0
24
54
 
25
55
  for (const s of series) {
26
56
  for (const dp of s.dataPoints) {
27
57
  if (dp.timestamp < minTs) minTs = dp.timestamp
28
58
  if (dp.timestamp > maxTs) maxTs = dp.timestamp
29
59
  if (dp.value != null && dp.value > maxVal) maxVal = dp.value
60
+ if (dp.value != null && dp.value < minVal) minVal = dp.value
30
61
  }
31
62
  }
32
63
 
64
+ if (domain && Number.isFinite(domain.start) && Number.isFinite(domain.end) && domain.end > domain.start) {
65
+ minTs = domain.start
66
+ maxTs = domain.end
67
+ }
33
68
  if (minTs === maxTs) maxTs = minTs + 60
34
- if (maxVal === 0) {
69
+ if (maxVal === 0 && minVal === 0) {
35
70
  // Unit-appropriate floor so the Y-axis isn't misleadingly large.
36
71
  maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
37
72
  }
@@ -44,21 +79,28 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
44
79
  }
45
80
  }
46
81
 
47
- const padding = maxVal * 0.1
48
- const yMax = maxVal + padding
82
+ const padding = Math.max(maxVal, -minVal) * 0.1
83
+ // A count axis ends on a whole step so its ticks are integers; other
84
+ // units keep headroom above the maximum.
85
+ const yMax = countAxis && minVal >= 0 ? integerAxisTop(maxVal, compact ? 1 : 4) : maxVal + padding
86
+ const yMin = minVal < 0 ? minVal - padding : 0
49
87
 
50
- return { minTs, maxTs, yMax, series }
51
- }, [series, unit, referenceLines])
88
+ return { minTs, maxTs, yMax, yMin, series }
89
+ }, [series, unit, referenceLines, domain, countAxis, compact])
52
90
 
53
91
  // Layout constants. marginLeft sized for the widest expected Y-tick label
54
92
  // ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
55
93
  // 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
94
+ const base = chartLayout(compact)
95
+ const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
96
+ const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
97
+ const yLabels = yValues.map(val => formatMetricValue(val, unit))
98
+ // The compact layout's larger text makes a label like "161.5 MiB" wider
99
+ // than the fixed margin, and SVG clips it; grow the margin to the widest
100
+ // label there. The full layout keeps its fixed margin for existing callers.
101
+ const marginLeft = compact
102
+ ? Math.max(base.marginLeft, Math.ceil(Math.max(0, ...yLabels.map(label => label.length)) * fontSize * 0.62 + 12))
103
+ : base.marginLeft
62
104
  const plotWidth = width - marginLeft - marginRight
63
105
  const plotHeight = height - marginTop - marginBottom
64
106
 
@@ -71,31 +113,31 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
71
113
  }
72
114
  const toY = (val: number) => {
73
115
  if (!chartData) return marginTop + plotHeight
74
- return marginTop + plotHeight - (val / chartData.yMax) * plotHeight
116
+ return marginTop + plotHeight - ((val - chartData.yMin) / (chartData.yMax - chartData.yMin)) * plotHeight
75
117
  }
76
118
 
77
119
  const yTicks = useMemo(() => {
78
120
  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])
121
+ return yValues.map((val, i) => ({ val, y: toY(val), label: yLabels[i] }))
122
+ }, [chartData, yValues, yLabels])
86
123
 
87
124
  const xTicks = useMemo(() => {
88
125
  if (!chartData) return []
89
126
  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) }
127
+ return Array.from({ length: xIntervals + 1 }, (_, i) => {
128
+ const ts = minTs + ((maxTs - minTs) / xIntervals) * i
129
+ // In the compact layout the two labels sit at the plot edges and would
130
+ // otherwise spill past the viewBox.
131
+ const anchor: 'start' | 'middle' | 'end' = !compact ? 'middle' : i === 0 ? 'start' : 'end'
132
+ return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
94
133
  })
95
- }, [chartData])
134
+ }, [chartData, xIntervals, compact])
96
135
 
97
136
  const paths = useMemo(() => {
98
137
  if (!chartData) return []
138
+ // The area fills toward zero, not the plot floor, so a series that dips
139
+ // below zero is shaded on the correct side of the axis.
140
+ const baseline = toY(0)
99
141
  const segments: {
100
142
  linePath: string
101
143
  areaPath: string
@@ -118,8 +160,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
118
160
  if (run.length >= 2) {
119
161
  const linePath = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
120
162
  const areaPath = linePath +
121
- ` L${run[run.length - 1].x},${marginTop + plotHeight}` +
122
- ` L${run[0].x},${marginTop + plotHeight} Z`
163
+ ` L${run[run.length - 1].x},${baseline}` +
164
+ ` L${run[0].x},${baseline} Z`
123
165
  segments.push({
124
166
  linePath,
125
167
  areaPath,
@@ -145,6 +187,26 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
145
187
  return segments
146
188
  }, [chartData])
147
189
 
190
+ const placedAnnotations = useMemo(() => {
191
+ if (!chartData || !annotations?.length) return []
192
+ return layoutAnnotations(
193
+ annotations,
194
+ toX,
195
+ { minTs: chartData.minTs, maxTs: chartData.maxTs },
196
+ { left: marginLeft, right: width - marginRight },
197
+ )
198
+ }, [chartData, annotations])
199
+
200
+ useEffect(() => {
201
+ 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)
204
+ update()
205
+ const observer = new ResizeObserver(update)
206
+ observer.observe(el)
207
+ return () => observer.disconnect()
208
+ }, [placedAnnotations.length, layout])
209
+
148
210
  // Hover: only emit a tooltip row when the hovered timestamp lies within
149
211
  // the series' actual sample range (with 2× median-step tolerance). Without
150
212
  // this filter a series that ended mid-window leaves stale ghost entries.
@@ -159,9 +221,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
159
221
  .map((s, i) => ({ s, i }))
160
222
  .filter(({ s }) => s.dataPoints.filter(dp => dp.value != null).length >= 2)
161
223
 
162
- const fullLabels = validSeries.map(({ s, i }) =>
163
- s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
164
- )
224
+ const allLabels = seriesLabels ?? seriesDisplayLabels(chartData.series)
225
+ const fullLabels = validSeries.map(({ i }) => allLabels[i] ?? `series-${i}`)
165
226
  const shortLabels = computeShortLabels(fullLabels)
166
227
 
167
228
  const points = validSeries.map(({ s, i }, vi) => {
@@ -197,8 +258,12 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
197
258
  }
198
259
  }).filter((p): p is NonNullable<typeof p> => p !== null)
199
260
 
200
- return { ts, x: clampedX, points }
201
- }, [hoverX, chartData])
261
+ const nearbyAnnotations = placedAnnotations.filter(
262
+ p => Math.abs(p.x - clampedX) <= ANNOTATION_HOVER_TOLERANCE,
263
+ )
264
+
265
+ return { ts, x: clampedX, points, nearbyAnnotations }
266
+ }, [hoverX, chartData, placedAnnotations, seriesLabels])
202
267
 
203
268
  const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
204
269
  const svg = svgRef.current
@@ -212,13 +277,17 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
212
277
  // every hook has been invoked (Rules of Hooks).
213
278
  if (!chartData) return null
214
279
 
280
+ const annotationLabelHeight = 14
281
+ const zeroLineY = chartData.yMin < 0 ? toY(0) : null
282
+
215
283
  return (
216
- <div className="relative">
284
+ <div className="relative" ref={wrapperRef}>
217
285
  <svg
218
286
  ref={svgRef}
219
287
  viewBox={`0 0 ${width} ${height}`}
220
288
  className="w-full h-full"
221
289
  preserveAspectRatio="xMidYMid meet"
290
+ data-chart-layout={compact ? 'compact' : 'full'}
222
291
  >
223
292
  {/* Grid lines */}
224
293
  {yTicks.map((tick, i) => (
@@ -235,6 +304,19 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
235
304
  />
236
305
  ))}
237
306
 
307
+ {zeroLineY !== null && (
308
+ <line
309
+ data-chart-zero-line
310
+ x1={marginLeft}
311
+ y1={zeroLineY}
312
+ x2={width - marginRight}
313
+ y2={zeroLineY}
314
+ stroke="currentColor"
315
+ className="text-theme-border/60"
316
+ strokeWidth="1"
317
+ />
318
+ )}
319
+
238
320
  {/* Y axis labels */}
239
321
  {yTicks.map((tick, i) => (
240
322
  <text
@@ -243,7 +325,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
243
325
  y={tick.y + 4}
244
326
  textAnchor="end"
245
327
  className="fill-theme-text-secondary"
246
- fontSize="11"
328
+ fontSize={fontSize}
247
329
  fontFamily="ui-monospace, monospace"
248
330
  >
249
331
  {tick.label}
@@ -256,9 +338,9 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
256
338
  key={`xlabel-${i}`}
257
339
  x={tick.x}
258
340
  y={height - 4}
259
- textAnchor="middle"
341
+ textAnchor={tick.anchor}
260
342
  className="fill-theme-text-secondary"
261
- fontSize="11"
343
+ fontSize={fontSize}
262
344
  fontFamily="ui-monospace, monospace"
263
345
  >
264
346
  {tick.label}
@@ -335,6 +417,67 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
335
417
  )
336
418
  })}
337
419
 
420
+ {/* Annotation markers: a vertical line per recorded instant, labelled
421
+ in stacked rows at the top of the plot. */}
422
+ {placedAnnotations.map((placed, i) => {
423
+ const labelY = marginTop + placed.row * (annotationLabelHeight + 2)
424
+ const showLabel = labelsFit && !compact && !placed.labelHidden
425
+ return (
426
+ <g
427
+ key={`annotation-${i}`}
428
+ data-chart-annotation={placed.annotation.kind}
429
+ data-chart-annotation-label={showLabel ? 'visible' : 'hidden'}
430
+ >
431
+ <line
432
+ x1={placed.x}
433
+ y1={marginTop}
434
+ x2={placed.x}
435
+ y2={marginTop + plotHeight}
436
+ stroke="currentColor"
437
+ className="text-accent"
438
+ strokeWidth="1.5"
439
+ strokeDasharray="3 3"
440
+ opacity="0.9"
441
+ />
442
+ {showLabel && (
443
+ <>
444
+ <rect
445
+ x={placed.labelX}
446
+ y={labelY}
447
+ width={placed.labelWidth}
448
+ height={annotationLabelHeight}
449
+ rx="3"
450
+ fill="currentColor"
451
+ className="text-theme-surface"
452
+ opacity="0.9"
453
+ />
454
+ <rect
455
+ x={placed.labelX}
456
+ y={labelY}
457
+ width={placed.labelWidth}
458
+ height={annotationLabelHeight}
459
+ rx="3"
460
+ fill="none"
461
+ stroke="currentColor"
462
+ className="text-accent/50"
463
+ strokeWidth="1"
464
+ />
465
+ <text
466
+ x={placed.labelX + 5}
467
+ y={labelY + annotationLabelHeight - 3.5}
468
+ fontSize="11"
469
+ fontFamily="ui-monospace, monospace"
470
+ fontWeight="500"
471
+ className="fill-accent-text"
472
+ >
473
+ {placed.labelText}
474
+ </text>
475
+ </>
476
+ )}
477
+ </g>
478
+ )
479
+ })}
480
+
338
481
  {/* Hover crosshair + dots */}
339
482
  {hoverData && (
340
483
  <>
@@ -383,6 +526,15 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
383
526
  <div className="text-theme-text-tertiary mb-1.5 font-mono">
384
527
  {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
385
528
  </div>
529
+ {hoverData.nearbyAnnotations.map((placed, i) => (
530
+ <div key={`annotation-${i}`} className="flex items-center gap-2 py-0.5">
531
+ <span className="w-2 h-2 rounded-full shrink-0 bg-accent" />
532
+ <span className="text-accent-text">Change recorded</span>
533
+ <span className="text-theme-text-primary font-mono ml-auto pl-3">
534
+ {placed.annotation.label}
535
+ </span>
536
+ </div>
537
+ ))}
386
538
  {hoverData.points.map((p, i) => (
387
539
  <div key={i} className="flex items-center gap-2 py-0.5">
388
540
  <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
+ });