@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
@@ -0,0 +1,339 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { BarChart3, ChevronDown, ChevronRight, Loader2, Wifi, WifiOff } from 'lucide-react'
3
+ import {
4
+ AreaChart,
5
+ SeriesLegend,
6
+ computeSaturation,
7
+ type ReferenceLine,
8
+ } from '@skyhook-io/k8s-ui/components/charts'
9
+ import { SEVERITY_BADGE, SEVERITY_TEXT, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
10
+ import {
11
+ usePrometheusStatus,
12
+ usePrometheusConnect,
13
+ usePrometheusResourceMetrics,
14
+ usePrometheusRightsizing,
15
+ useAutoPromConnect,
16
+ type PrometheusMetricCategory,
17
+ type PrometheusTimeRange,
18
+ type RightsizingTone,
19
+ } from '../../api/client'
20
+ import {
21
+ MetricsSummary,
22
+ TIME_RANGES,
23
+ WORKLOAD_CATEGORIES,
24
+ NODE_CATEGORIES,
25
+ computeRequestLimitLines,
26
+ type CategoryDef,
27
+ } from './PrometheusCharts'
28
+ import { RestartEventLane } from './RestartChart'
29
+
30
+ // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
31
+ // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
32
+ // can't fit the grid cleanly.
33
+ export interface PrometheusChartsGridProps {
34
+ kind: string
35
+ namespace: string
36
+ name: string
37
+ /** Optional full K8s resource for request/limit overlay derivation. */
38
+ resource?: any
39
+ }
40
+
41
+ const SUPPORTED_KINDS = new Set([
42
+ 'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
43
+ ])
44
+
45
+ export function PrometheusChartsGrid({
46
+ kind,
47
+ namespace,
48
+ name,
49
+ resource,
50
+ }: PrometheusChartsGridProps) {
51
+ // Node-kind workloads don't have container restart semantics — KSM would
52
+ // return empty series anyway, but suppressing the lane spares the query.
53
+ const showRestartLane = kind !== 'Node'
54
+ useAutoPromConnect()
55
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
56
+ const connectMutation = usePrometheusConnect()
57
+ const isConnected = status?.connected === true
58
+ const isSupported = SUPPORTED_KINDS.has(kind)
59
+
60
+ const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
61
+ const [diskExpanded, setDiskExpanded] = useState(false)
62
+
63
+ const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
64
+
65
+ // CPU + memory get reference-line overlays when a resource is provided.
66
+ // Computed once at the parent so each panel can stay otherwise generic.
67
+ const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
68
+ () => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
69
+ [resource, kind],
70
+ )
71
+ const memRefLines = useMemo<ReferenceLine[] | undefined>(
72
+ () => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
73
+ [resource, kind],
74
+ )
75
+
76
+ if (!isSupported) return null
77
+
78
+ if (statusLoading) {
79
+ return (
80
+ <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
81
+ <Loader2 className="w-5 h-5 animate-spin mr-2" />
82
+ Checking Prometheus availability...
83
+ </div>
84
+ )
85
+ }
86
+
87
+ if (!isConnected) {
88
+ return (
89
+ <div className="flex flex-col items-center justify-center py-12 gap-4">
90
+ <WifiOff className="w-10 h-10 text-theme-text-quaternary" />
91
+ <div className="text-center">
92
+ <p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
93
+ <p className="text-xs text-theme-text-tertiary mb-4">
94
+ {status?.error || 'Connect to view historical CPU, memory, and network metrics'}
95
+ </p>
96
+ <button
97
+ onClick={() => connectMutation.mutate()}
98
+ disabled={connectMutation.isPending}
99
+ className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
100
+ >
101
+ {connectMutation.isPending ? (
102
+ <Loader2 className="w-4 h-4 animate-spin" />
103
+ ) : (
104
+ <Wifi className="w-4 h-4" />
105
+ )}
106
+ Discover Prometheus
107
+ </button>
108
+ </div>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
114
+ categories.find(c => c.key === key)
115
+
116
+ // Disk I/O is collapsed by default — niche metric.
117
+ const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
118
+ const cpu = findCategory('cpu')
119
+ if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
120
+ const mem = findCategory('memory')
121
+ if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
122
+ if (kind !== 'Node') {
123
+ const rx = findCategory('network_rx')
124
+ if (rx) primaryCats.push({ def: rx })
125
+ const tx = findCategory('network_tx')
126
+ if (tx) primaryCats.push({ def: tx })
127
+ }
128
+ const disk = findCategory('filesystem')
129
+
130
+ return (
131
+ <div className="flex flex-col h-full overflow-auto">
132
+ <div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-theme-border bg-theme-surface/50">
133
+ <div className="flex items-center gap-2 text-sm font-medium text-theme-text-secondary">
134
+ <BarChart3 className="w-4 h-4 text-theme-text-tertiary" />
135
+ Metrics
136
+ <WorkloadHealthBadge kind={kind} namespace={namespace} name={name} />
137
+ </div>
138
+ <select
139
+ value={timeRange}
140
+ onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
141
+ 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"
142
+ >
143
+ {TIME_RANGES.map(tr => (
144
+ <option key={tr.value} value={tr.value}>{tr.label}</option>
145
+ ))}
146
+ </select>
147
+ </div>
148
+
149
+ {/* Restart lane sits above the grid so its markers visually align with
150
+ the time axis of the charts below. */}
151
+ {showRestartLane && (
152
+ <div className="px-4 pt-3">
153
+ <RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
154
+ </div>
155
+ )}
156
+
157
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
158
+ {primaryCats.map(({ def, refLines }) => (
159
+ <MetricsPanel
160
+ key={def.key}
161
+ category={def}
162
+ kind={kind}
163
+ namespace={namespace}
164
+ name={name}
165
+ timeRange={timeRange}
166
+ referenceLines={refLines}
167
+ />
168
+ ))}
169
+ </div>
170
+
171
+ {disk && (
172
+ <div className="px-4 pb-4">
173
+ <button
174
+ type="button"
175
+ onClick={() => setDiskExpanded(v => !v)}
176
+ className="flex items-center gap-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary py-1"
177
+ >
178
+ {diskExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
179
+ Disk I/O
180
+ </button>
181
+ {diskExpanded && (
182
+ <div className="mt-2">
183
+ <MetricsPanel
184
+ category={disk}
185
+ kind={kind}
186
+ namespace={namespace}
187
+ name={name}
188
+ timeRange={timeRange}
189
+ />
190
+ </div>
191
+ )}
192
+ </div>
193
+ )}
194
+ </div>
195
+ )
196
+ }
197
+
198
+ interface MetricsPanelProps {
199
+ category: CategoryDef
200
+ kind: string
201
+ namespace: string
202
+ name: string
203
+ timeRange: PrometheusTimeRange
204
+ referenceLines?: ReferenceLine[]
205
+ }
206
+
207
+ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
208
+ const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
209
+ kind, namespace, name, category.key, timeRange, true,
210
+ )
211
+
212
+ const series = metrics?.result?.series
213
+ const hasData = (series?.length ?? 0) > 0
214
+
215
+ // "% of limit / request" derived from current peak vs reference lines.
216
+ // Without this, a low-utilization workload with a high limit looks like
217
+ // an empty chart — the user can't tell healthy from starved at a glance.
218
+ const saturation = hasData && series && referenceLines
219
+ ? computeSaturation(series, referenceLines)
220
+ : undefined
221
+
222
+ return (
223
+ <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
224
+ <header className="flex items-center justify-between mb-2 gap-3">
225
+ <div className="flex items-center gap-2">
226
+ <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
227
+ {category.label}
228
+ </h3>
229
+ {saturation && <SaturationChip {...saturation} />}
230
+ </div>
231
+ {hasData && series && (
232
+ <MetricsSummary series={series} category={category} unit={metrics!.unit} />
233
+ )}
234
+ </header>
235
+
236
+ <div className="flex-1 min-h-[200px]">
237
+ {isLoading ? (
238
+ <PanelLoading />
239
+ ) : error ? (
240
+ <PanelError message={(error as Error).message} />
241
+ ) : hasData && series ? (
242
+ <>
243
+ <AreaChart
244
+ series={series}
245
+ color={category.chartColor}
246
+ fillColor={category.fillColor}
247
+ unit={metrics!.unit}
248
+ referenceLines={referenceLines}
249
+ />
250
+ {series.length > 1 && (
251
+ <div className="mt-1.5">
252
+ <SeriesLegend series={series} color={category.chartColor} />
253
+ </div>
254
+ )}
255
+ </>
256
+ ) : (
257
+ <PanelNoData hint={metrics?.hint} />
258
+ )}
259
+ </div>
260
+ </section>
261
+ )
262
+ }
263
+
264
+ function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
265
+ // Thresholds match the rightsizing tone vocabulary: amber from 75% (start
266
+ // watching), red at 90% (the same OOM-risk boundary the backend uses for
267
+ // memory in classifyRightsizing).
268
+ const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
269
+ const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
270
+ return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
271
+ }
272
+
273
+ // Severity ordering for rightsizing tones. Aligned with `Tone` constants in
274
+ // `internal/prometheus/rightsizing.go` — adding a new tone there triggers a
275
+ // TypeScript exhaustiveness error on the Record key set here.
276
+ const TONE_RANK: Record<RightsizingTone, number> = {
277
+ ok: 0,
278
+ info: 1,
279
+ warning: 2,
280
+ alert: 3,
281
+ critical: 4,
282
+ }
283
+
284
+ function WorkloadHealthBadge({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
285
+ const supported = kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet'
286
+ const { data, error } = usePrometheusRightsizing(kind, namespace, name, supported)
287
+ if (!supported) return null
288
+ // Surface a neutral "Health unknown" pill when the rightsizing endpoint
289
+ // errors — otherwise an actually-throttled or OOM-risk workload would
290
+ // silently render as fine while we have no signal to display.
291
+ if (error && !data) {
292
+ const msg = error instanceof Error ? error.message : String(error)
293
+ return <span className={`badge badge-sm ${SEVERITY_BADGE.neutral}`} title={`Health check failed: ${msg}`}>Health unknown</span>
294
+ }
295
+ if (!data?.sampleAvailable || data.rows.length === 0) return null
296
+
297
+ const worst = data.rows.reduce<RightsizingTone>(
298
+ (acc, r) => (TONE_RANK[r.tone] > TONE_RANK[acc] ? r.tone : acc),
299
+ 'ok',
300
+ )
301
+ // Skip the chip for the steady-state tones to avoid badge-blindness — we
302
+ // only want to draw the eye when there's something to address.
303
+ if (worst === 'ok' || worst === 'info') return null
304
+
305
+ const { label, severity }: { label: string; severity: Severity } =
306
+ worst === 'critical' ? { label: 'OOM risk', severity: 'error' } :
307
+ worst === 'alert' ? { label: 'CPU throttling', severity: 'alert' } :
308
+ /* warning */ { label: 'Needs review', severity: 'warning' }
309
+ return <span className={`badge badge-sm ${SEVERITY_BADGE[severity]}`}>{label}</span>
310
+ }
311
+
312
+ function PanelLoading() {
313
+ return (
314
+ <div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
315
+ <Loader2 className="w-4 h-4 animate-spin mr-2" />
316
+ Loading...
317
+ </div>
318
+ )
319
+ }
320
+
321
+ function PanelError({ message }: { message: string }) {
322
+ return (
323
+ <div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
324
+ Query failed
325
+ <span className="text-theme-text-quaternary mt-0.5 line-clamp-2" title={message}>{message}</span>
326
+ </div>
327
+ )
328
+ }
329
+
330
+ function PanelNoData({ hint }: { hint?: string }) {
331
+ return (
332
+ <div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
333
+ No data
334
+ {hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
335
+ </div>
336
+ )
337
+ }
338
+
339
+ export { isPrometheusSupported } from './PrometheusCharts'
@@ -0,0 +1,124 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import { AlertCircle } from 'lucide-react'
3
+ import { usePrometheusResourceMetrics, usePrometheusStatus, type PrometheusSeries, type PrometheusTimeRange } from '../../api/client'
4
+
5
+ /**
6
+ * RestartEventLane — vertical markers at each restart event, on a dedicated
7
+ * row below the chart. Markers stay readable when they cluster because they
8
+ * don't overlay the chart waveform. KSM-gated (uses kube_pod_container_status_restarts_total)
9
+ * — silently hidden when Prom isn't connected or the series doesn't exist.
10
+ */
11
+ export function RestartEventLane({ kind, namespace, name, range = '1h' }: {
12
+ kind: string
13
+ namespace: string
14
+ name: string
15
+ range?: PrometheusTimeRange
16
+ }) {
17
+ const { data: status } = usePrometheusStatus()
18
+ const isConnected = status?.connected === true
19
+ const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(kind, namespace, name, 'restarts', range, isConnected)
20
+
21
+ const restarts = useMemo(() => collectRestartEvents(metrics?.result?.series), [metrics])
22
+
23
+ // A real Prom-side failure shouldn't look identical to "no restarts" — log
24
+ // it so an operator investigating a missing lane has a breadcrumb. The lane
25
+ // still hides because we don't want a permanent red banner on every pod.
26
+ // Effect-gated so we log once per error change, not on every re-render.
27
+ useEffect(() => {
28
+ if (error) {
29
+ console.warn('[RestartEventLane] restart query failed', error)
30
+ }
31
+ }, [error])
32
+
33
+ if (!isConnected || isLoading) return null
34
+ if (restarts.length === 0) return null
35
+
36
+ // Position markers within the chart's full time window — not the
37
+ // min/max of detected events — so a cluster of restarts at the start
38
+ // of the window doesn't visually spread across the whole lane.
39
+ const nowSec = Date.now() / 1000
40
+ const windowStart = nowSec - rangeToSeconds(range)
41
+ const span = nowSec - windowStart
42
+
43
+ return (
44
+ <div className="rounded-md border border-amber-500/20 bg-amber-500/[0.04] px-3 py-2">
45
+ <div className="flex items-center gap-2 mb-1.5">
46
+ <AlertCircle className="w-3.5 h-3.5 text-amber-500/70" />
47
+ <span className="text-xs font-medium text-theme-text-secondary">
48
+ Restarts in last {range}
49
+ </span>
50
+ <span className="text-xs text-theme-text-quaternary tabular-nums">
51
+ {restarts.reduce((n, r) => n + r.value, 0)} total
52
+ </span>
53
+ </div>
54
+ <div className="relative h-5">
55
+ {/* Baseline */}
56
+ <div className="absolute inset-x-0 top-1/2 h-px bg-theme-border/40" />
57
+ {/* Markers */}
58
+ {restarts.map((r, i) => {
59
+ const left = `${Math.max(0, Math.min(100, ((r.timestamp - windowStart) / span) * 100))}%`
60
+ return (
61
+ <div
62
+ key={i}
63
+ className="absolute top-0 h-full w-px bg-amber-500/80"
64
+ style={{ left }}
65
+ title={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
66
+ >
67
+ <div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-500" />
68
+ </div>
69
+ )
70
+ })}
71
+ </div>
72
+ </div>
73
+ )
74
+ }
75
+
76
+ // ============================================================================
77
+ // Internals
78
+ // ============================================================================
79
+
80
+ interface RestartEvent {
81
+ timestamp: number
82
+ value: number
83
+ label: string
84
+ }
85
+
86
+ function collectRestartEvents(series: PrometheusSeries[] | undefined): RestartEvent[] {
87
+ if (!series) return []
88
+ const events: RestartEvent[] = []
89
+ for (const s of series) {
90
+ const pod = s.labels.pod ?? 'pod'
91
+ // `changes(...[1h])` produces a rolling count, so a single restart shows
92
+ // value=1 for ~60 consecutive samples (the whole 1h window). Emit a marker
93
+ // only when the count increases — that's when a *new* restart entered the
94
+ // window — and use the increase as the marker's restart count.
95
+ let prev: number | null = null
96
+ for (const dp of s.dataPoints) {
97
+ if (prev !== null) {
98
+ // Only count positive deltas — restarts that entered the rolling 1h
99
+ // window during the chart range. The first sample's value covers
100
+ // [start-1h, start] which is outside the user's chosen window; counting
101
+ // it would inflate the total with pre-window restarts.
102
+ const delta = dp.value - prev
103
+ if (delta > 0) {
104
+ events.push({ timestamp: dp.timestamp, value: delta, label: pod })
105
+ }
106
+ }
107
+ prev = dp.value
108
+ }
109
+ }
110
+ events.sort((a, b) => a.timestamp - b.timestamp)
111
+ return events
112
+ }
113
+
114
+ function rangeToSeconds(range: PrometheusTimeRange): number {
115
+ const match = range.match(/^(\d+)([mhd])$/)
116
+ if (!match) return 3600 // default to 1h if unrecognized
117
+ const n = parseInt(match[1], 10)
118
+ switch (match[2]) {
119
+ case 'm': return n * 60
120
+ case 'h': return n * 3600
121
+ case 'd': return n * 86400
122
+ default: return 3600
123
+ }
124
+ }
@@ -0,0 +1,167 @@
1
+ import { ArrowRight, Check, Info, AlertTriangle } from 'lucide-react'
2
+ import { SEVERITY_TEXT, SEVERITY_BADGE, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
3
+ import { usePrometheusRightsizing, usePrometheusStatus, type RightsizingTone, type RightsizingRow } from '../../api/client'
4
+
5
+ const RIGHTSIZING_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
6
+
7
+ /**
8
+ * RightsizingStrip — compact "current → recommended" table per container.
9
+ *
10
+ * Tone policy is deliberately mild:
11
+ * - "Well-sized" or "Nx headroom" → neutral, no badge
12
+ * - 5×+ over-provisioning → info ("could reduce"), not a problem
13
+ * - P95 exceeds request but within limit → warning
14
+ * - P95 exceeds CPU limit → alert (throttling)
15
+ * - Memory P95 near limit → critical (active OOM risk)
16
+ *
17
+ * Anything below severe over-provisioning displays without flagging it as
18
+ * an issue. 2-3× headroom is the common, sensible default and should not nag.
19
+ */
20
+ export function RightsizingStrip({ kind, namespace, name }: {
21
+ kind: string
22
+ namespace: string
23
+ name: string
24
+ }) {
25
+ const { data: status } = usePrometheusStatus()
26
+ const isConnected = status?.connected === true
27
+ const supported = RIGHTSIZING_KINDS.has(kind)
28
+ const { data, error, isLoading } = usePrometheusRightsizing(kind, namespace, name, isConnected && supported)
29
+
30
+ if (!supported || !isConnected || isLoading) return null
31
+ // Stay consistent with WorkloadHealthBadge — when the rightsizing query
32
+ // fails, surface a small inline note so the absence of recommendations
33
+ // doesn't read as "everything is fine."
34
+ if (error && !data) {
35
+ const msg = error instanceof Error ? error.message : String(error)
36
+ return (
37
+ <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
38
+ <header className="flex items-center justify-between mb-1">
39
+ <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
40
+ </header>
41
+ <p className="text-xs text-theme-text-tertiary" title={msg}>Right-sizing unavailable — Prometheus query failed.</p>
42
+ </section>
43
+ )
44
+ }
45
+ if (!data) return null
46
+ if (!data.sampleAvailable || data.rows.length === 0) {
47
+ // Backend distinguishes "workload too new / retention short" from "Prometheus
48
+ // query failed" — show the reason inline so operators have an actionable signal
49
+ // instead of an empty section.
50
+ if (!data.reason) return null
51
+ return (
52
+ <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
53
+ <header className="flex items-center justify-between mb-1">
54
+ <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
55
+ </header>
56
+ <p className="text-xs text-theme-text-tertiary">{data.reason}</p>
57
+ </section>
58
+ )
59
+ }
60
+
61
+ // Group rows by container so each container is a compact two-row block (cpu+mem).
62
+ const byContainer = new Map<string, RightsizingRow[]>()
63
+ for (const row of data.rows) {
64
+ if (!byContainer.has(row.container)) byContainer.set(row.container, [])
65
+ byContainer.get(row.container)!.push(row)
66
+ }
67
+
68
+ return (
69
+ <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
70
+ <header className="flex items-center justify-between mb-2">
71
+ <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
72
+ <span className="text-[11px] text-theme-text-tertiary">
73
+ based on last {data.window} · P95
74
+ </span>
75
+ </header>
76
+ <div className="space-y-1.5">
77
+ {Array.from(byContainer.entries()).map(([container, rows]) => (
78
+ <div key={container} className="text-xs">
79
+ <div className="text-theme-text-secondary font-medium mb-0.5">{container}</div>
80
+ <div className="space-y-0.5 pl-2">
81
+ {rows.map(row => (
82
+ <RightsizingLine key={row.resource} row={row} />
83
+ ))}
84
+ </div>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ </section>
89
+ )
90
+ }
91
+
92
+ function RightsizingLine({ row }: { row: RightsizingRow }) {
93
+ const showRec = row.recommendedRequest && row.recommendedRequest !== row.currentRequest
94
+ const toneClass = toneClasses(row.tone)
95
+ const Icon = toneIcon(row.tone)
96
+
97
+ return (
98
+ <div className="flex items-center gap-2 text-theme-text-tertiary tabular-nums">
99
+ <span className="w-12 text-theme-text-quaternary uppercase tracking-wide text-[10px]">
100
+ {row.resource}
101
+ </span>
102
+
103
+ <span className="text-theme-text-secondary min-w-[3.5rem]">
104
+ {row.currentRequest ?? <span className="text-theme-text-quaternary italic">unset</span>}
105
+ </span>
106
+
107
+ {showRec && (
108
+ <>
109
+ <ArrowRight className="w-3 h-3 text-theme-text-quaternary shrink-0" />
110
+ <span className={`min-w-[3.5rem] ${toneClass.value}`}>
111
+ {row.recommendedRequest}
112
+ </span>
113
+ </>
114
+ )}
115
+
116
+ {row.p95 && (
117
+ <span className="text-theme-text-quaternary text-[10px]">
118
+ (P95 {row.p95})
119
+ </span>
120
+ )}
121
+
122
+ {row.tone !== 'ok' && Icon && (
123
+ <span className={`ml-auto inline-flex items-center gap-1 ${toneClass.badge} px-1.5 py-0.5 rounded text-[10px]`}>
124
+ <Icon className="w-3 h-3" />
125
+ <span>{row.message}</span>
126
+ </span>
127
+ )}
128
+
129
+ {row.tone === 'ok' && row.message && (
130
+ <span className="ml-auto text-theme-text-quaternary text-[10px]">{row.message}</span>
131
+ )}
132
+ </div>
133
+ )
134
+ }
135
+
136
+ const TONE_TO_SEVERITY: Record<RightsizingTone, Severity> = {
137
+ critical: 'error',
138
+ alert: 'alert',
139
+ warning: 'warning',
140
+ info: 'info',
141
+ ok: 'neutral',
142
+ }
143
+
144
+ function toneClasses(tone: RightsizingTone): { value: string; badge: string } {
145
+ if (tone === 'ok') return { value: 'text-theme-text-secondary', badge: '' }
146
+ if (tone === 'info') {
147
+ // "Could reduce" is a suggestion, not a problem — mute the badge.
148
+ return { value: SEVERITY_TEXT.info, badge: 'text-theme-text-tertiary bg-theme-elevated/60' }
149
+ }
150
+ const sev = TONE_TO_SEVERITY[tone]
151
+ return { value: SEVERITY_TEXT[sev], badge: SEVERITY_BADGE[sev] }
152
+ }
153
+
154
+ function toneIcon(tone: RightsizingTone) {
155
+ switch (tone) {
156
+ case 'critical':
157
+ case 'alert':
158
+ case 'warning':
159
+ return AlertTriangle
160
+ case 'info':
161
+ return Info
162
+ case 'ok':
163
+ return Check
164
+ default:
165
+ return null
166
+ }
167
+ }