@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,938 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
2
+ import { useNavigate, useSearchParams } from 'react-router-dom'
3
+ import { AlertTriangle, DollarSign, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
4
+ import {
5
+ Collapse,
6
+ CollapseChevron,
7
+ EmptyState,
8
+ PageHeader,
9
+ SearchBox,
10
+ SelectMenu,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
13
+ import {
14
+ useAutoPromConnect,
15
+ useClusterInfo,
16
+ usePrometheusStatus,
17
+ useRightsizingScan,
18
+ type RightsizingRow,
19
+ } from '../../api/client'
20
+ import { RIGHTSIZING_DOCS_URL } from '../resource/RightsizingStrip'
21
+ import { buildWorkloadPath } from '../../utils/navigation'
22
+ import { CostViewTabs } from '../cost/CostViewTabs'
23
+ import {
24
+ flattenScanResults,
25
+ isActionableClass,
26
+ scanClassCounts,
27
+ type RightsizingScanRow,
28
+ type ScanClass,
29
+ } from './model'
30
+ import {
31
+ getResourceRequestPresentation,
32
+ RIGHTSIZING_ACTION_SEVERITY,
33
+ type ResourceSignal,
34
+ type RightsizingActionTone,
35
+ } from './presentation'
36
+
37
+ export const RIGHTSIZING_SCAN_DESCRIPTION =
38
+ 'Find CPU and memory requests to increase, reduce, or review. Radar never changes them.'
39
+ export const RIGHTSIZING_SCAN_METHODOLOGY =
40
+ 'Based on 7 days of history: CPU P95 and memory maximum, plus 15% headroom. Memory reductions require verifiable restart history.'
41
+
42
+ export type RightsizingScanSurfaceState =
43
+ | 'discovering'
44
+ | 'prometheus_required'
45
+ | 'first_run'
46
+ | 'scanning'
47
+ | 'fatal_error'
48
+ | 'unavailable'
49
+ | 'results'
50
+
51
+ export function getRightsizingScanSurfaceState(input: {
52
+ statusLoading: boolean
53
+ hasStatus: boolean
54
+ connected: boolean
55
+ pending: boolean
56
+ hasResult: boolean
57
+ hasError: boolean
58
+ resultState?: string
59
+ }): RightsizingScanSurfaceState {
60
+ if (input.statusLoading && !input.hasStatus) return 'discovering'
61
+ if (!input.connected) return 'prometheus_required'
62
+ if (input.pending && !input.hasResult) return 'scanning'
63
+ if (input.hasError && !input.hasResult) return 'fatal_error'
64
+ if (!input.hasResult) return 'first_run'
65
+ if (input.resultState === 'unavailable') return 'unavailable'
66
+ return 'results'
67
+ }
68
+
69
+ interface RightsizingScanViewProps {
70
+ namespaces: string[]
71
+ }
72
+
73
+ type ScanResult = NonNullable<ReturnType<typeof useRightsizingScan>['data']>
74
+ type ClassFilter = ScanClass | 'actions'
75
+ const ROW_PAGE_SIZE = 50
76
+
77
+ const ACTION_META: Record<
78
+ 'increase' | 'reduction' | 'review',
79
+ { label: string; severity: 'warning' | 'info' | 'neutral'; helper: string }
80
+ > = {
81
+ reduction: {
82
+ label: 'Reduce requests',
83
+ severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
84
+ helper: 'Reclaim meaningful capacity',
85
+ },
86
+ increase: {
87
+ label: 'Increase or add',
88
+ severity: RIGHTSIZING_ACTION_SEVERITY.increase,
89
+ helper: 'Reduce reliability risk',
90
+ },
91
+ review: {
92
+ label: 'Review first',
93
+ severity: RIGHTSIZING_ACTION_SEVERITY.review,
94
+ helper: 'Check safety signals or workloads with no replicas',
95
+ },
96
+ }
97
+
98
+ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
99
+ useAutoPromConnect()
100
+ const navigate = useNavigate()
101
+ const [params, setParams] = useSearchParams()
102
+ const { data: clusterInfo } = useClusterInfo()
103
+ const {
104
+ data: promStatus,
105
+ isLoading: statusLoading,
106
+ refetch: retryPrometheus,
107
+ } = usePrometheusStatus()
108
+ const scan = useRightsizingScan(namespaces, clusterInfo?.context)
109
+ const result = scan.data
110
+ const [openRow, setOpenRow] = useState<string | null>(null)
111
+ const [visibleLimit, setVisibleLimit] = useState(ROW_PAGE_SIZE)
112
+ const scopeKey = `${clusterInfo?.context ?? ''}\0${[...namespaces].sort().join(',')}`
113
+
114
+ useLayoutEffect(() => {
115
+ setOpenRow(null)
116
+ scan.reset()
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [scopeKey])
119
+
120
+ const runScan = async () => {
121
+ try {
122
+ await scan.mutateAsync()
123
+ } catch {
124
+ // A same-scope snapshot stays visible when refresh fails.
125
+ }
126
+ }
127
+
128
+ const rows = useMemo(() => (result ? flattenScanResults(result) : []), [result])
129
+ const includeSystem = params.get('rfScope') === 'all'
130
+ const scopeRows = includeSystem ? rows : rows.filter((row) => !row.system)
131
+ const counts = useMemo(() => scanClassCounts(scopeRows), [scopeRows])
132
+ const classFilter = (params.get('rfClass') as ClassFilter | null) ?? 'actions'
133
+ const search = params.get('rfQ') ?? ''
134
+ const kindFilter = params.get('rfKind') ?? ''
135
+ const namespaceFilter = params.get('rfNs') ?? ''
136
+ const setFilter = (key: string, value?: string) => {
137
+ const next = new URLSearchParams(params)
138
+ if (value) next.set(key, value)
139
+ else next.delete(key)
140
+ setParams(next, { replace: true })
141
+ }
142
+ const clearFilters = () => {
143
+ const next = new URLSearchParams(params)
144
+ for (const key of ['rfClass', 'rfQ', 'rfKind', 'rfNs', 'rfScope']) next.delete(key)
145
+ setParams(next, { replace: true })
146
+ }
147
+ const filteredRows = scopeRows.filter((row) => {
148
+ if (
149
+ classFilter === 'actions'
150
+ ? !isActionableClass(row.classification)
151
+ : row.classification !== classFilter
152
+ )
153
+ return false
154
+ if (kindFilter && row.kind !== kindFilter) return false
155
+ if (namespaceFilter && row.namespace !== namespaceFilter) return false
156
+ if (
157
+ search &&
158
+ !`${row.namespace} ${row.name} ${row.container} ${row.kind}`
159
+ .toLowerCase()
160
+ .includes(search.toLowerCase())
161
+ )
162
+ return false
163
+ return true
164
+ })
165
+ const visibleRows = filteredRows.slice(0, visibleLimit)
166
+
167
+ useEffect(() => {
168
+ setVisibleLimit(ROW_PAGE_SIZE)
169
+ }, [classFilter, search, kindFilter, namespaceFilter, includeSystem])
170
+
171
+ const kinds = [...new Set(scopeRows.map((row) => row.kind))].sort()
172
+ const resultNamespaces = [...new Set(scopeRows.map((row) => row.namespace))].sort()
173
+ const activeFilters =
174
+ classFilter !== 'actions' || Boolean(search || kindFilter || namespaceFilter || includeSystem)
175
+ const hiddenSystem = rows.length - rows.filter((row) => !row.system).length
176
+ const onlySystemRowsAreHidden = !includeSystem && rows.length > 0 && scopeRows.length === 0
177
+ const surfaceState = getRightsizingScanSurfaceState({
178
+ statusLoading,
179
+ hasStatus: Boolean(promStatus),
180
+ connected: promStatus?.connected === true,
181
+ pending: scan.isPending,
182
+ hasResult: Boolean(result),
183
+ hasError: Boolean(scan.error),
184
+ resultState: result?.state,
185
+ })
186
+
187
+ const openWorkload = (row: RightsizingScanRow) => {
188
+ navigate(
189
+ `${buildWorkloadPath({ kind: row.kind, namespace: row.namespace, name: row.name })}?tab=cost`,
190
+ )
191
+ }
192
+
193
+ return (
194
+ <div className="flex-1 min-h-0 overflow-y-auto">
195
+ <div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
196
+ <PageHeader
197
+ icon={DollarSign}
198
+ title="Cost Insights"
199
+ description="Understand current allocation and find CPU and memory requests worth tuning."
200
+ />
201
+ <CostViewTabs />
202
+ <div className="flex flex-wrap items-start justify-between gap-3">
203
+ <div>
204
+ <h2 className="text-base font-semibold text-theme-text-primary">Rightsizing</h2>
205
+ <p className="mt-0.5 text-sm text-theme-text-secondary">
206
+ {RIGHTSIZING_SCAN_DESCRIPTION}
207
+ </p>
208
+ </div>
209
+ <div className="flex items-center gap-3">
210
+ <a
211
+ href={RIGHTSIZING_DOCS_URL}
212
+ target="_blank"
213
+ rel="noreferrer"
214
+ className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline"
215
+ >
216
+ Methodology <ExternalLink className="h-3 w-3" />
217
+ </a>
218
+ {result?.scannedAt && (
219
+ <span className="text-xs text-theme-text-tertiary">
220
+ Scanned {formatScanTime(result.scannedAt)}
221
+ </span>
222
+ )}
223
+ {result && (
224
+ <button
225
+ type="button"
226
+ onClick={runScan}
227
+ disabled={scan.isPending}
228
+ className="btn-brand inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium"
229
+ >
230
+ <RefreshCw className={`h-3.5 w-3.5 ${scan.isPending ? 'animate-spin' : ''}`} />
231
+ {scan.isPending ? 'Scanning…' : 'Run again'}
232
+ </button>
233
+ )}
234
+ </div>
235
+ </div>
236
+
237
+ {surfaceState === 'discovering' ? (
238
+ <CenteredState
239
+ loading
240
+ title="Looking for Prometheus…"
241
+ body="Radar is checking the current cluster for workload metrics."
242
+ />
243
+ ) : surfaceState === 'prometheus_required' ? (
244
+ <CenteredState
245
+ title="Prometheus is required"
246
+ body="Rightsizing uses Prometheus history. Cost Overview can still use OpenCost independently."
247
+ action={
248
+ <button
249
+ type="button"
250
+ onClick={() => retryPrometheus()}
251
+ className="btn-brand px-3 py-1.5 text-xs font-medium"
252
+ >
253
+ Check again
254
+ </button>
255
+ }
256
+ />
257
+ ) : surfaceState === 'first_run' ? (
258
+ <FirstRunState namespaces={namespaces} onRun={runScan} />
259
+ ) : surfaceState === 'scanning' ? (
260
+ <CenteredState
261
+ loading
262
+ title="Analyzing CPU and memory requests…"
263
+ body="Comparing 7 days of CPU and memory usage with configured requests. Larger clusters can take up to a minute."
264
+ />
265
+ ) : surfaceState === 'fatal_error' ? (
266
+ <CenteredState
267
+ title="Rightsizing scan failed"
268
+ body={errorMessage(scan.error)}
269
+ action={
270
+ <button
271
+ type="button"
272
+ onClick={runScan}
273
+ className="btn-brand px-3 py-1.5 text-xs font-medium"
274
+ >
275
+ Try again
276
+ </button>
277
+ }
278
+ />
279
+ ) : surfaceState === 'unavailable' && result ? (
280
+ <CenteredState
281
+ title="Rightsizing is unavailable"
282
+ body={unavailableMessage(result.reason)}
283
+ action={
284
+ <button
285
+ type="button"
286
+ onClick={runScan}
287
+ disabled={scan.isPending}
288
+ className="btn-brand px-3 py-1.5 text-xs font-medium"
289
+ >
290
+ Try again
291
+ </button>
292
+ }
293
+ />
294
+ ) : result ? (
295
+ <div
296
+ className={`flex flex-col gap-4 transition-opacity ${scan.isPending ? 'opacity-70' : ''}`}
297
+ >
298
+ {scan.error && (
299
+ <Notice
300
+ tone="warning"
301
+ text={`Refresh failed; showing results from ${formatScanTime(result.scannedAt)}. ${errorMessage(scan.error)}`}
302
+ />
303
+ )}
304
+ {scan.isPending && (
305
+ <Notice text="Scanning the current scope. Previous results remain visible until the scan completes." />
306
+ )}
307
+ <ScanSummary
308
+ result={result}
309
+ counts={counts}
310
+ selected={classFilter}
311
+ onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
312
+ />
313
+ <ScanNotices result={result} rows={rows} />
314
+ {result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
315
+ <EmptyState
316
+ variant="card"
317
+ headline="No supported workloads in this scope"
318
+ body="No Deployment, StatefulSet, or DaemonSet containers are visible in the selected namespaces."
319
+ />
320
+ ) : (
321
+ <section className="overflow-visible rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
322
+ <ScanFilters
323
+ search={search}
324
+ onSearch={(value) => setFilter('rfQ', value || undefined)}
325
+ kind={kindFilter}
326
+ kinds={kinds}
327
+ onKind={(value) => setFilter('rfKind', value || undefined)}
328
+ namespace={namespaceFilter}
329
+ namespaces={resultNamespaces}
330
+ onNamespace={(value) => setFilter('rfNs', value || undefined)}
331
+ includeSystem={includeSystem}
332
+ hiddenSystem={hiddenSystem}
333
+ onScope={(value) => setFilter('rfScope', value === 'all' ? 'all' : undefined)}
334
+ shown={filteredRows.length}
335
+ total={scopeRows.length}
336
+ onClear={clearFilters}
337
+ active={activeFilters}
338
+ />
339
+ {filteredRows.length === 0 ? (
340
+ <EmptyState
341
+ tone="filtered"
342
+ headline={
343
+ onlySystemRowsAreHidden
344
+ ? 'System workloads are hidden'
345
+ : 'No results match the current filters'
346
+ }
347
+ body={
348
+ onlySystemRowsAreHidden
349
+ ? 'This scope contains only Kubernetes system workloads. Include them to review platform requests.'
350
+ : 'Choose another result type or clear a filter.'
351
+ }
352
+ action={
353
+ <button
354
+ type="button"
355
+ onClick={() =>
356
+ onlySystemRowsAreHidden ? setFilter('rfScope', 'all') : clearFilters()
357
+ }
358
+ className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary"
359
+ >
360
+ {onlySystemRowsAreHidden
361
+ ? 'Include system workloads'
362
+ : 'Show recommended actions'}
363
+ </button>
364
+ }
365
+ />
366
+ ) : (
367
+ <div>
368
+ <div className="grid grid-cols-[minmax(230px,1.15fr)_minmax(190px,1fr)_minmax(200px,1fr)_minmax(220px,.9fr)_28px] gap-3 border-b border-theme-border px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
369
+ <span>Workload / container</span>
370
+ <span>CPU request</span>
371
+ <span>Memory request</span>
372
+ <span>Total request change</span>
373
+ <span />
374
+ </div>
375
+ <div className="table-divide-subtle">
376
+ {visibleRows.map((row) => (
377
+ <ScanResultRow
378
+ key={row.id}
379
+ row={row}
380
+ open={openRow === row.id}
381
+ onToggle={() => setOpenRow(openRow === row.id ? null : row.id)}
382
+ onOpen={() => openWorkload(row)}
383
+ />
384
+ ))}
385
+ {visibleRows.length < filteredRows.length && (
386
+ <div className="flex justify-center border-t border-theme-border px-4 py-3">
387
+ <button
388
+ type="button"
389
+ onClick={() => setVisibleLimit((value) => value + ROW_PAGE_SIZE)}
390
+ className="text-xs font-medium text-accent-text hover:underline"
391
+ >
392
+ Show {Math.min(ROW_PAGE_SIZE, filteredRows.length - visibleRows.length)}{' '}
393
+ more
394
+ </button>
395
+ </div>
396
+ )}
397
+ </div>
398
+ </div>
399
+ )}
400
+ </section>
401
+ )}
402
+ </div>
403
+ ) : null}
404
+ </div>
405
+ </div>
406
+ )
407
+ }
408
+
409
+ function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: () => void }) {
410
+ const scope =
411
+ namespaces.length === 0
412
+ ? 'all visible namespaces'
413
+ : namespaces.length === 1
414
+ ? namespaces[0]
415
+ : `${namespaces.length} selected namespaces`
416
+ return (
417
+ <div className="rounded-xl border border-theme-border bg-theme-surface p-8 text-center shadow-theme-sm">
418
+ <Gauge className="mx-auto h-9 w-9 text-theme-text-tertiary" />
419
+ <h2 className="mt-3 text-base font-semibold text-theme-text-primary">
420
+ Review CPU and memory requests
421
+ </h2>
422
+ <p className="mx-auto mt-1 max-w-xl text-sm text-theme-text-secondary">
423
+ Scan {scope} to find CPU and memory requests to increase, reduce, or review alongside
424
+ autoscaling.
425
+ </p>
426
+ <p className="mt-3 text-xs text-theme-text-tertiary">{RIGHTSIZING_SCAN_METHODOLOGY}</p>
427
+ <button
428
+ type="button"
429
+ onClick={onRun}
430
+ className="btn-brand mt-5 px-4 py-2 text-sm font-medium"
431
+ >
432
+ Scan visible workloads
433
+ </button>
434
+ </div>
435
+ )
436
+ }
437
+
438
+ function ScanSummary({
439
+ result,
440
+ counts,
441
+ selected,
442
+ onSelect,
443
+ }: {
444
+ result: ScanResult
445
+ counts: ReturnType<typeof scanClassCounts>
446
+ selected: ClassFilter
447
+ onSelect: (value: ClassFilter) => void
448
+ }) {
449
+ const evaluated = result.coverage.workloadsEvaluated ?? result.workloads.length
450
+ const discovered = result.coverage.workloadsDiscovered ?? result.workloads.length
451
+ return (
452
+ <section className="rounded-xl border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
453
+ <div className="grid gap-2 md:grid-cols-3">
454
+ {(Object.keys(ACTION_META) as Array<keyof typeof ACTION_META>).map((key) => {
455
+ const meta = ACTION_META[key]
456
+ return (
457
+ <button
458
+ key={key}
459
+ type="button"
460
+ onClick={() => onSelect(selected === key ? 'actions' : key)}
461
+ className={`rounded-lg border p-3 text-left transition-colors ${selected === key ? 'border-accent bg-accent-muted' : 'border-theme-border bg-theme-base/50 hover:bg-theme-hover'}`}
462
+ >
463
+ <div className="flex items-center justify-between gap-2">
464
+ <span className="text-sm font-medium text-theme-text-primary">{meta.label}</span>
465
+ <Badge severity={meta.severity} size="sm">
466
+ {counts[key]} containers
467
+ </Badge>
468
+ </div>
469
+ <p className="mt-1 text-xs text-theme-text-tertiary">{meta.helper}</p>
470
+ </button>
471
+ )
472
+ })}
473
+ </div>
474
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
475
+ <span>
476
+ {evaluated} of {discovered} visible workloads evaluated · {result.window || '7d'} history
477
+ </span>
478
+ <span className="flex items-center gap-3">
479
+ <button
480
+ type="button"
481
+ onClick={() => onSelect('in_range')}
482
+ className="hover:text-theme-text-primary"
483
+ >
484
+ {counts.in_range} containers · no meaningful change
485
+ </button>
486
+ <button
487
+ type="button"
488
+ onClick={() => onSelect('need_data')}
489
+ className="hover:text-theme-text-primary"
490
+ >
491
+ {counts.need_data} containers · not analyzed
492
+ </button>
493
+ {selected !== 'actions' && (
494
+ <button
495
+ type="button"
496
+ onClick={() => onSelect('actions')}
497
+ className="font-medium text-accent-text hover:underline"
498
+ >
499
+ Recommended actions
500
+ </button>
501
+ )}
502
+ </span>
503
+ </div>
504
+ </section>
505
+ )
506
+ }
507
+
508
+ function ScanNotices({ result, rows }: { result: ScanResult; rows: RightsizingScanRow[] }) {
509
+ const notices: string[] = []
510
+ if (result.state === 'partial')
511
+ notices.push('Some workloads could not be fully analyzed. Completed recommendations are shown.')
512
+ if ((result.coverage.restrictedKinds?.length ?? 0) > 0)
513
+ notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
514
+ if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
515
+ notices.push('Some workload kinds could not be evaluated with the available ownership data.')
516
+ for (const warning of result.warnings ?? []) notices.push(warningMessage(warning.code))
517
+ if (rows.length > 0 && rows.every((row) => row.classification === 'need_data'))
518
+ notices.push('There is not enough recent history to recommend request changes yet.')
519
+ return notices.length > 0 ? (
520
+ <div className="flex flex-col gap-2">
521
+ {notices.map((text) => (
522
+ <Notice key={text} text={text} />
523
+ ))}
524
+ </div>
525
+ ) : null
526
+ }
527
+
528
+ function Notice({ text, tone }: { text: string; tone?: 'warning' }) {
529
+ return (
530
+ <div
531
+ className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${tone === 'warning' ? 'status-degraded' : 'border-theme-border bg-theme-surface text-theme-text-secondary'}`}
532
+ >
533
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
534
+ {text}
535
+ </div>
536
+ )
537
+ }
538
+
539
+ function ScanFilters(props: {
540
+ search: string
541
+ onSearch: (value: string) => void
542
+ kind: string
543
+ kinds: string[]
544
+ onKind: (value: string) => void
545
+ namespace: string
546
+ namespaces: string[]
547
+ onNamespace: (value: string) => void
548
+ includeSystem: boolean
549
+ hiddenSystem: number
550
+ onScope: (value: string) => void
551
+ shown: number
552
+ total: number
553
+ active: boolean
554
+ onClear: () => void
555
+ }) {
556
+ return (
557
+ <div className="flex flex-wrap items-center gap-2 border-b border-theme-border p-3">
558
+ <SearchBox
559
+ value={props.search}
560
+ onChange={props.onSearch}
561
+ scope="global"
562
+ shortcutId="rightsizing-search"
563
+ placeholder="Search workloads…"
564
+ className="mr-auto w-60 2xl:w-72"
565
+ />
566
+ <SelectMenu
567
+ ariaLabel="Filter by namespace"
568
+ value={props.namespace}
569
+ onChange={props.onNamespace}
570
+ className="w-40 2xl:w-48"
571
+ options={[
572
+ { value: '', label: 'All namespaces' },
573
+ ...props.namespaces.map((value) => ({ value, label: value })),
574
+ ]}
575
+ />
576
+ <SelectMenu
577
+ ariaLabel="Filter by workload kind"
578
+ value={props.kind}
579
+ onChange={props.onKind}
580
+ className="w-36"
581
+ options={[
582
+ { value: '', label: 'All kinds' },
583
+ ...props.kinds.map((value) => ({ value, label: value })),
584
+ ]}
585
+ />
586
+ {props.hiddenSystem > 0 && (
587
+ <SelectMenu
588
+ ariaLabel="Choose workload scope"
589
+ value={props.includeSystem ? 'all' : 'applications'}
590
+ onChange={props.onScope}
591
+ className="w-44 2xl:w-48"
592
+ options={[
593
+ { value: 'applications', label: 'Hide system workloads' },
594
+ {
595
+ value: 'all',
596
+ label: `Include system workloads (+${props.hiddenSystem})`,
597
+ },
598
+ ]}
599
+ />
600
+ )}
601
+ <span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">
602
+ {props.shown} of {props.total} containers
603
+ </span>
604
+ {props.active && (
605
+ <button
606
+ type="button"
607
+ onClick={props.onClear}
608
+ className="shrink-0 text-xs text-accent-text hover:underline"
609
+ >
610
+ Reset view
611
+ </button>
612
+ )}
613
+ </div>
614
+ )
615
+ }
616
+
617
+ function ScanResultRow({
618
+ row,
619
+ open,
620
+ onToggle,
621
+ onOpen,
622
+ }: {
623
+ row: RightsizingScanRow
624
+ open: boolean
625
+ onToggle: () => void
626
+ onOpen: () => void
627
+ }) {
628
+ return (
629
+ <div>
630
+ <button
631
+ type="button"
632
+ onClick={onToggle}
633
+ aria-expanded={open}
634
+ className="grid w-full grid-cols-[minmax(230px,1.15fr)_minmax(190px,1fr)_minmax(200px,1fr)_minmax(220px,.9fr)_28px] items-center gap-3 px-4 py-3 text-left hover:bg-theme-hover/50"
635
+ >
636
+ <div className="min-w-0">
637
+ <div className="flex items-center gap-2">
638
+ <Badge kind={row.kind} size="sm">
639
+ {row.kind}
640
+ </Badge>
641
+ <span className="truncate text-sm font-medium text-theme-text-primary">{row.name}</span>
642
+ </div>
643
+ <div className="mt-1 truncate text-xs text-theme-text-tertiary">
644
+ {row.namespace} · {row.container}
645
+ </div>
646
+ </div>
647
+ <ResourceRequestCell row={row.cpu} scaledToZero={row.scaledToZero} />
648
+ <ResourceRequestCell row={row.memory} scaledToZero={row.scaledToZero} />
649
+ <ImpactCell row={row} />
650
+ <CollapseChevron open={open} className="h-4 w-4" />
651
+ </button>
652
+ <Collapse open={open} mountLazily>
653
+ <div className="grid gap-4 border-t border-theme-border bg-theme-base/40 px-4 py-3 md:grid-cols-[1fr_1fr_auto]">
654
+ <FitWhy label="CPU" row={row.cpu} />
655
+ <FitWhy label="Memory" row={row.memory} />
656
+ <button
657
+ type="button"
658
+ onClick={onOpen}
659
+ className="self-start text-xs font-medium text-accent-text hover:underline"
660
+ >
661
+ Open workload
662
+ </button>
663
+ </div>
664
+ </Collapse>
665
+ </div>
666
+ )
667
+ }
668
+
669
+ function ResourceRequestCell({
670
+ row,
671
+ scaledToZero,
672
+ }: {
673
+ row?: RightsizingRow
674
+ scaledToZero: boolean
675
+ }) {
676
+ const presentation = getResourceRequestPresentation(row, scaledToZero)
677
+ return (
678
+ <div className="min-w-0 text-xs">
679
+ <div className="truncate font-medium tabular-nums text-theme-text-primary">
680
+ {presentation.primary}
681
+ </div>
682
+ <div className={`mt-0.5 ${requestToneClass(presentation.tone)}`}>
683
+ {presentation.secondary}
684
+ </div>
685
+ {presentation.signals.length > 0 && (
686
+ <div className="mt-1 flex flex-wrap gap-1">
687
+ {presentation.signals.map((signal) => (
688
+ <SignalBadge key={signal} signal={signal} />
689
+ ))}
690
+ </div>
691
+ )}
692
+ </div>
693
+ )
694
+ }
695
+
696
+ function requestToneClass(tone: RightsizingActionTone): string {
697
+ if (tone === 'reduction') return 'text-accent-text'
698
+ if (tone === 'increase') return 'text-warning-text'
699
+ return 'text-theme-text-tertiary'
700
+ }
701
+
702
+ function ImpactCell({ row }: { row: RightsizingScanRow }) {
703
+ if (row.classification === 'in_range') {
704
+ return <div className="text-xs text-theme-text-tertiary">Below action threshold</div>
705
+ }
706
+ const changes: string[] = []
707
+ if (row.impact.cpuChange !== 0) changes.push(formatCPUChange(row.impact.cpuChange))
708
+ if (row.impact.memoryChange !== 0) changes.push(formatMemoryChange(row.impact.memoryChange))
709
+ return (
710
+ <div className="min-w-0 text-xs text-theme-text-secondary">
711
+ <div>{row.replicas === 1 ? '1 replica' : `${row.replicas} replicas`}</div>
712
+ {row.scaledToZero ? (
713
+ <div className="mt-0.5 text-theme-text-tertiary">No current capacity impact</div>
714
+ ) : changes.length > 0 ? (
715
+ changes.map((change) => (
716
+ <div key={change} className="mt-0.5 tabular-nums text-theme-text-tertiary">
717
+ {change}
718
+ </div>
719
+ ))
720
+ ) : (
721
+ <div className="mt-0.5 text-theme-text-tertiary">No suggested change</div>
722
+ )}
723
+ </div>
724
+ )
725
+ }
726
+
727
+ function SignalBadge({ signal }: { signal: ResourceSignal }) {
728
+ return (
729
+ <Badge
730
+ severity={
731
+ signal === 'oom' || signal === 'query_error'
732
+ ? 'error'
733
+ : signal === 'throttling'
734
+ ? 'warning'
735
+ : 'neutral'
736
+ }
737
+ size="sm"
738
+ >
739
+ {signalLabel(signal)}
740
+ </Badge>
741
+ )
742
+ }
743
+
744
+ function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
745
+ if (!row)
746
+ return (
747
+ <div>
748
+ <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
749
+ {label}
750
+ </div>
751
+ <p className="mt-1 text-xs text-theme-text-secondary">No result returned.</p>
752
+ </div>
753
+ )
754
+ const history =
755
+ row.coverage >= 0.95
756
+ ? 'Full 7-day history.'
757
+ : `${Math.max(row.coverage * 7, 0).toFixed(1)} days of usable history.`
758
+ const observation = row.observed
759
+ ? row.observed.name === 'Max'
760
+ ? `${label} peaked at ${row.observed.formatted} during the measured period. `
761
+ : `${label} stayed below ${row.observed.formatted} for ${row.observed.name === 'P95' ? '95%' : '99%'} of the measured period. `
762
+ : ''
763
+ return (
764
+ <div>
765
+ <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
766
+ Why this {label.toLowerCase()} guidance
767
+ </div>
768
+ <p className="mt-1 text-xs text-theme-text-secondary">
769
+ {observation}
770
+ {history}
771
+ </p>
772
+ <EvidenceNote row={row} />
773
+ </div>
774
+ )
775
+ }
776
+
777
+ function EvidenceNote({ row }: { row: RightsizingRow }) {
778
+ if (row.hpaManaged)
779
+ return (
780
+ <p className="mt-1 text-xs text-theme-text-tertiary">
781
+ The HPA uses this request when calculating scale, so Radar does not suggest changing it
782
+ directly.
783
+ </p>
784
+ )
785
+ if (row.recommendationReason === 'oom_evidence')
786
+ return (
787
+ <p className="mt-1 text-xs text-theme-text-tertiary">
788
+ A recent out-of-memory restart makes a lower memory request unsafe to suggest.
789
+ </p>
790
+ )
791
+ if (row.recommendationReason === 'hpa_evidence_unavailable')
792
+ return (
793
+ <p className="mt-1 text-xs text-theme-text-tertiary">
794
+ Radar could not verify whether autoscaling depends on this request.
795
+ </p>
796
+ )
797
+ if (row.recommendationReason === 'oom_evidence_unavailable')
798
+ return (
799
+ <p className="mt-1 text-xs text-theme-text-tertiary">
800
+ Radar could not verify restart history before suggesting a lower memory request.
801
+ </p>
802
+ )
803
+ if (row.limitConflict)
804
+ return (
805
+ <p className="mt-1 text-xs text-theme-text-tertiary">
806
+ The calculated request would exceed the current limit.
807
+ </p>
808
+ )
809
+ if (row.fit === 'insufficient_history')
810
+ return (
811
+ <p className="mt-1 text-xs text-theme-text-tertiary">
812
+ Wait for more runtime history before changing this request.
813
+ </p>
814
+ )
815
+ const isReduction =
816
+ row.recommendedRequestValue != null &&
817
+ row.currentRequestValue != null &&
818
+ row.recommendedRequestValue < row.currentRequestValue
819
+ if (row.resource === 'cpu' && isReduction && (row.throttleRatio ?? 0) >= 0.1)
820
+ return (
821
+ <p className="mt-1 text-xs text-theme-text-tertiary">
822
+ CPU limit throttling was observed. Review the limit and burst behavior before reducing this
823
+ request.
824
+ </p>
825
+ )
826
+ if (row.resource === 'cpu' && isReduction && row.bursty)
827
+ return (
828
+ <p className="mt-1 text-xs text-theme-text-tertiary">
829
+ CPU usage was bursty during the measured period. Review the peak before reducing this
830
+ request.
831
+ </p>
832
+ )
833
+ if (row.reductionLimited && row.calculatedRequest && row.recommendedRequest) {
834
+ return (
835
+ <p className="mt-1 text-xs text-theme-text-tertiary">
836
+ Demand-based target: {row.calculatedRequest}. Radar suggests {row.recommendedRequest} as a
837
+ conservative next step; observe another full window before reducing further.
838
+ {row.bursty && row.peak ? ` CPU P99 reached ${row.peak.formatted}.` : ''}
839
+ </p>
840
+ )
841
+ }
842
+ if (row.recommendedRequest)
843
+ return (
844
+ <p className="mt-1 text-xs text-theme-text-tertiary">
845
+ The target includes 15% headroom, a practical minimum, and scale-aware rounding.
846
+ </p>
847
+ )
848
+ return null
849
+ }
850
+
851
+ function CenteredState({
852
+ loading,
853
+ title,
854
+ body,
855
+ action,
856
+ }: {
857
+ loading?: boolean
858
+ title: string
859
+ body: string
860
+ action?: React.ReactNode
861
+ }) {
862
+ return (
863
+ <div className="flex min-h-48 items-center justify-center rounded-xl border border-theme-border bg-theme-surface">
864
+ <div className="flex max-w-lg flex-col items-center px-6 text-center">
865
+ {loading ? (
866
+ <Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary" />
867
+ ) : (
868
+ <Gauge className="h-8 w-8 text-theme-text-tertiary" />
869
+ )}
870
+ <h2 className="mt-3 text-base font-semibold text-theme-text-primary">{title}</h2>
871
+ <p className="mt-1 text-sm text-theme-text-secondary">{body}</p>
872
+ {action && <div className="mt-4">{action}</div>}
873
+ </div>
874
+ </div>
875
+ )
876
+ }
877
+
878
+ function formatCPUChange(value: number): string {
879
+ const prefix = value > 0 ? '+' : '−'
880
+ const absolute = Math.abs(value)
881
+ return absolute >= 1
882
+ ? `${prefix}${absolute.toFixed(1)} CPU requested`
883
+ : `${prefix}${Math.round(absolute * 1000)}m CPU requested`
884
+ }
885
+
886
+ function formatMemoryChange(value: number): string {
887
+ const prefix = value > 0 ? '+' : '−'
888
+ const mib = Math.abs(value) / (1024 * 1024)
889
+ return mib >= 1024
890
+ ? `${prefix}${(mib / 1024).toFixed(1)}Gi memory requested`
891
+ : `${prefix}${Math.round(mib)}Mi memory requested`
892
+ }
893
+
894
+ function signalLabel(signal: ResourceSignal): string {
895
+ return {
896
+ hpa: 'HPA',
897
+ oom: 'OOM restart',
898
+ bursty: 'Bursty usage',
899
+ throttling: 'Throttling',
900
+ query_error: 'Metrics unavailable',
901
+ }[signal]
902
+ }
903
+
904
+ function errorMessage(error: unknown): string {
905
+ return error instanceof Error ? error.message : 'An unexpected error occurred.'
906
+ }
907
+
908
+ function unavailableMessage(reason?: string): string {
909
+ if (reason === 'workload_kinds_unavailable')
910
+ return 'No supported workload kinds are available with your current Kubernetes access.'
911
+ if (reason === 'owner_metrics_missing')
912
+ return 'kube-state-metrics ownership metrics were not found. Rightsizing needs kube_pod_owner to map samples to workloads.'
913
+ if (reason === 'deployment_owner_metrics_missing')
914
+ return 'Deployment history cannot be mapped because kube_replicaset_owner is unavailable from kube-state-metrics.'
915
+ if (reason === 'owner_metrics_query_failed')
916
+ return 'Radar found Prometheus, but could not query kube-state-metrics ownership data.'
917
+ if (reason === 'scan_incomplete')
918
+ return 'The scan could not evaluate any workloads in the current scope.'
919
+ return 'Prometheus or kube-state-metrics does not expose the metrics required for this scan.'
920
+ }
921
+
922
+ function warningMessage(code: string): string {
923
+ if (code === 'scan_deadline_exceeded')
924
+ return 'The scan reached its time limit. Results from completed batches are shown.'
925
+ if (code === 'owner_metrics_query_failed') return 'Workload ownership data could not be queried.'
926
+ if (code === 'oom_evidence_unavailable')
927
+ return 'Radar could not verify restart history for some containers, so it withheld their memory reductions.'
928
+ if (code.endsWith('_query_failed'))
929
+ return `Some ${code.replace('_query_failed', '').replaceAll('_', ' ')} data could not be queried.`
930
+ return 'Some rightsizing data was unavailable. Completed recommendations are shown.'
931
+ }
932
+
933
+ function formatScanTime(value: string): string {
934
+ const timestamp = new Date(value)
935
+ return Number.isNaN(timestamp.getTime())
936
+ ? value
937
+ : timestamp.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
938
+ }