@skyhook-io/radar-app 1.1.1 → 1.2.0

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 (40) hide show
  1. package/package.json +2 -1
  2. package/src/App.tsx +167 -64
  3. package/src/api/client.ts +197 -11
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/dock/TerminalTab.tsx +1 -1
  10. package/src/components/gitops/GitOpsView.tsx +1 -1
  11. package/src/components/helm/InstallWizard.tsx +5 -5
  12. package/src/components/helm/ValuesViewer.tsx +3 -39
  13. package/src/components/home/ClusterHealthCard.tsx +17 -13
  14. package/src/components/home/HomeView.tsx +18 -2
  15. package/src/components/home/MCPSetupDialog.tsx +5 -3
  16. package/src/components/resource/HPACharts.tsx +232 -0
  17. package/src/components/resource/PVCUsageBar.tsx +59 -0
  18. package/src/components/resource/PrometheusCharts.tsx +151 -434
  19. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  20. package/src/components/resource/RestartChart.tsx +124 -0
  21. package/src/components/resource/RightsizingStrip.tsx +167 -0
  22. package/src/components/resources/CompositeRenderer.tsx +101 -0
  23. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  24. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  25. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  26. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  27. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  28. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  29. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  30. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  31. package/src/components/resources/renderers/index.ts +1 -0
  32. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  33. package/src/components/traffic/TrafficFlowList.tsx +16 -11
  34. package/src/components/traffic/TrafficGraph.tsx +5 -1
  35. package/src/components/ui/DiagnosticsOverlay.tsx +127 -8
  36. package/src/components/workload/WorkloadView.tsx +107 -3
  37. package/src/context/NavCustomization.tsx +13 -0
  38. package/src/main.tsx +1 -0
  39. package/src/monaco-deep.d.ts +8 -0
  40. package/src/monaco-setup.ts +26 -0
@@ -1,24 +1,32 @@
1
- import { useState, useMemo, useRef, useCallback } from 'react'
1
+ import { useState, useMemo } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { BarChart3, Wifi, WifiOff, Loader2 } from 'lucide-react'
4
+ import {
5
+ AreaChart,
6
+ MetricsSummary as BaseMetricsSummary,
7
+ SeriesLegend,
8
+ type TimeSeries,
9
+ type ReferenceLine,
10
+ } from '@skyhook-io/k8s-ui/components/charts'
4
11
  import {
5
12
  usePrometheusStatus,
6
13
  usePrometheusConnect,
7
14
  usePrometheusResourceMetrics,
15
+ useAutoPromConnect,
8
16
  type PrometheusMetricCategory,
9
17
  type PrometheusTimeRange,
10
- type PrometheusSeries,
11
18
  } from '../../api/client'
12
19
 
13
20
  // ============================================================================
14
- // Types & Constants
21
+ // Radar-specific category config (lives here, not in k8s-ui, so consumers
22
+ // reusing AreaChart aren't forced to inherit Radar's category vocabulary).
15
23
  // ============================================================================
16
24
 
17
25
  const SUPPORTED_KINDS = new Set([
18
26
  'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
19
27
  ])
20
28
 
