@skyhook-io/radar-app 1.8.7 → 1.8.8

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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. package/src/main.tsx +1 -1
@@ -1,167 +1,363 @@
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'
1
+ import { ArrowRight, ExternalLink, Gauge, HelpCircle, Loader2 } from 'lucide-react'
2
+ import { Link, useLocation } from 'react-router-dom'
3
+ import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
4
+ import {
5
+ usePrometheusRightsizing,
6
+ usePrometheusStatus,
7
+ useAutoPromConnect,
8
+ type PrometheusRightsizing,
9
+ type RightsizingFit,
10
+ type RightsizingRow,
11
+ } from '../../api/client'
4
12
  import { Tooltip } from '../ui/Tooltip'
5
13
 
6
14
  const RIGHTSIZING_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
15
+ export const RIGHTSIZING_DOCS_URL = 'https://radarhq.io/docs/features/rightsizing'
16
+ export const RIGHTSIZING_SUMMARY =
17
+ 'Evidence-based guidance for this workload, not a savings estimate or automatic change.'
18
+ export const RIGHTSIZING_METHODOLOGY =
19
+ 'Uses seven days of 5-minute samples: CPU P95 and memory maximum, plus 15% headroom. Reductions are staged and rounded to practical values; memory reductions also require verifiable restart history. Radar does not change requests.'
7
20
 
