@skyhook-io/radar-app 1.8.6 → 1.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- 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.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- 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 +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getRightsizingScanSurfaceState,
|
|
4
|
+
RIGHTSIZING_SCAN_DESCRIPTION,
|
|
5
|
+
RIGHTSIZING_SCAN_METHODOLOGY,
|
|
6
|
+
} from './RightsizingScanView'
|
|
7
|
+
|
|
8
|
+
describe('rightsizing scan copy', () => {
|
|
9
|
+
it('sets expectations without claiming efficiency or savings', () => {
|
|
10
|
+
const copy = `${RIGHTSIZING_SCAN_DESCRIPTION} ${RIGHTSIZING_SCAN_METHODOLOGY}`
|
|
11
|
+
expect(copy).toContain('CPU and memory requests')
|
|
12
|
+
expect(copy).toContain('increase, reduce, or review')
|
|
13
|
+
expect(copy).toContain('Radar never changes them')
|
|
14
|
+
expect(copy).toContain('CPU P95 and memory maximum')
|
|
15
|
+
expect(copy).toContain('Memory reductions require verifiable restart history')
|
|
16
|
+
expect(copy.toLowerCase()).not.toContain('efficiency')
|
|
17
|
+
expect(copy.toLowerCase()).not.toContain('savings')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('retains a prior snapshot after a failed rerun but treats a first-run failure as fatal', () => {
|
|
21
|
+
expect(
|
|
22
|
+
getRightsizingScanSurfaceState({
|
|
23
|
+
statusLoading: false,
|
|
24
|
+
hasStatus: true,
|
|
25
|
+
connected: true,
|
|
26
|
+
pending: false,
|
|
27
|
+
hasResult: true,
|
|
28
|
+
hasError: true,
|
|
29
|
+
resultState: 'complete',
|
|
30
|
+
}),
|
|
31
|
+
).toBe('results')
|
|
32
|
+
expect(
|
|
33
|
+
getRightsizingScanSurfaceState({
|
|
34
|
+
statusLoading: false,
|
|
35
|
+
hasStatus: true,
|
|
36
|
+
connected: true,
|
|
37
|
+
pending: false,
|
|
38
|
+
hasResult: false,
|
|
39
|
+
hasError: true,
|
|
40
|
+
}),
|
|
41
|
+
).toBe('fatal_error')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not auto-scan a connected first visit', () => {
|
|
45
|
+
expect(
|
|
46
|
+
getRightsizingScanSurfaceState({
|
|
47
|
+
statusLoading: false,
|
|
48
|
+
hasStatus: true,
|
|
49
|
+
connected: true,
|
|
50
|
+
pending: false,
|
|
51
|
+
hasResult: false,
|
|
52
|
+
hasError: false,
|
|
53
|
+
}),
|
|
54
|
+
).toBe('first_run')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { RightsizingScanResponse, RightsizingRow } from '../../api/client'
|
|
3
|
+
import { calculateImpact, classifyRows, flattenScanResults, scanClassCounts } from './model'
|
|
4
|
+
|
|
5
|
+
function metric(overrides: Partial<RightsizingRow> = {}): RightsizingRow {
|
|
6
|
+
return {
|
|
7
|
+
container: 'app',
|
|
8
|
+
resource: 'cpu',
|
|
9
|
+
fit: 'balanced',
|
|
10
|
+
confidence: 'high',
|
|
11
|
+
sampleCount: 2017,
|
|
12
|
+
expectedSamples: 2017,
|
|
13
|
+
coverage: 1,
|
|
14
|
+
hpaManaged: false,
|
|
15
|
+
hpaEvidenceAvailable: true,
|
|
16
|
+
oomEvidenceAvailable: true,
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function response(
|
|
22
|
+
rows: RightsizingRow[],
|
|
23
|
+
replicas = 1,
|
|
24
|
+
namespace = 'shop',
|
|
25
|
+
scaledToZero = false,
|
|
26
|
+
): RightsizingScanResponse {
|
|
27
|
+
return {
|
|
28
|
+
state: 'complete',
|
|
29
|
+
scannedAt: '2026-07-12T10:00:00Z',
|
|
30
|
+
window: '7d',
|
|
31
|
+
source: 'radar',
|
|
32
|
+
coverage: {
|
|
33
|
+
workloadsDiscovered: 1,
|
|
34
|
+
workloadsEvaluated: 1,
|
|
35
|
+
workloadsWithData: 1,
|
|
36
|
+
batches: 1,
|
|
37
|
+
completedBatches: 1,
|
|
38
|
+
},
|
|
39
|
+
workloads: [
|
|
40
|
+
{
|
|
41
|
+
kind: 'Deployment',
|
|
42
|
+
namespace,
|
|
43
|
+
name: 'api',
|
|
44
|
+
replicas,
|
|
45
|
+
scaledToZero,
|
|
46
|
+
rows,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('rightsizing scan model', () => {
|
|
53
|
+
it('prioritizes reliability guidance and explicit safety review', () => {
|
|
54
|
+
expect(
|
|
55
|
+
classifyRows([
|
|
56
|
+
metric({
|
|
57
|
+
fit: 'oversized',
|
|
58
|
+
currentRequestValue: 0.1,
|
|
59
|
+
recommendedRequestValue: 0.02,
|
|
60
|
+
recommendedRequest: '20m',
|
|
61
|
+
}),
|
|
62
|
+
metric({
|
|
63
|
+
resource: 'memory',
|
|
64
|
+
fit: 'missing_request',
|
|
65
|
+
recommendedRequestValue: 64 * 1024 * 1024,
|
|
66
|
+
recommendedRequest: '64Mi',
|
|
67
|
+
}),
|
|
68
|
+
]),
|
|
69
|
+
).toBe('increase')
|
|
70
|
+
expect(
|
|
71
|
+
classifyRows([
|
|
72
|
+
metric({
|
|
73
|
+
fit: 'oversized',
|
|
74
|
+
hpaManaged: true,
|
|
75
|
+
recommendationReason: 'hpa_managed',
|
|
76
|
+
}),
|
|
77
|
+
]),
|
|
78
|
+
).toBe('review')
|
|
79
|
+
expect(
|
|
80
|
+
classifyRows([
|
|
81
|
+
metric({
|
|
82
|
+
fit: 'oversized',
|
|
83
|
+
recommendationReason: 'hpa_evidence_unavailable',
|
|
84
|
+
}),
|
|
85
|
+
]),
|
|
86
|
+
).toBe('review')
|
|
87
|
+
expect(
|
|
88
|
+
classifyRows([
|
|
89
|
+
metric({
|
|
90
|
+
fit: 'oversized',
|
|
91
|
+
currentRequestValue: 0.2,
|
|
92
|
+
recommendedRequestValue: 0.1,
|
|
93
|
+
recommendedRequest: '100m',
|
|
94
|
+
throttleRatio: 0.1,
|
|
95
|
+
}),
|
|
96
|
+
]),
|
|
97
|
+
).toBe('review')
|
|
98
|
+
expect(
|
|
99
|
+
classifyRows([
|
|
100
|
+
metric({
|
|
101
|
+
fit: 'oversized',
|
|
102
|
+
currentRequestValue: 0.2,
|
|
103
|
+
recommendedRequestValue: 0.1,
|
|
104
|
+
recommendedRequest: '100m',
|
|
105
|
+
bursty: true,
|
|
106
|
+
}),
|
|
107
|
+
]),
|
|
108
|
+
).toBe('review')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('suppresses reductions that are not meaningful across replicas', () => {
|
|
112
|
+
const tiny = metric({
|
|
113
|
+
fit: 'oversized',
|
|
114
|
+
currentRequestValue: 0.005,
|
|
115
|
+
recommendedRequestValue: 0.001,
|
|
116
|
+
recommendedRequest: '1m',
|
|
117
|
+
})
|
|
118
|
+
const meaningful = metric({
|
|
119
|
+
fit: 'oversized',
|
|
120
|
+
currentRequestValue: 0.1,
|
|
121
|
+
recommendedRequestValue: 0.02,
|
|
122
|
+
recommendedRequest: '20m',
|
|
123
|
+
})
|
|
124
|
+
expect(classifyRows([tiny], 9)).toBe('in_range')
|
|
125
|
+
expect(classifyRows([meaningful], 1)).toBe('reduction')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('excludes recommendations that require manual review from aggregate impact', () => {
|
|
129
|
+
const cpu = metric({
|
|
130
|
+
fit: 'oversized',
|
|
131
|
+
currentRequestValue: 0.1,
|
|
132
|
+
recommendedRequestValue: 0.05,
|
|
133
|
+
recommendedRequest: '50m',
|
|
134
|
+
throttleRatio: 0.2,
|
|
135
|
+
})
|
|
136
|
+
const memory = metric({
|
|
137
|
+
resource: 'memory',
|
|
138
|
+
fit: 'oversized',
|
|
139
|
+
currentRequestValue: 128 * 1024 * 1024,
|
|
140
|
+
recommendedRequestValue: 64 * 1024 * 1024,
|
|
141
|
+
recommendedRequest: '64Mi',
|
|
142
|
+
})
|
|
143
|
+
expect(calculateImpact([cpu, memory], 3)).toEqual({
|
|
144
|
+
replicas: 3,
|
|
145
|
+
cpuChange: 0,
|
|
146
|
+
memoryChange: -192 * 1024 * 1024,
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('keeps incomplete history out of no-change results', () => {
|
|
151
|
+
expect(classifyRows([metric({ fit: 'insufficient_history' })])).toBe('need_data')
|
|
152
|
+
expect(classifyRows([metric({ queryError: 'query failed' })])).toBe('need_data')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('keeps workloads with no current replicas in review', () => {
|
|
156
|
+
const oversized = flattenScanResults(
|
|
157
|
+
response(
|
|
158
|
+
[
|
|
159
|
+
metric({
|
|
160
|
+
fit: 'oversized',
|
|
161
|
+
currentRequestValue: 0.2,
|
|
162
|
+
recommendedRequestValue: 0.05,
|
|
163
|
+
recommendedRequest: '50m',
|
|
164
|
+
}),
|
|
165
|
+
],
|
|
166
|
+
0,
|
|
167
|
+
'shop',
|
|
168
|
+
true,
|
|
169
|
+
),
|
|
170
|
+
)[0]
|
|
171
|
+
expect(oversized.classification).toBe('review')
|
|
172
|
+
expect(oversized.impact.cpuChange).toBe(0)
|
|
173
|
+
|
|
174
|
+
expect(classifyRows([metric()], 0, true)).toBe('review')
|
|
175
|
+
expect(classifyRows([metric({ fit: 'insufficient_history' })], 0, true)).toBe('need_data')
|
|
176
|
+
expect(
|
|
177
|
+
classifyRows(
|
|
178
|
+
[
|
|
179
|
+
metric({
|
|
180
|
+
fit: 'missing_request',
|
|
181
|
+
recommendedRequestValue: 0.1,
|
|
182
|
+
recommendedRequest: '100m',
|
|
183
|
+
}),
|
|
184
|
+
],
|
|
185
|
+
0,
|
|
186
|
+
true,
|
|
187
|
+
),
|
|
188
|
+
).toBe('review')
|
|
189
|
+
expect(
|
|
190
|
+
classifyRows(
|
|
191
|
+
[
|
|
192
|
+
metric({
|
|
193
|
+
fit: 'under_requested',
|
|
194
|
+
currentRequestValue: 0.1,
|
|
195
|
+
recommendedRequestValue: 0.2,
|
|
196
|
+
recommendedRequest: '200m',
|
|
197
|
+
}),
|
|
198
|
+
],
|
|
199
|
+
0,
|
|
200
|
+
true,
|
|
201
|
+
),
|
|
202
|
+
).toBe('review')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('groups resources, calculates replica impact, and tags system workloads', () => {
|
|
206
|
+
const rows = flattenScanResults(
|
|
207
|
+
response(
|
|
208
|
+
[
|
|
209
|
+
metric({
|
|
210
|
+
fit: 'under_requested',
|
|
211
|
+
currentRequestValue: 0.1,
|
|
212
|
+
recommendedRequestValue: 0.2,
|
|
213
|
+
recommendedRequest: '200m',
|
|
214
|
+
throttleRatio: 0.2,
|
|
215
|
+
}),
|
|
216
|
+
metric({ resource: 'memory', currentPodOOM: true }),
|
|
217
|
+
],
|
|
218
|
+
3,
|
|
219
|
+
'kube-system',
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
expect(rows).toHaveLength(1)
|
|
223
|
+
expect(rows[0].impact.cpuChange).toBeCloseTo(0.3)
|
|
224
|
+
expect(rows[0].system).toBe(true)
|
|
225
|
+
expect(scanClassCounts(rows).increase).toBe(1)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { RightsizingScanResponse, RightsizingRow } from '../../api/client'
|
|
2
|
+
|
|
3
|
+
export type ScanClass = 'increase' | 'reduction' | 'review' | 'in_range' | 'need_data'
|
|
4
|
+
|
|
5
|
+
export interface RightsizingImpact {
|
|
6
|
+
replicas: number
|
|
7
|
+
cpuChange: number
|
|
8
|
+
memoryChange: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RightsizingScanRow {
|
|
12
|
+
id: string
|
|
13
|
+
kind: string
|
|
14
|
+
namespace: string
|
|
15
|
+
name: string
|
|
16
|
+
container: string
|
|
17
|
+
replicas: number
|
|
18
|
+
cpu?: RightsizingRow
|
|
19
|
+
memory?: RightsizingRow
|
|
20
|
+
classification: ScanClass
|
|
21
|
+
impact: RightsizingImpact
|
|
22
|
+
system: boolean
|
|
23
|
+
scaledToZero: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CLASS_RANK: Record<ScanClass, number> = {
|
|
27
|
+
reduction: 0,
|
|
28
|
+
increase: 1,
|
|
29
|
+
review: 2,
|
|
30
|
+
need_data: 3,
|
|
31
|
+
in_range: 4,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MIN_CPU_REDUCTION = 0.05
|
|
35
|
+
const MIN_MEMORY_REDUCTION = 64 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
export function classifyRows(
|
|
38
|
+
rows: RightsizingRow[],
|
|
39
|
+
replicas = 1,
|
|
40
|
+
scaledToZero = false,
|
|
41
|
+
): ScanClass {
|
|
42
|
+
if (rows.some((row) => row.queryError || row.fit === 'insufficient_history')) return 'need_data'
|
|
43
|
+
if (scaledToZero) return 'review'
|
|
44
|
+
if (
|
|
45
|
+
rows.some(
|
|
46
|
+
(row) =>
|
|
47
|
+
(row.fit === 'under_requested' || row.fit === 'missing_request') && row.recommendedRequest,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return 'increase'
|
|
51
|
+
if (rows.some(needsManualReview)) return 'review'
|
|
52
|
+
const impact = calculateImpact(rows, replicas)
|
|
53
|
+
if (-impact.cpuChange >= MIN_CPU_REDUCTION || -impact.memoryChange >= MIN_MEMORY_REDUCTION)
|
|
54
|
+
return 'reduction'
|
|
55
|
+
return 'in_range'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function needsManualReview(row: RightsizingRow): boolean {
|
|
59
|
+
return (
|
|
60
|
+
row.hpaManaged ||
|
|
61
|
+
row.currentPodOOM === true ||
|
|
62
|
+
row.windowOomEvidence === true ||
|
|
63
|
+
row.limitConflict === true ||
|
|
64
|
+
row.recommendationReason === 'hpa_evidence_unavailable' ||
|
|
65
|
+
row.recommendationReason === 'oom_evidence_unavailable' ||
|
|
66
|
+
(isReduction(row) && (row.bursty || (row.throttleRatio ?? 0) >= 0.1))
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isReduction(row: RightsizingRow): boolean {
|
|
71
|
+
return (
|
|
72
|
+
row.recommendedRequestValue != null &&
|
|
73
|
+
row.currentRequestValue != null &&
|
|
74
|
+
row.recommendedRequestValue < row.currentRequestValue
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function calculateImpact(rows: RightsizingRow[], replicas: number): RightsizingImpact {
|
|
79
|
+
const count = Math.max(replicas, 0)
|
|
80
|
+
let cpuChange = 0
|
|
81
|
+
let memoryChange = 0
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
if (row.recommendedRequestValue == null || needsManualReview(row)) continue
|
|
84
|
+
const change = (row.recommendedRequestValue - (row.currentRequestValue ?? 0)) * count
|
|
85
|
+
if (row.resource === 'cpu') cpuChange += change
|
|
86
|
+
else memoryChange += change
|
|
87
|
+
}
|
|
88
|
+
return { replicas: count, cpuChange, memoryChange }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function flattenScanResults(scan: RightsizingScanResponse): RightsizingScanRow[] {
|
|
92
|
+
const result: RightsizingScanRow[] = []
|
|
93
|
+
for (const workload of scan.workloads ?? []) {
|
|
94
|
+
const byContainer = new Map<string, RightsizingRow[]>()
|
|
95
|
+
for (const row of workload.rows ?? []) {
|
|
96
|
+
const rows = byContainer.get(row.container) ?? []
|
|
97
|
+
rows.push(row)
|
|
98
|
+
byContainer.set(row.container, rows)
|
|
99
|
+
}
|
|
100
|
+
for (const [container, rows] of byContainer) {
|
|
101
|
+
const replicas = workload.replicas ?? 1
|
|
102
|
+
result.push({
|
|
103
|
+
id: `${workload.kind}/${workload.namespace}/${workload.name}/${container}`,
|
|
104
|
+
kind: workload.kind,
|
|
105
|
+
namespace: workload.namespace,
|
|
106
|
+
name: workload.name,
|
|
107
|
+
container,
|
|
108
|
+
replicas,
|
|
109
|
+
cpu: rows.find((row) => row.resource === 'cpu'),
|
|
110
|
+
memory: rows.find((row) => row.resource === 'memory'),
|
|
111
|
+
classification: classifyRows(rows, replicas, workload.scaledToZero),
|
|
112
|
+
impact: calculateImpact(rows, replicas),
|
|
113
|
+
system: isSystemNamespace(workload.namespace),
|
|
114
|
+
scaledToZero: workload.scaledToZero,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result.sort(
|
|
119
|
+
(a, b) =>
|
|
120
|
+
CLASS_RANK[a.classification] - CLASS_RANK[b.classification] ||
|
|
121
|
+
impactScore(b) - impactScore(a) ||
|
|
122
|
+
a.namespace.localeCompare(b.namespace) ||
|
|
123
|
+
a.name.localeCompare(b.name) ||
|
|
124
|
+
a.container.localeCompare(b.container),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function impactScore(row: RightsizingScanRow): number {
|
|
129
|
+
return Math.max(
|
|
130
|
+
Math.abs(row.impact.cpuChange) / MIN_CPU_REDUCTION,
|
|
131
|
+
Math.abs(row.impact.memoryChange) / MIN_MEMORY_REDUCTION,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function scanClassCounts(rows: RightsizingScanRow[]): Record<ScanClass, number> {
|
|
136
|
+
const counts: Record<ScanClass, number> = {
|
|
137
|
+
increase: 0,
|
|
138
|
+
reduction: 0,
|
|
139
|
+
review: 0,
|
|
140
|
+
in_range: 0,
|
|
141
|
+
need_data: 0,
|
|
142
|
+
}
|
|
143
|
+
for (const row of rows) counts[row.classification]++
|
|
144
|
+
return counts
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isActionableClass(value: ScanClass): boolean {
|
|
148
|
+
return value === 'increase' || value === 'reduction' || value === 'review'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSystemNamespace(namespace: string): boolean {
|
|
152
|
+
return (
|
|
153
|
+
namespace === 'kube-system' ||
|
|
154
|
+
namespace === 'kube-public' ||
|
|
155
|
+
namespace === 'kube-node-lease' ||
|
|
156
|
+
namespace.startsWith('gke-managed-')
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { RightsizingRow } from '../../api/client'
|
|
3
|
+
import { getResourceRequestPresentation } from './presentation'
|
|
4
|
+
|
|
5
|
+
function metric(overrides: Partial<RightsizingRow> = {}): RightsizingRow {
|
|
6
|
+
return {
|
|
7
|
+
container: 'app',
|
|
8
|
+
resource: 'cpu',
|
|
9
|
+
fit: 'balanced',
|
|
10
|
+
confidence: 'high',
|
|
11
|
+
sampleCount: 2017,
|
|
12
|
+
expectedSamples: 2017,
|
|
13
|
+
coverage: 1,
|
|
14
|
+
hpaManaged: false,
|
|
15
|
+
hpaEvidenceAvailable: true,
|
|
16
|
+
oomEvidenceAvailable: true,
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('rightsizing request presentation', () => {
|
|
22
|
+
it('shows configured and suggested values in a consistent comparison', () => {
|
|
23
|
+
expect(
|
|
24
|
+
getResourceRequestPresentation(
|
|
25
|
+
metric({
|
|
26
|
+
fit: 'oversized',
|
|
27
|
+
currentRequest: '1500m',
|
|
28
|
+
currentRequestValue: 1.5,
|
|
29
|
+
recommendedRequest: '750m',
|
|
30
|
+
recommendedRequestValue: 0.75,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
).toEqual({
|
|
34
|
+
primary: '1500m → 750m',
|
|
35
|
+
secondary: 'Reduce request',
|
|
36
|
+
signals: [],
|
|
37
|
+
tone: 'reduction',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('makes missing requests explicit', () => {
|
|
42
|
+
expect(
|
|
43
|
+
getResourceRequestPresentation(
|
|
44
|
+
metric({
|
|
45
|
+
fit: 'missing_request',
|
|
46
|
+
recommendedRequest: '100m',
|
|
47
|
+
recommendedRequestValue: 0.1,
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
).toEqual({
|
|
51
|
+
primary: 'Unset → 100m',
|
|
52
|
+
secondary: 'Add request',
|
|
53
|
+
signals: [],
|
|
54
|
+
tone: 'increase',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps the configured value visible when safety signals require review', () => {
|
|
59
|
+
expect(
|
|
60
|
+
getResourceRequestPresentation(
|
|
61
|
+
metric({
|
|
62
|
+
fit: 'oversized',
|
|
63
|
+
currentRequest: '200m',
|
|
64
|
+
currentRequestValue: 0.2,
|
|
65
|
+
recommendedRequest: '100m',
|
|
66
|
+
recommendedRequestValue: 0.1,
|
|
67
|
+
bursty: true,
|
|
68
|
+
throttleRatio: 0.2,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
).toEqual({
|
|
72
|
+
primary: '200m current',
|
|
73
|
+
secondary: 'Review before reducing',
|
|
74
|
+
signals: ['bursty', 'throttling'],
|
|
75
|
+
tone: 'review',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('places resource-specific safety evidence with the request it qualifies', () => {
|
|
80
|
+
expect(
|
|
81
|
+
getResourceRequestPresentation(
|
|
82
|
+
metric({
|
|
83
|
+
resource: 'memory',
|
|
84
|
+
currentRequest: '128Mi',
|
|
85
|
+
currentRequestValue: 128 * 1024 * 1024,
|
|
86
|
+
recommendationReason: 'oom_evidence',
|
|
87
|
+
windowOomEvidence: true,
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
).toEqual({
|
|
91
|
+
primary: '128Mi current',
|
|
92
|
+
secondary: 'Keep current request',
|
|
93
|
+
signals: ['oom'],
|
|
94
|
+
tone: 'review',
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('keeps no-change and unavailable data neutral', () => {
|
|
99
|
+
expect(getResourceRequestPresentation(metric()).tone).toBe('neutral')
|
|
100
|
+
expect(getResourceRequestPresentation(metric({ queryError: 'query failed' })).tone).toBe(
|
|
101
|
+
'neutral',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { RightsizingRow } from '../../api/client'
|
|
2
|
+
|
|
3
|
+
export type ResourceSignal = 'hpa' | 'oom' | 'bursty' | 'throttling' | 'query_error'
|
|
4
|
+
export type RightsizingActionTone = 'reduction' | 'increase' | 'review' | 'neutral'
|
|
5
|
+
|
|
6
|
+
export const RIGHTSIZING_ACTION_SEVERITY = {
|
|
7
|
+
reduction: 'info',
|
|
8
|
+
increase: 'warning',
|
|
9
|
+
review: 'neutral',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
export interface ResourceRequestPresentation {
|
|
13
|
+
primary: string
|
|
14
|
+
secondary: string
|
|
15
|
+
signals: ResourceSignal[]
|
|
16
|
+
tone: RightsizingActionTone
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getResourceRequestPresentation(
|
|
20
|
+
row?: RightsizingRow,
|
|
21
|
+
scaledToZero = false,
|
|
22
|
+
): ResourceRequestPresentation {
|
|
23
|
+
if (!row) return { primary: '—', secondary: 'No result returned', signals: [], tone: 'neutral' }
|
|
24
|
+
|
|
25
|
+
const current = row.currentRequest && row.currentRequestValue !== 0 ? row.currentRequest : 'Unset'
|
|
26
|
+
const currentOnly = current === 'Unset' ? current : `${current} current`
|
|
27
|
+
const signals = resourceSignals(row)
|
|
28
|
+
const reason = row.recommendationReason
|
|
29
|
+
|
|
30
|
+
if (row.queryError)
|
|
31
|
+
return { primary: currentOnly, secondary: 'Metrics query failed', signals, tone: 'neutral' }
|
|
32
|
+
if (row.fit === 'insufficient_history')
|
|
33
|
+
return { primary: currentOnly, secondary: 'Not enough history', signals, tone: 'neutral' }
|
|
34
|
+
if (scaledToZero)
|
|
35
|
+
return { primary: currentOnly, secondary: 'Review before workload runs again', signals, tone: 'review' }
|
|
36
|
+
if (row.hpaManaged)
|
|
37
|
+
return { primary: currentOnly, secondary: 'Review with autoscaling', signals, tone: 'review' }
|
|
38
|
+
if (reason === 'hpa_evidence_unavailable')
|
|
39
|
+
return {
|
|
40
|
+
primary: currentOnly,
|
|
41
|
+
secondary: 'Review — autoscaling status unavailable',
|
|
42
|
+
signals,
|
|
43
|
+
tone: 'review',
|
|
44
|
+
}
|
|
45
|
+
if (reason === 'oom_evidence')
|
|
46
|
+
return { primary: currentOnly, secondary: 'Keep current request', signals, tone: 'review' }
|
|
47
|
+
if (reason === 'oom_evidence_unavailable')
|
|
48
|
+
return {
|
|
49
|
+
primary: currentOnly,
|
|
50
|
+
secondary: 'Review — restart history incomplete',
|
|
51
|
+
signals,
|
|
52
|
+
tone: 'review',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isReduction =
|
|
56
|
+
row.recommendedRequestValue != null &&
|
|
57
|
+
row.currentRequestValue != null &&
|
|
58
|
+
row.recommendedRequestValue < row.currentRequestValue
|
|
59
|
+
const comparison = row.recommendedRequest
|
|
60
|
+
? `${current} → ${row.recommendedRequest}`
|
|
61
|
+
: currentOnly
|
|
62
|
+
|
|
63
|
+
if (row.limitConflict)
|
|
64
|
+
return {
|
|
65
|
+
primary: comparison,
|
|
66
|
+
secondary: 'Raise the limit before changing',
|
|
67
|
+
signals,
|
|
68
|
+
tone: 'review',
|
|
69
|
+
}
|
|
70
|
+
if (row.resource === 'cpu' && isReduction && (row.bursty || (row.throttleRatio ?? 0) >= 0.1))
|
|
71
|
+
return { primary: currentOnly, secondary: 'Review before reducing', signals, tone: 'review' }
|
|
72
|
+
if (row.recommendedRequest) {
|
|
73
|
+
if (current === 'Unset')
|
|
74
|
+
return { primary: comparison, secondary: 'Add request', signals, tone: 'increase' }
|
|
75
|
+
const increase = (row.recommendedRequestValue ?? 0) > (row.currentRequestValue ?? 0)
|
|
76
|
+
return {
|
|
77
|
+
primary: comparison,
|
|
78
|
+
secondary: increase ? 'Increase request' : 'Reduce request',
|
|
79
|
+
signals,
|
|
80
|
+
tone: increase ? 'increase' : 'reduction',
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { primary: currentOnly, secondary: 'No meaningful change', signals, tone: 'neutral' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resourceSignals(row: RightsizingRow): ResourceSignal[] {
|
|
87
|
+
const signals: ResourceSignal[] = []
|
|
88
|
+
if (row.hpaManaged) signals.push('hpa')
|
|
89
|
+
if (row.resource === 'memory' && (row.currentPodOOM || row.windowOomEvidence)) signals.push('oom')
|
|
90
|
+
if (row.resource === 'cpu' && row.bursty) signals.push('bursty')
|
|
91
|
+
if (row.resource === 'cpu' && (row.throttleRatio ?? 0) >= 0.1) signals.push('throttling')
|
|
92
|
+
if (row.queryError) signals.push('query_error')
|
|
93
|
+
return signals
|
|
94
|
+
}
|