@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,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
+ }