@skyhook-io/k8s-ui 1.6.0 → 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.
Files changed (79) hide show
  1. package/package.json +7 -2
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  25. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  26. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  27. package/src/components/gitops/RollbackDialog.tsx +117 -0
  28. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  29. package/src/components/gitops/detail-helpers.test.ts +97 -0
  30. package/src/components/gitops/detail-helpers.ts +112 -0
  31. package/src/components/gitops/index.ts +52 -1
  32. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  33. package/src/components/gitops/tree/index.ts +2 -0
  34. package/src/components/gitops/tree/merge.test.ts +240 -0
  35. package/src/components/gitops/tree/merge.ts +160 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  37. package/src/components/resources/ResourcesView.tsx +403 -69
  38. package/src/components/resources/index.ts +1 -0
  39. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  40. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  41. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  42. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  43. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  44. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  45. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  46. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  48. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  49. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  50. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  52. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  53. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  54. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  55. package/src/components/resources/renderers/index.ts +8 -0
  56. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  57. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  58. package/src/components/resources/resource-utils-flux.ts +14 -0
  59. package/src/components/resources/resource-utils.ts +1 -0
  60. package/src/components/resources/resources-column-filter.test.ts +74 -0
  61. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  62. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  63. package/src/components/ui/YamlEditor.tsx +28 -15
  64. package/src/components/ui/drawer-components.tsx +8 -20
  65. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  66. package/src/components/workload/WorkloadView.tsx +2 -2
  67. package/src/index.ts +3 -0
  68. package/src/types/core.ts +31 -6
  69. package/src/types/index.ts +1 -0
  70. package/src/types/rbac.ts +99 -0
  71. package/src/utils/api-resources.ts +9 -1
  72. package/src/utils/badge-colors.ts +25 -0
  73. package/src/utils/gitops-owner.test.ts +70 -111
  74. package/src/utils/gitops-owner.ts +37 -74
  75. package/src/utils/helm-status.test.ts +50 -0
  76. package/src/utils/index.ts +1 -0
  77. package/src/utils/rbac-badges.ts +61 -0
  78. package/src/utils/rbac-blast-radius.test.ts +137 -0
  79. 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.0",
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"
@@ -87,7 +92,7 @@
87
92
  "elkjs": "^0.11.1",
88
93
  "lucide-react": "^1.12.0",
89
94
  "react": "^19.2.6",
90
- "react-dom": "^19.2.4",
95
+ "react-dom": "^19.2.6",
91
96
  "typescript": "^6.0.2",
92
97
  "vitest": "^4.1.5",
93
98
  "yaml": "^2.9.0"
@@ -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
+ }