@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 +1 -1
- package/src/components/charts/AreaChart.test.tsx +160 -0
- package/src/components/charts/AreaChart.tsx +191 -39
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +193 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +32 -0
- package/src/components/charts/axis.ts +60 -0
- package/src/components/charts/colors.test.ts +27 -0
- package/src/components/charts/colors.ts +23 -0
- package/src/components/charts/format.test.ts +19 -0
- package/src/components/charts/format.ts +17 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/types.ts +12 -0
- package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/index.ts +7 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -57
- package/src/components/resources/renderers/WorkloadRenderer.tsx +4 -20
- package/src/components/topology/K8sResourceNode.test.ts +9 -0
- package/src/components/topology/K8sResourceNode.tsx +56 -5
- package/src/components/ui/Badge.tsx +5 -1
- package/src/components/ui/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/monaco-deep.d.ts +10 -0
- package/src/types/core.ts +23 -10
package/package.json
CHANGED
|
@@ -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
|
|
5
|
+
import { layoutAnnotations } from './annotations'
|
|
6
|
+
import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
|
|
7
|
+
import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
const height =
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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},${
|
|
122
|
-
` L${run[0].x},${
|
|
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
|
|
163
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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=
|
|
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=
|
|
341
|
+
textAnchor={tick.anchor}
|
|
260
342
|
className="fill-theme-text-secondary"
|
|
261
|
-
fontSize=
|
|
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
|
+
});
|