8
- /**
9
- * RightsizingStrip — compact "current → recommended" table per container.
10
- *
11
- * Tone policy is deliberately mild:
12
- * - "Well-sized" or "Nx headroom" → neutral, no badge
13
- * - 5×+ over-provisioning → info ("could reduce"), not a problem
14
- * - P95 exceeds request but within limit → warning
15
- * - P95 exceeds CPU limit → alert (throttling)
16
- * - Memory P95 near limit → critical (active OOM risk)
17
- *
18
- * Anything below severe over-provisioning displays without flagging it as
19
- * an issue. 2-3× headroom is the common, sensible default and should not nag.
20
- */
21
- export function RightsizingStrip({ kind, namespace, name }: {
21
+ interface RightsizingProps {
22
22
  kind: string
23
23
  namespace: string
24
24
  name: string
25
- }) {
26
- const { data: status } = usePrometheusStatus()
27
- const isConnected = status?.connected === true
25
+ }
26
+
27
+ function useRightsizingEvidence({ kind, namespace, name }: RightsizingProps) {
28
+ useAutoPromConnect()
29
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
30
+ const connected = status?.connected === true
28
31
  const supported = RIGHTSIZING_KINDS.has(kind)
29
- const { data, error, isLoading } = usePrometheusRightsizing(kind, namespace, name, isConnected && supported)
32
+ const query = usePrometheusRightsizing(kind, namespace, name, connected && supported)
33
+ return { connected, supported, statusLoading, ...query }
34
+ }
30
35
 
31
- if (!supported || !isConnected || isLoading) return null
32
- if (error && !data) {
33
- const msg = error instanceof Error ? error.message : String(error)
34
- return (
35
- <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
36
- <header className="flex items-center justify-between mb-1">
37
- <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
38
- </header>
39
- <Tooltip content={msg}>
40
- <p className="text-xs text-theme-text-tertiary">Right-sizing unavailable — Prometheus query failed.</p>
41
- </Tooltip>
42
- </section>
43
- )
36
+ export function RightsizingPanel(props: RightsizingProps) {
37
+ const state = useRightsizingEvidence(props)
38
+ const { search } = useLocation()
39
+ if (!state.supported) return null
40
+
41
+ return (
42
+ <section className="mx-auto w-full max-w-[1600px] rounded-lg border border-theme-border bg-theme-surface/50">
43
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
44
+ <div className="flex items-start gap-2">
45
+ <Gauge className="mt-0.5 h-4 w-4 text-theme-text-tertiary" />
46
+ <div>
47
+ <div className="flex items-center gap-1.5">
48
+ <h3 className="text-sm font-semibold text-theme-text-primary">Rightsizing</h3>
49
+ <Tooltip content={RIGHTSIZING_METHODOLOGY}>
50
+ <HelpCircle className="h-3.5 w-3.5 text-theme-text-tertiary" />
51
+ </Tooltip>
52
+ </div>
53
+ <p className="text-xs text-theme-text-tertiary">
54
+ {RIGHTSIZING_SUMMARY}{' '}
55
+ <a
56
+ href={RIGHTSIZING_DOCS_URL}
57
+ target="_blank"
58
+ rel="noreferrer"
59
+ className="inline-flex items-center gap-1 text-accent-text hover:underline"
60
+ >
61
+ How it works
62
+ <ExternalLink className="h-3 w-3" />
63
+ </a>
64
+ </p>
65
+ </div>
66
+ </div>
67
+ <div className="flex items-center gap-3">
68
+ {state.data && <RightsizingContext data={state.data} />}
69
+ <Link
70
+ to={{ pathname: '/cost/rightsizing', search }}
71
+ className="text-xs font-medium text-accent-text hover:underline"
72
+ >
73
+ Scan visible workloads
74
+ </Link>
75
+ </div>
76
+ </header>
77
+ <div className="p-4">
78
+ <RightsizingBody state={state} compact={false} />
79
+ </div>
80
+ </section>
81
+ )
82
+ }
83
+
84
+ export function RightsizingStrip(props: RightsizingProps) {
85
+ const state = useRightsizingEvidence(props)
86
+ if (!state.supported || !state.connected || (state.isLoading && !state.data)) return null
87
+ return (
88
+ <section className="mb-3 rounded-lg border border-theme-border bg-theme-surface/40 p-3">
89
+ <header className="mb-2 flex items-center justify-between gap-3">
90
+ <div className="flex items-center gap-1.5">
91
+ <h3 className="text-sm font-medium text-theme-text-primary">Rightsizing</h3>
92
+ <Tooltip content={RIGHTSIZING_METHODOLOGY}>
93
+ <HelpCircle className="h-3.5 w-3.5 text-theme-text-tertiary" />
94
+ </Tooltip>
95
+ </div>
96
+ {state.data && <RightsizingContext data={state.data} compact />}
97
+ </header>
98
+ <RightsizingBody state={state} compact />
99
+ </section>
100
+ )
101
+ }
102
+
103
+ function RightsizingBody({
104
+ state,
105
+ compact,
106
+ }: {
107
+ state: ReturnType<typeof useRightsizingEvidence>
108
+ compact: boolean
109
+ }) {
110
+ if (state.statusLoading) {
111
+ return <EmptyState text="Checking Prometheus connection…" loading />
44
112
  }
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
- )
113
+ if (!state.connected) {
114
+ return <EmptyState text="Connect Prometheus to compare requests with observed demand." />
115
+ }
116
+ if (state.isLoading && !state.data) {
117
+ return <EmptyState text="Analyzing seven days of workload demand…" loading />
118
+ }
119
+ if (state.error && !state.data) {
120
+ const message = state.error instanceof Error ? state.error.message : String(state.error)
121
+ return <EmptyState text={`Rightsizing unavailable ${message}`} />
122
+ }
123
+ if (!state.data) return null
124
+ if (!state.data.sampleAvailable || state.data.rows.length === 0) {
125
+ return <EmptyState text={state.data.reason ?? 'No rightsizing evidence is available.'} />
59
126
  }
60
127
 
61
- // Group rows by container so each container is a compact two-row block (cpu+mem).
62
128
  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)
129
+ for (const row of state.data.rows) {
130
+ const rows = byContainer.get(row.container) ?? []
131
+ rows.push(row)
132
+ byContainer.set(row.container, rows)
66
133
  }
67
134
 
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">
135
+ if (compact) {
136
+ return (
137
+ <div className="space-y-2">
77
138
  {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} />
139
+ <div key={container}>
140
+ <div className="mb-1 text-xs font-medium text-theme-text-secondary">{container}</div>
141
+ <div className="space-y-1 pl-2">
142
+ {rows.map((row) => (
143
+ <CompactFitRow key={row.resource} row={row} />
83
144
  ))}
84
145
  </div>
85
146
  </div>
86
147
  ))}
87
148
  </div>
88
- </section>
149
+ )
150
+ }
151
+
152
+ return (
153
+ <div className="space-y-4">
154
+ {(state.data.scaledToZero || state.data.ownerCoverage === 'current_pods') && (
155
+ <div className="flex flex-wrap gap-2">
156
+ {state.data.scaledToZero && (
157
+ <Badge severity="neutral" size="sm">
158
+ No current replicas · showing retained evidence
159
+ </Badge>
160
+ )}
161
+ {state.data.ownerCoverage === 'current_pods' && (
162
+ <Badge severity="info" size="sm">
163
+ Current pods only · confidence capped
164
+ </Badge>
165
+ )}
166
+ </div>
167
+ )}
168
+ {Array.from(byContainer.entries()).map(([container, rows]) => (
169
+ <div key={container}>
170
+ <h4 className="mb-2 text-xs font-semibold text-theme-text-secondary">{container}</h4>
171
+ <div className="grid gap-3 md:grid-cols-2">
172
+ {rows.map((row) => (
173
+ <FitCard key={row.resource} row={row} window={state.data!.window} />
174
+ ))}
175
+ </div>
176
+ </div>
177
+ ))}
178
+ </div>
89
179
  )
