@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- 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/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- 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 +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
+
}
|