21
- interface CategoryDef {
29
+ export interface CategoryDef {
22
30
  key: PrometheusMetricCategory
23
31
  label: string
24
32
  color: string // tailwind text class
@@ -26,7 +34,7 @@ interface CategoryDef {
26
34
  fillColor: string // hex with alpha for SVG fill
27
35
  }
28
36
 
29
- const WORKLOAD_CATEGORIES: CategoryDef[] = [
37
+ export const WORKLOAD_CATEGORIES: CategoryDef[] = [
30
38
  { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
31
39
  { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
32
40
  { key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
@@ -34,28 +42,13 @@ const WORKLOAD_CATEGORIES: CategoryDef[] = [
34
42
  { key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
35
43
  ]
36
44
 
37
- const NODE_CATEGORIES: CategoryDef[] = [
45
+ export const NODE_CATEGORIES: CategoryDef[] = [
38
46
  { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
39
47
  { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
40
48
  { key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
41
49
  ]
42
50
 
43
- // Distinct colors for multi-series charts (up to 10 series).
44
- // Uses 500-level shades for adequate contrast on both dark (#1e293b) and light (#ffffff) surfaces.
45
- const SERIES_COLORS = [
46
- '#3b82f6', // blue-500
47
- '#10b981', // emerald-500
48
- '#f97316', // orange-500
49
- '#a855f7', // purple-500
50
- '#ec4899', // pink-500
51
- '#eab308', // yellow-500
52
- '#06b6d4', // cyan-500
53
- '#84cc16', // lime-500
54
- '#ef4444', // red-500
55
- '#6366f1', // indigo-500
56
- ]
57
-
58
- const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
51
+ export const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
59
52
  { value: '10m', label: '10m' },
60
53
  { value: '30m', label: '30m' },
61
54
  { value: '1h', label: '1h' },
@@ -66,6 +59,16 @@ const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
66
59
  { value: '7d', label: '7d' },
67
60
  ]
68
61
 
62
+ // Radar's MetricsSummary thin wrapper — adapts CategoryDef to the slim
63
+ // interface of the shared k8s-ui primitive so callers downstream don't change.
64
+ export function MetricsSummary({ series, category, unit }: {
65
+ series: TimeSeries[]
66
+ category: CategoryDef
67
+ unit: string
68
+ }) {
69
+ return <BaseMetricsSummary series={series} unit={unit} currentColorClass={category.color} />
70
+ }
71
+
69
72
  // ============================================================================
70
73
  // Main Component
71
74
  // ============================================================================
@@ -76,9 +79,19 @@ interface PrometheusChartsProps {
76
79
  name: string
77
80
  /** When true, show "no data" empty state instead of hiding. Defaults to false (hide when no data). */
78
81
  showEmptyState?: boolean
82
+ /**
83
+ * Full K8s resource. When provided, CPU and memory charts overlay the
84
+ * aggregate request/limit (summed across runtime containers including
85
+ * native sidecars, multiplied by readyReplicas for replicated workloads).
86
+ */
87
+ resource?: any
88
+ /** Notifies the parent when the user changes the time range, so sibling
89
+ * panels (e.g. restart lane) can mirror the selection. */
90
+ onTimeRangeChange?: (range: PrometheusTimeRange) => void
79
91
  }
80
92
 
81
- export function PrometheusCharts({ kind, namespace, name, showEmptyState = false }: PrometheusChartsProps) {
93
+ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false, resource, onTimeRangeChange }: PrometheusChartsProps) {
94
+ useAutoPromConnect()
82
95
  const { data: status, isLoading: statusLoading } = usePrometheusStatus()
83
96
  const connectMutation = usePrometheusConnect()
84
97
 
@@ -95,6 +108,14 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
95
108
  isConnected && isSupported,
96
109
  )
97
110
 
111
+ // Reference lines: aggregate request/limit overlaid on CPU and memory charts.
112
+ // Computed unconditionally and placed above early returns to keep hook order
113
+ // stable when connection state transitions (Rules of Hooks).
114
+ const referenceLines = useMemo<ReferenceLine[] | undefined>(() => {
115
+ if (!resource || (activeCategory !== 'cpu' && activeCategory !== 'memory')) return undefined
116
+ return computeRequestLimitLines(resource, kind, activeCategory)
117
+ }, [resource, kind, activeCategory])
118
+
98
119
  if (!isSupported) {
99
120
  return null
100
121
  }
@@ -170,7 +191,11 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
170
191
  {/* Time range selector */}
171
192
  <select
172
193
  value={timeRange}
173
- onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
194
+ onChange={e => {
195
+ const next = e.target.value as PrometheusTimeRange
196
+ setTimeRange(next)
197
+ onTimeRangeChange?.(next)
198
+ }}
174
199
  className="px-2 py-1 text-xs rounded-md bg-theme-elevated border border-theme-border text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
175
200
  >
176
201
  {TIME_RANGES.map(tr => (
@@ -206,6 +231,7 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
206
231
  color={activeCategoryDef.chartColor}
207
232
  fillColor={activeCategoryDef.fillColor}
208
233
  unit={metrics.unit}
234
+ referenceLines={referenceLines}
209
235
  />
210
236
  </div>
211
237
 
@@ -248,434 +274,125 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
248
274
  )
249
275
  }
250
276
 
277
+
251
278
  // ============================================================================
252
- // Sub-Components
279
+ // Request/limit overlay derivation
253
280
  // ============================================================================
254
281
 
255
- function MetricsSummary({ series, category, unit }: {
256
- series: PrometheusSeries[]
257
- category: CategoryDef
258
- unit: string
259
- }) {
260
- const stats = useMemo(() => {
261
- // Aggregate all data points across series
262
- const allValues: number[] = []
263
- for (const s of series) {
264
- for (const dp of s.dataPoints) {
265
- allValues.push(dp.value)
266
- }
267
- }
268
- if (allValues.length === 0) return null
269
-
270
- // Latest = sum of each series' most recent data point
271
- const lastValues = series.map(s => s.dataPoints[s.dataPoints.length - 1]?.value ?? 0)
272
- const current = lastValues.reduce((a, b) => a + b, 0)
273
- const max = Math.max(...allValues)
274
- const avg = allValues.reduce((a, b) => a + b, 0) / allValues.length
275
-
276
- return { current, max, avg }
277
- }, [series])
278
-
279
- if (!stats) return null
282
+ /**
283
+ * Compute aggregate request + limit reference lines from a K8s resource spec.
284
+ * Sums across runtime containers (regular + native sidecars), excluding pure
285
+ * init containers. The values are per-pod — workload charts use
286
+ * `sum(...) by (pod, namespace)` (one series per pod, at per-pod scale), so
287
+ * the reference line lives on the same axis without any replica multiplier.
288
+ *
289
+ * Returns undefined when the spec doesn't have enough information to render
290
+ * a meaningful line (no runtime containers, or no values set on any container).
291
+ */
292
+ export function computeRequestLimitLines(
293
+ resource: any,
294
+ kind: string,
295
+ category: 'cpu' | 'memory',
296
+ ): ReferenceLine[] | undefined {
297
+ if (!resource) return undefined
298
+ const podSpec = extractPodSpec(resource, kind)
299
+ if (!podSpec) return undefined
300
+
301
+ const runtimeContainers = collectRuntimeContainers(podSpec)
302
+ if (runtimeContainers.length === 0) return undefined
303
+
304
+ let reqSum = 0, reqAny = false
305
+ let limSum = 0, limAny = false
306
+ for (const c of runtimeContainers) {
307
+ const req = readQuantity(c.resources?.requests?.[category], category)
308
+ const lim = readQuantity(c.resources?.limits?.[category], category)
309
+ if (req != null) { reqSum += req; reqAny = true }
310
+ if (lim != null) { limSum += lim; limAny = true }
311
+ }
280
312
 
281
- return (
282
- <div className="flex items-center gap-6">
283
- <StatPill label="Current" value={formatMetricValue(stats.current, unit)} className={category.color} />
284
- <StatPill label="Average" value={formatMetricValue(stats.avg, unit)} className="text-theme-text-secondary" />
285
- <StatPill label="Peak" value={formatMetricValue(stats.max, unit)} className="text-theme-text-secondary" />
286
- </div>
287
- )
313
+ const lines: ReferenceLine[] = []
314
+ if (reqAny) {
315
+ lines.push({
316
+ value: reqSum,
317
+ label: `request ${formatRequestLimitLabel(reqSum, category)}`,
318
+ kind: 'request',
319
+ })
320
+ }
321
+ if (limAny) {
322
+ lines.push({
323
+ value: limSum,
324
+ label: `limit ${formatRequestLimitLabel(limSum, category)}`,
325
+ kind: 'limit',
326
+ })
327
+ }
328
+ return lines.length > 0 ? lines : undefined
288
329
  }
289
330
 
290
- function StatPill({ label, value, className }: { label: string; value: string; className?: string }) {
291
- return (
292
- <div className="flex items-baseline gap-1.5">
293
- <span className="text-xs text-theme-text-quaternary uppercase tracking-wide">{label}</span>
294
- <span className={clsx('text-sm font-semibold tabular-nums', className)}>{value}</span>
295
- </div>
296
- )
331
+ function extractPodSpec(resource: any, kind: string): any | undefined {
332
+ if (kind === 'Pod') return resource?.spec
333
+ if (kind === 'CronJob') return resource?.spec?.jobTemplate?.spec?.template?.spec
334
+ return resource?.spec?.template?.spec
297
335
  }
298
336
 
299
- // ============================================================================
300
- // Area Chart (pure SVG, no dependencies)
301
- // ============================================================================
302
-
303
- function seriesColor(index: number, fallback: string): string {
304
- return SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback
337
+ function collectRuntimeContainers(podSpec: any): any[] {
338
+ const out: any[] = []
339
+ for (const c of (podSpec?.containers || [])) out.push(c)
340
+ // Native sidecars (initContainers with restartPolicy: Always, GA in 1.33)
341
+ // run for the pod's lifetime and contribute to steady-state usage. Pure
342
+ // init containers run to completion and don't.
343
+ for (const c of (podSpec?.initContainers || [])) {
344
+ if (c?.restartPolicy === 'Always') out.push(c)
345
+ }
346
+ return out
305
347
  }
306
348
 
307
- function seriesFill(index: number, fallback: string): string {
308
- return (SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback) + '22'
349
+ const CPU_SUFFIXES: Record<string, number> = { n: 1e-9, u: 1e-6, m: 1e-3 }
350
+ const MEMORY_SUFFIXES: Record<string, number> = {
351
+ Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4, Pi: 1024 ** 5, Ei: 1024 ** 6,
352
+ K: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15, E: 1e18,
309
353
  }
310
354
 
311
- // Compute short labels that strip the shared prefix so pods are distinguishable.
312
- // e.g. ["backend-podinfo-849bd668f9-4tzkg", "backend-podinfo-849bd668f9-5z79f"] ["4tzkg", "5z79f"]
313
- function computeShortLabels(labels: string[]): string[] {
314
- if (labels.length <= 1) return labels
315
- // Find longest common prefix
316
- let prefix = labels[0]
317
- for (let i = 1; i < labels.length; i++) {
318
- while (!labels[i].startsWith(prefix)) {
319
- prefix = prefix.slice(0, -1)
320
- }
355
+ // NOT a duplicate of @skyhook-io/k8s-ui/utils/format's parseCPUToNanocores /
356
+ // parseMemoryToBytes — those return 0 on invalid input. We need null so that
357
+ // "abcMi" doesn't silently poison the caller's running sum and produce a
358
+ // missing/zeroed reference line on the chart (real garbage data must be
359
+ // distinguishable from a legit 0).
360
+ function readQuantity(raw: unknown, category: 'cpu' | 'memory'): number | null {
361
+ if (raw == null) return null
362
+ const s = String(raw).trim()
363
+ if (s === '') return null
364
+ if (category === 'cpu') {
365
+ if (s.endsWith('m')) return scaleOrNull(s, CPU_SUFFIXES.m)
366
+ if (s.endsWith('n')) return scaleOrNull(s, CPU_SUFFIXES.n)
367
+ if (s.endsWith('u')) return scaleOrNull(s, CPU_SUFFIXES.u)
368
+ const v = parseFloat(s)
369
+ return isNaN(v) ? null : v
321
370
  }
322
- // Trim to last separator (- or /) for cleaner cuts
323
- const lastSep = Math.max(prefix.lastIndexOf('-'), prefix.lastIndexOf('/'))
324
- if (lastSep > 0) prefix = prefix.slice(0, lastSep + 1)
325
-
326
- const suffixes = labels.map(l => l.slice(prefix.length))
327
- // If stripping made them empty or all the same, fall back to originals
328
- if (suffixes.some(s => s === '') || new Set(suffixes).size !== suffixes.length) return labels
329
- return suffixes
330
- }
331
-
332
- function AreaChart({ series, color, fillColor, unit }: {
333
- series: PrometheusSeries[]
334
- color: string
335
- fillColor: string
336
- unit: string
337
- }) {
338
- const svgRef = useRef<SVGSVGElement>(null)
339
- const [hoverX, setHoverX] = useState<number | null>(null)
340
- const multiSeries = series.length > 1
341
-
342
- const chartData = useMemo(() => {
343
- if (!series.length) return null
344
-
345
- // Merge all series into a single timeline for the X axis
346
- let minTs = Infinity
347
- let maxTs = -Infinity
348
- let maxVal = 0
349
-
350
- for (const s of series) {
351
- for (const dp of s.dataPoints) {
352
- if (dp.timestamp < minTs) minTs = dp.timestamp
353
- if (dp.timestamp > maxTs) maxTs = dp.timestamp
354
- if (dp.value > maxVal) maxVal = dp.value
355
- }
356
- }
357
-
358
- if (minTs === maxTs) maxTs = minTs + 60
359
- if (maxVal === 0) {
360
- // Use a small unit-appropriate default so the Y-axis isn't misleadingly large
361
- maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
362
- }
363
-
364
- const padding = maxVal * 0.1
365
- const yMax = maxVal + padding
366
-
367
- return { minTs, maxTs, yMax, series }
368
- }, [series, unit])
369
-
370
- if (!chartData) return null
371
-
372
- const { minTs, maxTs, yMax } = chartData
373
- const width = 1000
374
- const height = 300
375
- const marginLeft = 60
376
- const marginRight = 20
377
- const marginTop = 10
378
- const marginBottom = 30
379
- const plotWidth = width - marginLeft - marginRight
380
- const plotHeight = height - marginTop - marginBottom
381
-
382
- const toX = (ts: number) => marginLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth
383
- const toY = (val: number) => marginTop + plotHeight - (val / yMax) * plotHeight
384
-
385
- // Y axis ticks
386
- const yTicks = useMemo(() => {
387
- const count = 4
388
- return Array.from({ length: count + 1 }, (_, i) => {
389
- const val = (yMax / count) * i
390
- return { val, y: toY(val), label: formatMetricValue(val, unit) }
391
- })
392
- }, [yMax, unit])
393
-
394
- // X axis ticks
395
- const xTicks = useMemo(() => {
396
- const count = 6
397
- return Array.from({ length: count + 1 }, (_, i) => {
398
- const ts = minTs + ((maxTs - minTs) / count) * i
399
- return { ts, x: toX(ts), label: formatTimestamp(ts) }
400
- })
401
- }, [minTs, maxTs])
402
-
403
- // Build paths for each series
404
- const paths = useMemo(() => {
405
- return chartData.series.map((s, seriesIdx) => {
406
- if (s.dataPoints.length < 2) return null
407
- const points = s.dataPoints.map(dp => ({ x: toX(dp.timestamp), y: toY(dp.value) }))
408
-
409
- const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
410
-
411
- // Area path: line + close to bottom
412
- const areaPath = linePath +
413
- ` L${points[points.length - 1].x},${marginTop + plotHeight}` +
414
- ` L${points[0].x},${marginTop + plotHeight} Z`
415
-
416
- return {
417
- linePath,
418
- areaPath,
419
- strokeColor: multiSeries ? seriesColor(seriesIdx, color) : color,
420
- areaFillColor: multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor,
421
- key: seriesIdx,
422
- }
423
- }).filter(Boolean)
424
- }, [chartData])
425
-
426
- // Hover data: find nearest data point per series at the hovered X position
427
- const hoverData = useMemo(() => {
428
- if (hoverX === null) return null
429
- const clampedX = Math.max(marginLeft, Math.min(marginLeft + plotWidth, hoverX))
430
- const frac = (clampedX - marginLeft) / plotWidth
431
- const ts = minTs + frac * (maxTs - minTs)
432
-
433
- const validSeries = chartData.series
434
- .map((s, i) => ({ s, i }))
435
- .filter(({ s }) => s.dataPoints.length >= 2)
436
-
437
- const fullLabels = validSeries.map(({ s, i }) =>
438
- s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
439
- )
440
- const shortLabels = computeShortLabels(fullLabels)
441
-
442
- const points = validSeries.map(({ s, i }, vi) => {
443
- let closest = s.dataPoints[0]
444
- let closestDist = Infinity
445
- for (const dp of s.dataPoints) {
446
- const dist = Math.abs(dp.timestamp - ts)
447
- if (dist < closestDist) {
448
- closestDist = dist
449
- closest = dp
450
- }
451
- }
452
- return {
453
- label: shortLabels[vi],
454
- fullLabel: fullLabels[vi],
455
- value: closest.value,
456
- y: toY(closest.value),
457
- color: multiSeries ? seriesColor(i, color) : color,
458
- }
459
- })
460
-
461
- return { ts, x: clampedX, points }
462
- }, [hoverX, chartData])
463
-
464
- // Convert client mouse coordinates to SVG viewBox coordinates
465
- const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
466
- const svg = svgRef.current
467
- if (!svg) return
468
- const ctm = svg.getScreenCTM()
469
- if (!ctm) return
470
- setHoverX((e.clientX - ctm.e) / ctm.a)
471
- }, [])
472
-
473
- return (
474
- <div className="relative">
475
- <svg
476
- ref={svgRef}
477
- viewBox={`0 0 ${width} ${height}`}
478
- className="w-full h-full"
479
- preserveAspectRatio="xMidYMid meet"
480
- >
481
- {/* Grid lines */}
482
- {yTicks.map((tick, i) => (
483
- <line
484
- key={`grid-${i}`}
485
- x1={marginLeft}
486
- y1={tick.y}
487
- x2={width - marginRight}
488
- y2={tick.y}
489
- stroke="currentColor"
490
- className="text-theme-border/30"
491
- strokeWidth="1"
492
- strokeDasharray={i === 0 ? undefined : '4 4'}
493
- />
494
- ))}
495
-
496
- {/* Y axis labels */}
497
- {yTicks.map((tick, i) => (
498
- <text
499
- key={`ylabel-${i}`}
500
- x={marginLeft - 8}
501
- y={tick.y + 4}
502
- textAnchor="end"
503
- className="fill-theme-text-secondary"
504
- fontSize="11"
505
- fontFamily="ui-monospace, monospace"
506
- >
507
- {tick.label}
508
- </text>
509
- ))}
510
-
511
- {/* X axis labels */}
512
- {xTicks.map((tick, i) => (
513
- <text
514
- key={`xlabel-${i}`}
515
- x={tick.x}
516
- y={height - 4}
517
- textAnchor="middle"
518
- className="fill-theme-text-secondary"
519
- fontSize="11"
520
- fontFamily="ui-monospace, monospace"
521
- >
522
- {tick.label}
523
- </text>
524
- ))}
525
-
526
- {/* Area fills */}
527
- {paths.map(p => p && (
528
- <path
529
- key={`area-${p.key}`}
530
- d={p.areaPath}
531
- fill={p.areaFillColor}
532
- />
533
- ))}
534
-
535
- {/* Lines */}
536
- {paths.map(p => p && (
537
- <path
538
- key={`line-${p.key}`}
539
- d={p.linePath}
540
- fill="none"
541
- stroke={p.strokeColor}
542
- strokeWidth="2"
543
- strokeLinejoin="round"
544
- />
545
- ))}
546
-
547
- {/* Hover crosshair + dots */}
548
- {hoverData && (
549
- <>
550
- <line
551
- x1={hoverData.x} y1={marginTop}
552
- x2={hoverData.x} y2={marginTop + plotHeight}
553
- stroke="currentColor"
554
- className="text-theme-text-tertiary"
555
- strokeWidth="1"
556
- strokeDasharray="4 4"
557
- />
558
- {hoverData.points.map((p, i) => (
559
- <circle
560
- key={i}
561
- cx={hoverData.x} cy={p.y}
562
- r="4"
563
- fill={p.color}
564
- stroke="var(--color-theme-surface, #1a1a2e)"
565
- strokeWidth="2"
566
- />
567
- ))}
568
- </>
569
- )}
570
-
571
- {/* Invisible overlay for mouse events — must be last for event capture */}
572
- <rect
573
- x={marginLeft} y={marginTop}
574
- width={plotWidth} height={plotHeight}
575
- fill="transparent"
576
- style={{ cursor: 'crosshair' }}
577
- onMouseMove={handleMouseMove}
578
- onMouseLeave={() => setHoverX(null)}
579
- />
580
- </svg>
581
-
582
- {/* Tooltip positioned outside SVG for proper HTML rendering */}
583
- {hoverData && (
584
- <div
585
- className="absolute top-0 pointer-events-none z-10"
586
- style={{
587
- left: `${(hoverData.x / width) * 100}%`,
588
- transform: hoverData.x > width * 0.65 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)',
589
- }}
590
- >
591
- <div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
592
- <div className="text-theme-text-tertiary mb-1.5 font-mono">
593
- {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
594
- </div>
595
- {hoverData.points.map((p, i) => (
596
- <div key={i} className="flex items-center gap-2 py-0.5">
597
- <div
598
- className="w-2 h-2 rounded-full shrink-0"
599
- style={{ backgroundColor: p.color }}
600
- />
601
- <span className="text-theme-text-secondary font-mono" title={p.fullLabel}>
602
- {p.label}
603
- </span>
604
- <span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
605
- {formatMetricValue(p.value, unit)}
606
- </span>
607
- </div>
608
- ))}
609
- </div>
610
- </div>
611
- )}
612
- </div>
613
- )
371
+ // Memory: try two-character then one-character suffixes (Mi before M).
372
+ for (const suffix of ['Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei']) {
373
+ if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
374
+ }
375
+ for (const suffix of ['K', 'M', 'G', 'T', 'P', 'E']) {
376
+ if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
377
+ }
378
+ const v = parseFloat(s)
379
+ return isNaN(v) ? null : v
614
380
  }
615
381
 
616
- function SeriesLegend({ series, color }: { series: PrometheusSeries[]; color: string }) {
617
- const labels = series.map((s, i) => s.labels.pod || s.labels.instance || `series-${i}`)
618
- return (
619
- <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
620
- {series.slice(0, 10).map((_, i) => {
621
- const shortName = labels[i].length > 40 ? '...' + labels[i].slice(-37) : labels[i]
622
- return (
623
- <div key={i} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
624
- <div
625
- className="w-2.5 h-2.5 rounded-full shrink-0"
626
- style={{ backgroundColor: seriesColor(i, color) }}
627
- />
628
- <span className="truncate" title={labels[i]}>{shortName}</span>
629
- </div>
630
- )
631
- })}
632
- {series.length > 10 && (
633
- <span className="text-xs text-theme-text-quaternary">+{series.length - 10} more</span>
634
- )}
635
- </div>
636
- )
382
+ function scaleOrNull(s: string, scale: number): number | null {
383
+ const v = parseFloat(s)
384
+ return isNaN(v) ? null : v * scale
637
385
  }
638
386
 
639
- // ============================================================================
640
- // Formatters
641
- // ============================================================================
642
-
643
- function formatMetricValue(value: number, unit: string): string {
644
- if (value === 0) return '0'
645
-
646
- switch (unit) {
647
- case 'cores': {
648
- if (value < 0.0001) return '< 0.1m'
649
- if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
650
- if (value < 1) return `${(value * 1000).toFixed(0)}m`
651
- return `${value.toFixed(2)}`
652
- }
653
- case 'bytes': {
654
- if (value < 1) return '< 1 B'
655
- if (value < 1024) return `${value.toFixed(0)} B`
656
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`
657
- if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB`
658
- return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB`
659
- }
660
- case 'bytes/s': {
661
- if (value < 1) return '< 1 B/s'
662
- if (value < 1024) return `${value.toFixed(0)} B/s`
663
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB/s`
664
- if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
665
- return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
666
- }
667
- default:
668
- if (value < 0.01) return value.toExponential(1)
669
- if (value < 1) return value.toFixed(3)
670
- if (value < 100) return value.toFixed(2)
671
- if (value < 10000) return value.toFixed(0)
672
- return `${(value / 1000).toFixed(1)}k`
387
+ function formatRequestLimitLabel(value: number, category: 'cpu' | 'memory'): string {
388
+ if (category === 'cpu') {
389
+ if (value < 1) return `${Math.round(value * 1000)}m`
390
+ return value.toFixed(2).replace(/\.?0+$/, '')
673
391
  }
674
- }
675
-
676
- function formatTimestamp(unix: number): string {
677
- const d = new Date(unix * 1000)
678
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
392
+ // Memory — match formatMetricValue's tier breakpoints.
393
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)}KiB`
394
+ if (value < 1024 ** 3) return `${(value / (1024 ** 2)).toFixed(0)}MiB`
395
+ return `${(value / (1024 ** 3)).toFixed(1)}GiB`
679
396
  }
680
397
 
681
398
  // ============================================================================