90
180
  }
91
181
 
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)
182
+ function FitCard({ row, window }: { row: RightsizingRow; window: string }) {
183
+ return (
184
+ <div className="rounded-md border border-theme-border bg-theme-base/60 p-3">
185
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
186
+ <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
187
+ {row.resource}
188
+ </span>
189
+ <div className="flex flex-wrap items-center justify-end gap-1.5">
190
+ <FitBadge fit={row.fit} queryError={row.queryError} />
191
+ {row.hpaManaged && (
192
+ <Badge severity="info" size="sm">
193
+ HPA-managed
194
+ </Badge>
195
+ )}
196
+ {row.bursty && (
197
+ <Badge severity="warning" size="sm">
198
+ Bursty CPU
199
+ </Badge>
200
+ )}
201
+ {row.throttleRatio != null && row.throttleRatio >= 0.1 && (
202
+ <Badge severity="alert" size="sm">
203
+ Throttling observed
204
+ </Badge>
205
+ )}
206
+ {(row.currentPodOOM || row.windowOomEvidence) && (
207
+ <Badge severity="error" size="sm">
208
+ OOM evidence
209
+ </Badge>
210
+ )}
211
+ </div>
212
+ </div>
213
+ <div className="flex items-baseline gap-2 tabular-nums">
214
+ <div>
215
+ <div className="text-[10px] uppercase tracking-wide text-theme-text-quaternary">
216
+ Current request
217
+ </div>
218
+ <div className="text-lg font-semibold text-theme-text-primary">
219
+ {row.currentRequest ?? 'Unset'}
220
+ </div>
221
+ </div>
222
+ <ArrowRight className="h-4 w-4 text-theme-text-quaternary" />
223
+ <div>
224
+ <div className="text-[10px] uppercase tracking-wide text-theme-text-quaternary">
225
+ Suggested next step
226
+ </div>
227
+ <div className="text-lg font-semibold text-theme-text-primary">
228
+ {row.recommendedRequest ?? '—'}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ <div className="mt-3 grid grid-cols-2 gap-3 border-t border-theme-border pt-3 text-xs">
233
+ <Metric
234
+ label={`${row.observed?.name ?? (row.resource === 'cpu' ? 'P95' : 'Max')} observed`}
235
+ value={row.observed?.formatted ?? 'No samples'}
236
+ />
237
+ <Metric label={`Evidence · ${window}`} value={`${confidenceLabel(row)} · 5m samples`} />
238
+ </div>
239
+ <FitExplanation row={row} />
240
+ </div>
241
+ )
242
+ }
96
243
 
244
+ function CompactFitRow({ row }: { row: RightsizingRow }) {
97
245
  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]">
246
+ <div className="flex flex-wrap items-center gap-2 text-xs tabular-nums text-theme-text-tertiary">
247
+ <span className="w-12 text-[10px] uppercase tracking-wide text-theme-text-quaternary">
100
248
  {row.resource}
101
249
  </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 && (
250
+ <span className="min-w-14 text-theme-text-secondary">{row.currentRequest ?? 'unset'}</span>
251
+ {row.recommendedRequest && (
108
252
  <>
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>
253
+ <ArrowRight className="h-3 w-3 text-theme-text-quaternary" />
254
+ <span className="min-w-14 text-theme-text-primary">{row.recommendedRequest}</span>
113
255
  </>
114
256
  )}
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>
257
+ {row.observed && (
258
+ <span className="text-[10px] text-theme-text-quaternary">
259
+ ({row.observed.name} {row.observed.formatted})
126
260
  </span>
127
261
  )}
128
-
129
- {row.tone === 'ok' && row.message && (
130
- <span className="ml-auto text-theme-text-quaternary text-[10px]">{row.message}</span>
131
- )}
262
+ <span className="ml-auto">
263
+ <FitBadge fit={row.fit} queryError={row.queryError} />
264
+ </span>
132
265
  </div>
133
266
  )
134
267
  }
135
268
 
