@skyhook-io/k8s-ui 1.6.1 → 1.6.2
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 +6 -1
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +338 -57
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
"./src/components/timeline/*.tsx",
|
|
36
36
|
"./src/components/timeline/*.ts"
|
|
37
37
|
],
|
|
38
|
+
"./components/charts": "./src/components/charts/index.ts",
|
|
39
|
+
"./components/charts/*": [
|
|
40
|
+
"./src/components/charts/*.tsx",
|
|
41
|
+
"./src/components/charts/*.ts"
|
|
42
|
+
],
|
|
38
43
|
"./hooks/*": [
|
|
39
44
|
"./src/hooks/*.ts",
|
|
40
45
|
"./src/hooks/*.tsx"
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type * as React from 'react'
|
|
3
|
+
import { seriesColor, seriesFill, computeShortLabels } from './colors'
|
|
4
|
+
import { formatMetricValue, formatTimestamp } from './format'
|
|
5
|
+
import type { TimeSeries, ReferenceLine } from './types'
|
|
6
|
+
|
|
7
|
+
export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
|
|
8
|
+
series: TimeSeries[]
|
|
9
|
+
color: string
|
|
10
|
+
fillColor: string
|
|
11
|
+
unit: string
|
|
12
|
+
referenceLines?: ReferenceLine[]
|
|
13
|
+
}) {
|
|
14
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
15
|
+
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
16
|
+
const multiSeries = series.length > 1
|
|
17
|
+
|
|
18
|
+
const chartData = useMemo(() => {
|
|
19
|
+
if (!series.length) return null
|
|
20
|
+
|
|
21
|
+
let minTs = Infinity
|
|
22
|
+
let maxTs = -Infinity
|
|
23
|
+
let maxVal = 0
|
|
24
|
+
|
|
25
|
+
for (const s of series) {
|
|
26
|
+
for (const dp of s.dataPoints) {
|
|
27
|
+
if (dp.timestamp < minTs) minTs = dp.timestamp
|
|
28
|
+
if (dp.timestamp > maxTs) maxTs = dp.timestamp
|
|
29
|
+
if (dp.value > maxVal) maxVal = dp.value
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (minTs === maxTs) maxTs = minTs + 60
|
|
34
|
+
if (maxVal === 0) {
|
|
35
|
+
// Unit-appropriate floor so the Y-axis isn't misleadingly large.
|
|
36
|
+
maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Extend axis to include reference lines so request/limit aren't clipped
|
|
40
|
+
// at the top, which would make usage-vs-limit unreadable.
|
|
41
|
+
if (referenceLines) {
|
|
42
|
+
for (const rl of referenceLines) {
|
|
43
|
+
if (rl.value > maxVal) maxVal = rl.value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const padding = maxVal * 0.1
|
|
48
|
+
const yMax = maxVal + padding
|
|
49
|
+
|
|
50
|
+
return { minTs, maxTs, yMax, series }
|
|
51
|
+
}, [series, unit, referenceLines])
|
|
52
|
+
|
|
53
|
+
// Layout constants. marginLeft sized for the widest expected Y-tick label
|
|
54
|
+
// ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
|
|
55
|
+
// 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
|
|
62
|
+
const plotWidth = width - marginLeft - marginRight
|
|
63
|
+
const plotHeight = height - marginTop - marginBottom
|
|
64
|
+
|
|
65
|
+
// Coord transforms. When chartData is null (empty series) these return 0;
|
|
66
|
+
// the hooks downstream check chartData and bail to empty results so no
|
|
67
|
+
// bad coords ever reach the DOM.
|
|
68
|
+
const toX = (ts: number) => {
|
|
69
|
+
if (!chartData) return marginLeft
|
|
70
|
+
return marginLeft + ((ts - chartData.minTs) / (chartData.maxTs - chartData.minTs)) * plotWidth
|
|
71
|
+
}
|
|
72
|
+
const toY = (val: number) => {
|
|
73
|
+
if (!chartData) return marginTop + plotHeight
|
|
74
|
+
return marginTop + plotHeight - (val / chartData.yMax) * plotHeight
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const yTicks = useMemo(() => {
|
|
78
|
+
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])
|
|
86
|
+
|
|
87
|
+
const xTicks = useMemo(() => {
|
|
88
|
+
if (!chartData) return []
|
|
89
|
+
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) }
|
|
94
|
+
})
|
|
95
|
+
}, [chartData])
|
|
96
|
+
|
|
97
|
+
const paths = useMemo(() => {
|
|
98
|
+
if (!chartData) return []
|
|
99
|
+
return chartData.series.map((s, seriesIdx) => {
|
|
100
|
+
if (s.dataPoints.length < 2) return null
|
|
101
|
+
const points = s.dataPoints.map(dp => ({ x: toX(dp.timestamp), y: toY(dp.value) }))
|
|
102
|
+
|
|
103
|
+
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
104
|
+
const areaPath = linePath +
|
|
105
|
+
` L${points[points.length - 1].x},${marginTop + plotHeight}` +
|
|
106
|
+
` L${points[0].x},${marginTop + plotHeight} Z`
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
linePath,
|
|
110
|
+
areaPath,
|
|
111
|
+
strokeColor: multiSeries ? seriesColor(seriesIdx, color) : color,
|
|
112
|
+
areaFillColor: multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor,
|
|
113
|
+
key: seriesIdx,
|
|
114
|
+
}
|
|
115
|
+
}).filter(Boolean)
|
|
116
|
+
}, [chartData])
|
|
117
|
+
|
|
118
|
+
// Hover: only emit a tooltip row when the hovered timestamp lies within
|
|
119
|
+
// the series' actual sample range (with 2× median-step tolerance). Without
|
|
120
|
+
// this filter a series that ended mid-window leaves stale ghost entries.
|
|
121
|
+
const hoverData = useMemo(() => {
|
|
122
|
+
if (!chartData || hoverX === null) return null
|
|
123
|
+
const { minTs, maxTs } = chartData
|
|
124
|
+
const clampedX = Math.max(marginLeft, Math.min(marginLeft + plotWidth, hoverX))
|
|
125
|
+
const frac = (clampedX - marginLeft) / plotWidth
|
|
126
|
+
const ts = minTs + frac * (maxTs - minTs)
|
|
127
|
+
|
|
128
|
+
const validSeries = chartData.series
|
|
129
|
+
.map((s, i) => ({ s, i }))
|
|
130
|
+
.filter(({ s }) => s.dataPoints.length >= 2)
|
|
131
|
+
|
|
132
|
+
const fullLabels = validSeries.map(({ s, i }) =>
|
|
133
|
+
s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
|
|
134
|
+
)
|
|
135
|
+
const shortLabels = computeShortLabels(fullLabels)
|
|
136
|
+
|
|
137
|
+
const points = validSeries.map(({ s, i }, vi) => {
|
|
138
|
+
const dps = s.dataPoints
|
|
139
|
+
const seriesMin = dps[0].timestamp
|
|
140
|
+
const seriesMax = dps[dps.length - 1].timestamp
|
|
141
|
+
const medianStep = dps.length >= 2
|
|
142
|
+
? (seriesMax - seriesMin) / (dps.length - 1)
|
|
143
|
+
: 30
|
|
144
|
+
const tolerance = Math.max(medianStep * 2, 60)
|
|
145
|
+
if (ts < seriesMin - tolerance || ts > seriesMax + tolerance) {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
let closest = dps[0]
|
|
149
|
+
let closestDist = Infinity
|
|
150
|
+
for (const dp of dps) {
|
|
151
|
+
const dist = Math.abs(dp.timestamp - ts)
|
|
152
|
+
if (dist < closestDist) {
|
|
153
|
+
closestDist = dist
|
|
154
|
+
closest = dp
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
label: shortLabels[vi],
|
|
159
|
+
fullLabel: fullLabels[vi],
|
|
160
|
+
value: closest.value,
|
|
161
|
+
y: toY(closest.value),
|
|
162
|
+
color: multiSeries ? seriesColor(i, color) : color,
|
|
163
|
+
}
|
|
164
|
+
}).filter((p): p is NonNullable<typeof p> => p !== null)
|
|
165
|
+
|
|
166
|
+
return { ts, x: clampedX, points }
|
|
167
|
+
}, [hoverX, chartData])
|
|
168
|
+
|
|
169
|
+
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
170
|
+
const svg = svgRef.current
|
|
171
|
+
if (!svg) return
|
|
172
|
+
const ctm = svg.getScreenCTM()
|
|
173
|
+
if (!ctm) return
|
|
174
|
+
setHoverX((e.clientX - ctm.e) / ctm.a)
|
|
175
|
+
}, [])
|
|
176
|
+
|
|
177
|
+
// Hook calls above run unconditionally; bail out of rendering only after
|
|
178
|
+
// every hook has been invoked (Rules of Hooks).
|
|
179
|
+
if (!chartData) return null
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="relative">
|
|
183
|
+
<svg
|
|
184
|
+
ref={svgRef}
|
|
185
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
186
|
+
className="w-full h-full"
|
|
187
|
+
preserveAspectRatio="xMidYMid meet"
|
|
188
|
+
>
|
|
189
|
+
{/* Grid lines */}
|
|
190
|
+
{yTicks.map((tick, i) => (
|
|
191
|
+
<line
|
|
192
|
+
key={`grid-${i}`}
|
|
193
|
+
x1={marginLeft}
|
|
194
|
+
y1={tick.y}
|
|
195
|
+
x2={width - marginRight}
|
|
196
|
+
y2={tick.y}
|
|
197
|
+
stroke="currentColor"
|
|
198
|
+
className="text-theme-border/30"
|
|
199
|
+
strokeWidth="1"
|
|
200
|
+
strokeDasharray={i === 0 ? undefined : '4 4'}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
|
|
204
|
+
{/* Y axis labels */}
|
|
205
|
+
{yTicks.map((tick, i) => (
|
|
206
|
+
<text
|
|
207
|
+
key={`ylabel-${i}`}
|
|
208
|
+
x={marginLeft - 8}
|
|
209
|
+
y={tick.y + 4}
|
|
210
|
+
textAnchor="end"
|
|
211
|
+
className="fill-theme-text-secondary"
|
|
212
|
+
fontSize="11"
|
|
213
|
+
fontFamily="ui-monospace, monospace"
|
|
214
|
+
>
|
|
215
|
+
{tick.label}
|
|
216
|
+
</text>
|
|
217
|
+
))}
|
|
218
|
+
|
|
219
|
+
{/* X axis labels */}
|
|
220
|
+
{xTicks.map((tick, i) => (
|
|
221
|
+
<text
|
|
222
|
+
key={`xlabel-${i}`}
|
|
223
|
+
x={tick.x}
|
|
224
|
+
y={height - 4}
|
|
225
|
+
textAnchor="middle"
|
|
226
|
+
className="fill-theme-text-secondary"
|
|
227
|
+
fontSize="11"
|
|
228
|
+
fontFamily="ui-monospace, monospace"
|
|
229
|
+
>
|
|
230
|
+
{tick.label}
|
|
231
|
+
</text>
|
|
232
|
+
))}
|
|
233
|
+
|
|
234
|
+
{/* Area fills */}
|
|
235
|
+
{paths.map(p => p && (
|
|
236
|
+
<path
|
|
237
|
+
key={`area-${p.key}`}
|
|
238
|
+
d={p.areaPath}
|
|
239
|
+
fill={p.areaFillColor}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
|
|
243
|
+
{/* Lines */}
|
|
244
|
+
{paths.map(p => p && (
|
|
245
|
+
<path
|
|
246
|
+
key={`line-${p.key}`}
|
|
247
|
+
d={p.linePath}
|
|
248
|
+
fill="none"
|
|
249
|
+
stroke={p.strokeColor}
|
|
250
|
+
strokeWidth="2"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
|
|
255
|
+
{/* Reference lines (request / limit overlays). Label sits on a subtle
|
|
256
|
+
background pill so it stays legible against the chart fill. */}
|
|
257
|
+
{referenceLines?.map((rl, i) => {
|
|
258
|
+
const y = Math.max(marginTop, Math.min(marginTop + plotHeight, toY(rl.value)))
|
|
259
|
+
const stroke = rl.kind === 'limit' ? '#f59e0b' : '#94a3b8'
|
|
260
|
+
const labelText = rl.label
|
|
261
|
+
// Sized to fit common label widths ("limit 384MiB", "request 100m"
|
|
262
|
+
// ≈ 90px at fontSize 11). Conservative to prevent right-edge overlap.
|
|
263
|
+
const labelWidth = labelText.length * 6.5 + 10
|
|
264
|
+
const labelHeight = 14
|
|
265
|
+
const labelX = width - marginRight - labelWidth
|
|
266
|
+
const labelY = Math.max(marginTop + labelHeight + 2, y - 6)
|
|
267
|
+
return (
|
|
268
|
+
<g key={`ref-${i}`}>
|
|
269
|
+
<line
|
|
270
|
+
x1={marginLeft}
|
|
271
|
+
y1={y}
|
|
272
|
+
x2={width - marginRight}
|
|
273
|
+
y2={y}
|
|
274
|
+
stroke={stroke}
|
|
275
|
+
strokeWidth="1"
|
|
276
|
+
strokeDasharray="6 4"
|
|
277
|
+
opacity="0.75"
|
|
278
|
+
/>
|
|
279
|
+
<rect
|
|
280
|
+
x={labelX}
|
|
281
|
+
y={labelY - labelHeight + 2}
|
|
282
|
+
width={labelWidth}
|
|
283
|
+
height={labelHeight}
|
|
284
|
+
rx="3"
|
|
285
|
+
fill="currentColor"
|
|
286
|
+
className="text-theme-surface"
|
|
287
|
+
opacity="0.85"
|
|
288
|
+
/>
|
|
289
|
+
<text
|
|
290
|
+
x={width - marginRight - 5}
|
|
291
|
+
y={labelY - 2}
|
|
292
|
+
textAnchor="end"
|
|
293
|
+
fontSize="11"
|
|
294
|
+
fontFamily="ui-monospace, monospace"
|
|
295
|
+
fontWeight="500"
|
|
296
|
+
fill={stroke}
|
|
297
|
+
>
|
|
298
|
+
{labelText}
|
|
299
|
+
</text>
|
|
300
|
+
</g>
|
|
301
|
+
)
|
|
302
|
+
})}
|
|
303
|
+
|
|
304
|
+
{/* Hover crosshair + dots */}
|
|
305
|
+
{hoverData && (
|
|
306
|
+
<>
|
|
307
|
+
<line
|
|
308
|
+
x1={hoverData.x} y1={marginTop}
|
|
309
|
+
x2={hoverData.x} y2={marginTop + plotHeight}
|
|
310
|
+
stroke="currentColor"
|
|
311
|
+
className="text-theme-text-tertiary"
|
|
312
|
+
strokeWidth="1"
|
|
313
|
+
strokeDasharray="4 4"
|
|
314
|
+
/>
|
|
315
|
+
{hoverData.points.map((p, i) => (
|
|
316
|
+
<circle
|
|
317
|
+
key={i}
|
|
318
|
+
cx={hoverData.x} cy={p.y}
|
|
319
|
+
r="4"
|
|
320
|
+
fill={p.color}
|
|
321
|
+
stroke="var(--color-theme-surface, #1a1a2e)"
|
|
322
|
+
strokeWidth="2"
|
|
323
|
+
/>
|
|
324
|
+
))}
|
|
325
|
+
</>
|
|
326
|
+
)}
|
|
327
|
+
|
|
328
|
+
{/* Invisible overlay for mouse events — must be last for event capture */}
|
|
329
|
+
<rect
|
|
330
|
+
x={marginLeft} y={marginTop}
|
|
331
|
+
width={plotWidth} height={plotHeight}
|
|
332
|
+
fill="transparent"
|
|
333
|
+
style={{ cursor: 'crosshair' }}
|
|
334
|
+
onMouseMove={handleMouseMove}
|
|
335
|
+
onMouseLeave={() => setHoverX(null)}
|
|
336
|
+
/>
|
|
337
|
+
</svg>
|
|
338
|
+
|
|
339
|
+
{/* Tooltip outside SVG for HTML rendering */}
|
|
340
|
+
{hoverData && (
|
|
341
|
+
<div
|
|
342
|
+
className="absolute top-0 pointer-events-none z-10"
|
|
343
|
+
style={{
|
|
344
|
+
left: `${(hoverData.x / width) * 100}%`,
|
|
345
|
+
transform: hoverData.x > width * 0.65 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)',
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
|
|
349
|
+
<div className="text-theme-text-tertiary mb-1.5 font-mono">
|
|
350
|
+
{new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
351
|
+
</div>
|
|
352
|
+
{hoverData.points.map((p, i) => (
|
|
353
|
+
<div key={i} className="flex items-center gap-2 py-0.5">
|
|
354
|
+
<div
|
|
355
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
356
|
+
style={{ backgroundColor: p.color }}
|
|
357
|
+
/>
|
|
358
|
+
<span className="text-theme-text-secondary font-mono" title={p.fullLabel}>
|
|
359
|
+
{p.label}
|
|
360
|
+
</span>
|
|
361
|
+
<span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
|
|
362
|
+
{formatMetricValue(p.value, unit)}
|
|
363
|
+
</span>
|
|
364
|
+
</div>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { formatMetricValue } from './format'
|
|
4
|
+
import type { TimeSeries } from './types'
|
|
5
|
+
|
|
6
|
+
export function MetricsSummary({ series, unit, currentColorClass }: {
|
|
7
|
+
series: TimeSeries[]
|
|
8
|
+
unit: string
|
|
9
|
+
/** Tailwind text class for the "Current" pill — caller's accent color. */
|
|
10
|
+
currentColorClass?: string
|
|
11
|
+
}) {
|
|
12
|
+
const stats = useMemo(() => {
|
|
13
|
+
const allValues: number[] = []
|
|
14
|
+
for (const s of series) {
|
|
15
|
+
for (const dp of s.dataPoints) {
|
|
16
|
+
allValues.push(dp.value)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (allValues.length === 0) return null
|
|
20
|
+
|
|
21
|
+
// Current = sum of each series' most recent data point (matches
|
|
22
|
+
// operator mental model of "total across pods right now").
|
|
23
|
+
const lastValues = series.map(s => s.dataPoints[s.dataPoints.length - 1]?.value ?? 0)
|
|
24
|
+
const current = lastValues.reduce((a, b) => a + b, 0)
|
|
25
|
+
const max = Math.max(...allValues)
|
|
26
|
+
const avg = allValues.reduce((a, b) => a + b, 0) / allValues.length
|
|
27
|
+
|
|
28
|
+
return { current, max, avg }
|
|
29
|
+
}, [series])
|
|
30
|
+
|
|
31
|
+
if (!stats) return null
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex items-center gap-6">
|
|
35
|
+
<StatPill label="Current" value={formatMetricValue(stats.current, unit)} className={currentColorClass} />
|
|
36
|
+
<StatPill label="Average" value={formatMetricValue(stats.avg, unit)} className="text-theme-text-secondary" />
|
|
37
|
+
<StatPill label="Peak" value={formatMetricValue(stats.max, unit)} className="text-theme-text-secondary" />
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function StatPill({ label, value, className }: { label: string; value: string; className?: string }) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex items-baseline gap-1.5">
|
|
45
|
+
<span className="text-xs text-theme-text-quaternary uppercase tracking-wide">{label}</span>
|
|
46
|
+
<span className={clsx('text-sm font-semibold tabular-nums', className)}>{value}</span>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { seriesColor } from './colors'
|
|
2
|
+
import type { TimeSeries } from './types'
|
|
3
|
+
|
|
4
|
+
// Caps visible entries to match AreaChart's SERIES_COLORS length; extras
|
|
5
|
+
// collapse to "+N more".
|
|
6
|
+
export function SeriesLegend({ series, color }: { series: TimeSeries[]; color: string }) {
|
|
7
|
+
const labels = series.map((s, i) => s.labels.pod || s.labels.instance || `series-${i}`)
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
|
10
|
+
{series.slice(0, 10).map((_, i) => {
|
|
11
|
+
const shortName = labels[i].length > 40 ? '...' + labels[i].slice(-37) : labels[i]
|
|
12
|
+
return (
|
|
13
|
+
<div key={i} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
14
|
+
<div
|
|
15
|
+
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
16
|
+
style={{ backgroundColor: seriesColor(i, color) }}
|
|
17
|
+
/>
|
|
18
|
+
<span className="truncate" title={labels[i]}>{shortName}</span>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
})}
|
|
22
|
+
{series.length > 10 && (
|
|
23
|
+
<span className="text-xs text-theme-text-quaternary">+{series.length - 10} more</span>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Distinct colors for multi-series charts (up to 10 series).
|
|
2
|
+
// Uses 500-level shades for adequate contrast on both dark (#1e293b) and
|
|
3
|
+
// light (#ffffff) surfaces.
|
|
4
|
+
export const SERIES_COLORS: readonly string[] = [
|
|
5
|
+
'#3b82f6', // blue-500
|
|
6
|
+
'#10b981', // emerald-500
|
|
7
|
+
'#f97316', // orange-500
|
|
8
|
+
'#a855f7', // purple-500
|
|
9
|
+
'#ec4899', // pink-500
|
|
10
|
+
'#eab308', // yellow-500
|
|
11
|
+
'#06b6d4', // cyan-500
|
|
12
|
+
'#84cc16', // lime-500
|
|
13
|
+
'#ef4444', // red-500
|
|
14
|
+
'#6366f1', // indigo-500
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export function seriesColor(index: number, fallback: string): string {
|
|
18
|
+
return SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function seriesFill(index: number, fallback: string): string {
|
|
22
|
+
return (SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback) + '22'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Strip the shared prefix from a set of labels so the differentiating suffix
|
|
27
|
+
* is what's shown. Example:
|
|
28
|
+
* ["backend-podinfo-849bd668f9-4tzkg", "backend-podinfo-849bd668f9-5z79f"]
|
|
29
|
+
* → ["4tzkg", "5z79f"]
|
|
30
|
+
*
|
|
31
|
+
* If stripping would leave empty strings or duplicates, falls back to the
|
|
32
|
+
* original labels — we'd rather show a long-but-correct label than a short
|
|
33
|
+
* misleading one.
|
|
34
|
+
*/
|
|
35
|
+
export function computeShortLabels(labels: string[]): string[] {
|
|
36
|
+
if (labels.length <= 1) return labels
|
|
37
|
+
let prefix = labels[0]
|
|
38
|
+
for (let i = 1; i < labels.length; i++) {
|
|
39
|
+
while (!labels[i].startsWith(prefix)) {
|
|
40
|
+
prefix = prefix.slice(0, -1)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const lastSep = Math.max(prefix.lastIndexOf('-'), prefix.lastIndexOf('/'))
|
|
44
|
+
if (lastSep > 0) prefix = prefix.slice(0, lastSep + 1)
|
|
45
|
+
|
|
46
|
+
const suffixes = labels.map(l => l.slice(prefix.length))
|
|
47
|
+
if (suffixes.some(s => s === '') || new Set(suffixes).size !== suffixes.length) return labels
|
|
48
|
+
return suffixes
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Compact human-readable formatters for chart values and timestamps.
|
|
2
|
+
// Unit-tier breakpoints chosen so the rendered text stays short enough to
|
|
3
|
+
// fit inside chart axis labels and tooltips at typical font sizes.
|
|
4
|
+
|
|
5
|
+
export function formatMetricValue(value: number, unit: string): string {
|
|
6
|
+
if (value === 0) return '0'
|
|
7
|
+
|
|
8
|
+
switch (unit) {
|
|
9
|
+
case 'cores': {
|
|
10
|
+
if (value < 0.0001) return '< 0.1m'
|
|
11
|
+
if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
|
|
12
|
+
if (value < 1) return `${(value * 1000).toFixed(0)}m`
|
|
13
|
+
return `${value.toFixed(2)}`
|
|
14
|
+
}
|
|
15
|
+
case 'bytes': {
|
|
16
|
+
if (value < 1) return '< 1 B'
|
|
17
|
+
if (value < 1024) return `${value.toFixed(0)} B`
|
|
18
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`
|
|
19
|
+
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB`
|
|
20
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB`
|
|
21
|
+
}
|
|
22
|
+
case 'bytes/s': {
|
|
23
|
+
if (value < 1) return '< 1 B/s'
|
|
24
|
+
if (value < 1024) return `${value.toFixed(0)} B/s`
|
|
25
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB/s`
|
|
26
|
+
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
|
|
27
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
|
|
28
|
+
}
|
|
29
|
+
default:
|
|
30
|
+
if (value < 0.01) return value.toExponential(1)
|
|
31
|
+
if (value < 1) return value.toFixed(3)
|
|
32
|
+
if (value < 100) return value.toFixed(2)
|
|
33
|
+
if (value < 10000) return value.toFixed(0)
|
|
34
|
+
return `${(value / 1000).toFixed(1)}k`
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatTimestamp(unix: number): string {
|
|
39
|
+
const d = new Date(unix * 1000)
|
|
40
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { AreaChart } from './AreaChart'
|
|
2
|
+
export { MetricsSummary } from './MetricsSummary'
|
|
3
|
+
export { SeriesLegend } from './SeriesLegend'
|
|
4
|
+
export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels } from './colors'
|
|
5
|
+
export { formatMetricValue, formatTimestamp } from './format'
|
|
6
|
+
export { computeSaturation } from './saturation'
|
|
7
|
+
export type {
|
|
8
|
+
TimeSeriesPoint,
|
|
9
|
+
TimeSeries,
|
|
10
|
+
ReferenceLine,
|
|
11
|
+
// Deprecated Prom-prefixed aliases — see types.ts.
|
|
12
|
+
PrometheusDataPoint,
|
|
13
|
+
PrometheusSeries,
|
|
14
|
+
} from './types'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computeSaturation } from './saturation'
|
|
3
|
+
import type { ReferenceLine, TimeSeries } from './types'
|
|
4
|
+
|
|
5
|
+
const series = (values: number[]): TimeSeries => ({
|
|
6
|
+
labels: { pod: 'p' },
|
|
7
|
+
dataPoints: values.map((value, i) => ({ timestamp: i, value })),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const refRequest: ReferenceLine = { value: 100, label: 'request 100', kind: 'request' }
|
|
11
|
+
const refLimit: ReferenceLine = { value: 200, label: 'limit 200', kind: 'limit' }
|
|
12
|
+
|
|
13
|
+
describe('computeSaturation', () => {
|
|
14
|
+
it('returns undefined for empty series array', () => {
|
|
15
|
+
expect(computeSaturation([], [refLimit])).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns undefined when all datapoints are zero', () => {
|
|
19
|
+
expect(computeSaturation([series([0, 0, 0])], [refLimit])).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns undefined when no references are provided', () => {
|
|
23
|
+
expect(computeSaturation([series([10, 20, 30])], [])).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns undefined when the chosen ref has zero value', () => {
|
|
27
|
+
const zeroLimit: ReferenceLine = { value: 0, label: 'limit 0', kind: 'limit' }
|
|
28
|
+
expect(computeSaturation([series([10, 20])], [zeroLimit])).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uses request when only request is present', () => {
|
|
32
|
+
const result = computeSaturation([series([25, 50])], [refRequest])
|
|
33
|
+
expect(result).toEqual({ ratio: 0.5, against: 'request' })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('prefers limit over request when both are present', () => {
|
|
37
|
+
const result = computeSaturation([series([100])], [refRequest, refLimit])
|
|
38
|
+
// peak=100, against limit=200 → 0.5
|
|
39
|
+
expect(result).toEqual({ ratio: 0.5, against: 'limit' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('peaks across all data points and all series', () => {
|
|
43
|
+
const result = computeSaturation(
|
|
44
|
+
[series([10, 20]), series([5, 180, 30])],
|
|
45
|
+
[refLimit],
|
|
46
|
+
)
|
|
47
|
+
expect(result).toEqual({ ratio: 180 / 200, against: 'limit' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('handles ratio > 1 (workload exceeds its ceiling)', () => {
|
|
51
|
+
const result = computeSaturation([series([300])], [refLimit])
|
|
52
|
+
expect(result?.ratio).toBeCloseTo(1.5)
|
|
53
|
+
expect(result?.against).toBe('limit')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('ignores zero-valued samples but uses positive ones for peak', () => {
|
|
57
|
+
const result = computeSaturation([series([0, 0, 50, 0])], [refLimit])
|
|
58
|
+
expect(result?.ratio).toBeCloseTo(0.25)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReferenceLine, TimeSeries } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute panel saturation as a ratio of peak observed value to its
|
|
5
|
+
* operational ceiling. Picks the limit reference when both are present
|
|
6
|
+
* (limit is the OOM/throttle boundary; request is the scheduler reservation —
|
|
7
|
+
* the operationally meaningful number).
|
|
8
|
+
*
|
|
9
|
+
* Returns `undefined` for:
|
|
10
|
+
* - empty series (nothing to derive a peak from)
|
|
11
|
+
* - all-zero data (peak <= 0)
|
|
12
|
+
* - no usable reference (missing, or ref.value <= 0)
|
|
13
|
+
*
|
|
14
|
+
* Callers should treat `undefined` as "don't render a saturation chip".
|
|
15
|
+
*/
|
|
16
|
+
export function computeSaturation(
|
|
17
|
+
series: TimeSeries[],
|
|
18
|
+
refs: ReferenceLine[],
|
|
19
|
+
): { ratio: number; against: 'limit' | 'request' } | undefined {
|
|
20
|
+
let peak = 0
|
|
21
|
+
for (const s of series) {
|
|
22
|
+
for (const dp of s.dataPoints) {
|
|
23
|
+
if (dp.value > peak) peak = dp.value
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (peak <= 0) return undefined
|
|
27
|
+
const limit = refs.find(r => r.kind === 'limit')
|
|
28
|
+
const request = refs.find(r => r.kind === 'request')
|
|
29
|
+
const ref = limit ?? request
|
|
30
|
+
if (!ref || ref.value <= 0) return undefined
|
|
31
|
+
return { ratio: peak / ref.value, against: limit ? 'limit' : 'request' }
|
|
32
|
+
}
|