@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.
- package/package.json +4 -4
- package/src/App.tsx +58 -50
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -234
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/diagnose/AISettings.tsx +7 -12
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/index.css +5 -1
- package/src/main.tsx +1 -1
|
@@ -1,167 +1,363 @@
|
|
|
1
|
-
import { ArrowRight,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
32
|
+
const query = usePrometheusRightsizing(kind, namespace, name, connected && supported)
|
|
33
|
+
return { connected, supported, statusLoading, ...query }
|
|
34
|
+
}
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (!
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
<
|
|
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}
|
|
79
|
-
<div className="
|
|
80
|
-
<div className="space-y-
|
|
81
|
-
{rows.map(row => (
|
|
82
|
-
<
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
99
|
-
<span className="w-12 text-
|
|
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
|
-
|
|
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="
|
|
110
|
-
<span className=
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
return { value: SEVERITY_TEXT[sev], badge: SEVERITY_BADGE[sev] }
|
|
309
|
+
return { label: labels[fit], severity: severities[fit] }
|
|
152
310
|
}
|
|
153
311
|
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
}
|