@skyhook-io/radar-app 1.1.0 → 1.1.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 +2 -2
- package/src/App.tsx +81 -18
- package/src/api/client.ts +200 -26
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +258 -1862
- package/src/components/helm/ChartBrowser.tsx +61 -10
- package/src/components/helm/HelmView.tsx +28 -11
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -1
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/contexts/CapabilitiesContext.tsx +8 -3
- package/src/components/gitops/RollbackDialog.tsx +0 -107
- package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
|
@@ -1,24 +1,32 @@
|
|
|
1
|
-
import { useState, useMemo
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
//
|
|
279
|
+
// Request/limit overlay derivation
|
|
253
280
|
// ============================================================================
|
|
254
281
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
291
|
-
return
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
617
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
// ============================================================================
|