136
- const TONE_TO_SEVERITY: Record<RightsizingTone, Severity> = {
137
- critical: 'error',
138
- alert: 'alert',
139
- warning: 'warning',
140
- info: 'info',
141
- ok: 'neutral',
269
+ function RightsizingContext({
270
+ data,
271
+ compact = false,
272
+ }: {
273
+ data: PrometheusRightsizing
274
+ compact?: boolean
275
+ }) {
276
+ const evidence = data.ownerCoverage === 'ksm_history' ? 'retained ownership' : 'current pods only'
277
+ return (
278
+ <span className="text-[11px] text-theme-text-tertiary">
279
+ {compact ? data.window : `Last ${data.window} · ${evidence}`}
280
+ </span>
281
+ )
282
+ }
283
+
284
+ function FitBadge({ fit, queryError }: { fit: RightsizingFit; queryError?: string }) {
285
+ const presentation = getRightsizingPresentation(fit, queryError)
286
+ return (
287
+ <Badge severity={presentation.severity} size="sm">
288
+ {presentation.label}
289
+ </Badge>
290
+ )
142
291
  }
143
292
 
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' }
293
+ export function getRightsizingPresentation(fit: RightsizingFit, queryError?: string) {
294
+ if (queryError) return { label: 'Query failed', severity: 'error' as const }
295
+ const labels: Record<RightsizingFit, string> = {
296
+ balanced: 'Balanced',
297
+ oversized: 'Oversized',
298
+ under_requested: 'Under-requested',
299
+ missing_request: 'Missing request',
300
+ insufficient_history: 'Insufficient history',
301
+ }
302
+ const severities: Record<RightsizingFit, 'success' | 'info' | 'warning' | 'neutral'> = {
303
+ balanced: 'success',
304
+ oversized: 'info',
305
+ under_requested: 'warning',
306
+ missing_request: 'warning',
307
+ insufficient_history: 'neutral',
149
308
  }
150
- const sev = TONE_TO_SEVERITY[tone]
151
- return { value: SEVERITY_TEXT[sev], badge: SEVERITY_BADGE[sev] }
309
+ return { label: labels[fit], severity: severities[fit] }
152
310
  }
153
311
 
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
312
+ function FitExplanation({ row }: { row: RightsizingRow }) {
313
+ const message = getRightsizingExplanation(row)
314
+ if (!message) return null
315
+ return <p className="mt-3 text-xs text-theme-text-tertiary">{message}</p>
316
+ }
317
+
318
+ export function getRightsizingExplanation(row: RightsizingRow): string | undefined {
319
+ const messages: Record<string, string> = {
320
+ request_within_fit_range: 'The configured request is within 30% of the evidence-based target.',
321
+ insufficient_history: 'At least six hours of samples are required before suggesting a request.',
322
+ hpa_managed: `The HPA manages ${row.resource}; review its target before changing this request.`,
323
+ hpa_evidence_unavailable:
324
+ 'Radar could not verify HPA ownership, so it withheld a suggested request.',
325
+ oom_evidence: 'A lower memory request is withheld because OOM evidence exists.',
326
+ oom_evidence_unavailable:
327
+ 'Radar could not verify recent OOM history, so it withheld a lower memory request.',
328
+ recommended_request_exceeds_limit:
329
+ 'The evidence-based request would exceed the current limit; review the limit first.',
330
+ }
331
+ if (row.reductionLimited && row.calculatedRequest && row.recommendedRequest) {
332
+ const burstNote = row.bursty && row.peak ? ` CPU P99 reached ${row.peak.formatted}.` : ''
333
+ return `Demand-based target: ${row.calculatedRequest}. Radar suggests ${row.recommendedRequest} as a conservative next step; observe another full window before reducing further.${burstNote}`
166
334
  }
335
+ const message = row.queryError
336
+ ? `Partial result: ${row.queryError}.`
337
+ : messages[row.recommendationReason ?? '']
338
+ const throttleUnavailable = row.resource === 'cpu' && !row.throttleAvailable
339
+ if (!message && !throttleUnavailable) return undefined
340
+ return message ?? 'CPU throttling metrics are unavailable; no throttling conclusion was drawn.'
341
+ }
342
+
343
+ function confidenceLabel(row: RightsizingRow) {
344
+ return `${row.confidence[0].toUpperCase()}${row.confidence.slice(1)} confidence`
345
+ }
346
+
347
+ function Metric({ label, value }: { label: string; value: string }) {
348
+ return (
349
+ <div>
350
+ <div className="text-theme-text-quaternary">{label}</div>
351
+ <div className="mt-0.5 text-theme-text-secondary">{value}</div>
352
+ </div>
353
+ )
354
+ }
355
+
356
+ function EmptyState({ text, loading = false }: { text: string; loading?: boolean }) {
357
+ return (
358
+ <div className="flex min-h-16 items-center justify-center text-sm text-theme-text-tertiary">
359
+ {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
360
+ {text}
361
+ </div>
362
+ )
167
363
  }