@skyhook-io/radar-app 1.12.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/App.tsx +35 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +246 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +21 -1
- package/src/components/ConnectionErrorView.tsx +7 -8
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +19 -9
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +60 -1
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +687 -107
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +270 -29
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/index.css +11 -1
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- package/src/utils/version.ts +56 -0
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useOpenCostNodes,
|
|
9
9
|
} from '../../api/client'
|
|
10
10
|
import type {
|
|
11
|
+
CostUnavailableReason,
|
|
11
12
|
OpenCostNamespaceCost,
|
|
12
13
|
OpenCostWorkloadCost,
|
|
13
14
|
OpenCostNodeCost,
|
|
@@ -39,6 +40,15 @@ import { kindToPlural, openExternal } from '../../utils/navigation'
|
|
|
39
40
|
import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
|
|
40
41
|
import { RightsizingScanView } from '../rightsizing/RightsizingScanView'
|
|
41
42
|
import { CostViewTabs } from './CostViewTabs'
|
|
43
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
44
|
+
import {
|
|
45
|
+
costConfigurationAction,
|
|
46
|
+
costFreshnessLabel,
|
|
47
|
+
costIntegrationUnavailableMessage,
|
|
48
|
+
costRateLabels,
|
|
49
|
+
costSourceLabel,
|
|
50
|
+
isCostDiscoveryPending,
|
|
51
|
+
} from './source'
|
|
42
52
|
|
|
43
53
|
interface CostViewProps {
|
|
44
54
|
onBack: () => void
|
|
@@ -47,6 +57,13 @@ interface CostViewProps {
|
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
const SYSTEM_COST_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease'])
|
|
60
|
+
const CONFIGURABLE_COST_REASONS = new Set<CostUnavailableReason>([
|
|
61
|
+
'no_prometheus',
|
|
62
|
+
'no_cost_source',
|
|
63
|
+
'source_unavailable',
|
|
64
|
+
'authentication_error',
|
|
65
|
+
'configuration_mismatch',
|
|
66
|
+
])
|
|
50
67
|
|
|
51
68
|
export function CostView(props: CostViewProps) {
|
|
52
69
|
const { pathname } = useLocation()
|
|
@@ -61,14 +78,17 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
61
78
|
const { data: nodeData } = useOpenCostNodes()
|
|
62
79
|
const { data: clusterInfo } = useClusterInfo()
|
|
63
80
|
const { connection } = useConnection()
|
|
81
|
+
const navCustomization = useNavCustomization()
|
|
82
|
+
const settingsAvailable = !navCustomization.embedded
|
|
64
83
|
const [showHelp, setShowHelp] = useState(false)
|
|
65
|
-
const [
|
|
84
|
+
const [discoverySince, setDiscoverySince] = useState<number | null>(null)
|
|
85
|
+
const namespaceScopeCount = data?.namespaceScope?.length ?? 0
|
|
66
86
|
|
|
67
87
|
useEffect(() => {
|
|
68
|
-
if (data?.available === false && data.reason
|
|
69
|
-
|
|
88
|
+
if (data?.available === false && isCostDiscoveryPending(data.reason)) {
|
|
89
|
+
setDiscoverySince((prev) => prev ?? Date.now())
|
|
70
90
|
} else {
|
|
71
|
-
|
|
91
|
+
setDiscoverySince(null)
|
|
72
92
|
}
|
|
73
93
|
}, [data?.available, data?.reason])
|
|
74
94
|
|
|
@@ -82,8 +102,8 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
82
102
|
|
|
83
103
|
if (!data || !data.available) {
|
|
84
104
|
const reason = data?.reason
|
|
85
|
-
const discoveryAgeMs =
|
|
86
|
-
if (reason
|
|
105
|
+
const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
|
|
106
|
+
if (isCostDiscoveryPending(reason) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
87
107
|
return (
|
|
88
108
|
<CostOverviewState>
|
|
89
109
|
<div className="flex min-h-64 items-center justify-center">
|
|
@@ -91,16 +111,16 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
91
111
|
<Loader2 className="w-8 h-8 animate-spin text-theme-text-tertiary/60" />
|
|
92
112
|
<div>
|
|
93
113
|
<p className="text-sm font-medium text-theme-text-primary">
|
|
94
|
-
Looking for
|
|
114
|
+
Looking for cost data…
|
|
95
115
|
</p>
|
|
96
116
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
Radar is checking OpenCost metrics in a PromQL-compatible backend and a local
|
|
118
|
+
Kubecost 3 Aggregator. First discovery can take a few seconds.
|
|
99
119
|
</p>
|
|
100
120
|
</div>
|
|
101
121
|
<button
|
|
102
122
|
onClick={() => {
|
|
103
|
-
|
|
123
|
+
setDiscoverySince(Date.now())
|
|
104
124
|
refetch()
|
|
105
125
|
}}
|
|
106
126
|
disabled={isFetching}
|
|
@@ -113,14 +133,12 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
113
133
|
</CostOverviewState>
|
|
114
134
|
)
|
|
115
135
|
}
|
|
116
|
-
const message =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
? 'Cost data temporarily unavailable — Prometheus was found but queries failed'
|
|
123
|
-
: 'OpenCost not detected — install OpenCost for cost visibility'
|
|
136
|
+
const message = costUnavailableMessage(reason, {
|
|
137
|
+
settingsAvailable,
|
|
138
|
+
namespaceScoped: namespaceScopeCount > 0,
|
|
139
|
+
})
|
|
140
|
+
const canConfigure = settingsAvailable && reason != null && CONFIGURABLE_COST_REASONS.has(reason)
|
|
141
|
+
const configureAction = costConfigurationAction(reason)
|
|
124
142
|
|
|
125
143
|
return (
|
|
126
144
|
<CostOverviewState>
|
|
@@ -128,24 +146,37 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
128
146
|
<div className="flex flex-col items-center gap-3 text-theme-text-secondary">
|
|
129
147
|
<Coins className="w-8 h-8 text-theme-text-tertiary/40" />
|
|
130
148
|
<p className="text-sm">{message}</p>
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
150
|
+
{canConfigure && (
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => window.dispatchEvent(
|
|
154
|
+
new CustomEvent('radar:open-settings', { detail: { section: configureAction.section } }),
|
|
155
|
+
)}
|
|
156
|
+
className="btn-brand px-3 py-1.5 text-xs font-medium"
|
|
157
|
+
>
|
|
158
|
+
{configureAction.label}
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
{(reason === 'no_prometheus' || reason === 'no_cost_source' || reason === 'source_unavailable') && (
|
|
138
162
|
<button
|
|
139
163
|
onClick={() => {
|
|
140
|
-
|
|
164
|
+
setDiscoverySince(Date.now())
|
|
141
165
|
refetch()
|
|
142
166
|
}}
|
|
143
167
|
disabled={isFetching}
|
|
144
|
-
className="text-xs text-
|
|
168
|
+
className="rounded-md border border-theme-border px-3 py-1.5 text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
|
|
145
169
|
>
|
|
146
170
|
{isFetching ? 'Checking…' : 'Check again'}
|
|
147
171
|
</button>
|
|
148
172
|
)}
|
|
173
|
+
</div>
|
|
174
|
+
<button
|
|
175
|
+
onClick={onBack}
|
|
176
|
+
className="text-xs text-accent-text hover:text-theme-text-primary transition-colors"
|
|
177
|
+
>
|
|
178
|
+
Back to Dashboard
|
|
179
|
+
</button>
|
|
149
180
|
</div>
|
|
150
181
|
</div>
|
|
151
182
|
</CostOverviewState>
|
|
@@ -154,6 +185,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
154
185
|
|
|
155
186
|
const hourlyCost = data.totalHourlyCost ?? 0
|
|
156
187
|
const currency = data.currency ?? DEFAULT_COST_CURRENCY
|
|
188
|
+
const rateLabels = costRateLabels(data.source === 'kubecost' ? data.window : undefined)
|
|
157
189
|
const namespaces = data.namespaces ?? []
|
|
158
190
|
const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0)
|
|
159
191
|
const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0)
|
|
@@ -213,6 +245,9 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
213
245
|
connectionState={connection.state}
|
|
214
246
|
/>
|
|
215
247
|
<div className="flex flex-col items-end">
|
|
248
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
249
|
+
Allocated workload cost
|
|
250
|
+
</span>
|
|
216
251
|
<div className="flex items-baseline gap-1">
|
|
217
252
|
<span className="text-2xl font-bold text-theme-text-primary tabular-nums">
|
|
218
253
|
{formatProjectedMonthlyCost(hourlyCost, currency)}
|
|
@@ -229,7 +264,9 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
229
264
|
</span>
|
|
230
265
|
</div>
|
|
231
266
|
<span className="text-[10px] text-theme-text-quaternary">
|
|
232
|
-
|
|
267
|
+
{data.source === 'kubecost'
|
|
268
|
+
? costFreshnessLabel(data.source, data.window, data.dataThrough)
|
|
269
|
+
: `projected from ${costFreshnessLabel(data.source, data.window)}`}
|
|
233
270
|
</span>
|
|
234
271
|
</div>
|
|
235
272
|
</div>
|
|
@@ -237,11 +274,19 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
237
274
|
|
|
238
275
|
<CostViewTabs />
|
|
239
276
|
|
|
277
|
+
{namespaceScopeCount > 0 && (
|
|
278
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface/50 px-4 py-3 text-xs text-theme-text-secondary">
|
|
279
|
+
Allocated workload cost, trend, and namespace breakdown are scoped to {namespaceScopeCount}{' '}
|
|
280
|
+
{namespaceScopeCount === 1 ? 'namespace' : 'namespaces'}.
|
|
281
|
+
{nodes.length > 0 && ' Node costs below remain cluster-wide.'}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
240
285
|
{/* CPU vs Memory (vs Storage) split bar */}
|
|
241
286
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
242
287
|
<div className="flex items-center justify-between mb-2">
|
|
243
288
|
<span className="text-xs font-medium text-theme-text-secondary">
|
|
244
|
-
|
|
289
|
+
{namespaceScopeCount > 0 ? 'Scoped allocated workload cost' : 'Allocated workload cost'}
|
|
245
290
|
</span>
|
|
246
291
|
<div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
|
|
247
292
|
<span className="flex items-center gap-1.5">
|
|
@@ -279,7 +324,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
279
324
|
</div>
|
|
280
325
|
|
|
281
326
|
{/* Cost trend chart */}
|
|
282
|
-
<CostTrendChart />
|
|
327
|
+
<CostTrendChart namespaceScoped={namespaceScopeCount > 0} />
|
|
283
328
|
|
|
284
329
|
{/* Namespace cost table */}
|
|
285
330
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
@@ -290,7 +335,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
290
335
|
Namespace Breakdown
|
|
291
336
|
</span>
|
|
292
337
|
<span className="text-[10px] text-theme-text-quaternary ml-2">
|
|
293
|
-
projected monthly from
|
|
338
|
+
projected monthly from {rateLabels.rate}
|
|
294
339
|
</span>
|
|
295
340
|
</div>
|
|
296
341
|
<span className="text-xs text-theme-text-tertiary">
|
|
@@ -304,7 +349,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
304
349
|
<div className="grid grid-cols-[minmax(180px,1fr)_110px_90px_minmax(160px,1fr)_150px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
|
|
305
350
|
<span>Namespace</span>
|
|
306
351
|
<Tooltip
|
|
307
|
-
content=
|
|
352
|
+
content={`Projected from ${rateLabels.rate} — not historical spend`}
|
|
308
353
|
wrapperClassName="!block text-right"
|
|
309
354
|
>
|
|
310
355
|
<span className="cursor-help">Projected/mo*</span>
|
|
@@ -312,7 +357,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
312
357
|
<span className="text-right">Hourly</span>
|
|
313
358
|
<span>CPU / Memory</span>
|
|
314
359
|
<Tooltip
|
|
315
|
-
content=
|
|
360
|
+
content={`Projected monthly CPU and memory allocation from the ${rateLabels.rate}`}
|
|
316
361
|
wrapperClassName="!block text-right"
|
|
317
362
|
>
|
|
318
363
|
<span className="cursor-help">CPU / Memory/mo*</span>
|
|
@@ -346,22 +391,46 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
|
|
|
346
391
|
{/* Footer */}
|
|
347
392
|
<div className="flex items-center justify-between text-xs text-theme-text-tertiary pb-4">
|
|
348
393
|
<span>
|
|
349
|
-
{currency} ·
|
|
394
|
+
{currency} · {costFreshnessLabel(data.source, data.window, data.dataThrough)} ·
|
|
350
395
|
*monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
|
|
351
396
|
{currency !== DEFAULT_COST_CURRENCY && (
|
|
352
397
|
<> · no conversion</>
|
|
353
398
|
)}
|
|
354
399
|
</span>
|
|
355
|
-
<span className="text-indigo-500 font-medium">
|
|
400
|
+
<span className="text-indigo-500 font-medium">{costSourceLabel(data.source)}</span>
|
|
356
401
|
</div>
|
|
357
402
|
</div>
|
|
358
403
|
|
|
359
404
|
{/* Help dialog */}
|
|
360
|
-
{showHelp && <CostHelpDialog currency={currency} onClose={() => setShowHelp(false)} />}
|
|
405
|
+
{showHelp && <CostHelpDialog currency={currency} source={data.source} window={data.window} onClose={() => setShowHelp(false)} />}
|
|
361
406
|
</div>
|
|
362
407
|
)
|
|
363
408
|
}
|
|
364
409
|
|
|
410
|
+
export function costUnavailableMessage(
|
|
411
|
+
reason?: CostUnavailableReason,
|
|
412
|
+
options: { settingsAvailable?: boolean; namespaceScoped?: boolean } = {},
|
|
413
|
+
): string {
|
|
414
|
+
if (reason === 'no_metrics' && options.namespaceScoped) {
|
|
415
|
+
return 'No allocation data is visible in the current namespace scope'
|
|
416
|
+
}
|
|
417
|
+
const integrationMessage = costIntegrationUnavailableMessage(reason, options.settingsAvailable ?? true)
|
|
418
|
+
if (integrationMessage) return integrationMessage
|
|
419
|
+
|
|
420
|
+
switch (reason) {
|
|
421
|
+
case 'no_prometheus':
|
|
422
|
+
return 'No compatible cost source found — connect OpenCost metrics through a PromQL-compatible backend or configure Kubecost'
|
|
423
|
+
case 'no_metrics':
|
|
424
|
+
return 'Cost data is not ready — the selected source returned no allocation data'
|
|
425
|
+
case 'query_error':
|
|
426
|
+
return 'Cost data is temporarily unavailable — the selected source query failed'
|
|
427
|
+
case 'access_denied':
|
|
428
|
+
return 'You do not have access to view cluster cost data'
|
|
429
|
+
default:
|
|
430
|
+
return 'No compatible cost data was detected'
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
365
434
|
function CostOverviewState({ children }: { children: React.ReactNode }) {
|
|
366
435
|
return (
|
|
367
436
|
<div className="flex-1 overflow-y-auto">
|
|
@@ -618,6 +687,7 @@ function NodeCostTable({
|
|
|
618
687
|
currency: string
|
|
619
688
|
onOpenResource?: (resource: SelectedResource) => void
|
|
620
689
|
}) {
|
|
690
|
+
const totalHourlyCost = nodes.reduce((total, node) => total + node.hourlyCost, 0)
|
|
621
691
|
return (
|
|
622
692
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
623
693
|
<div className="px-4 py-3 border-b border-theme-border">
|
|
@@ -625,14 +695,21 @@ function NodeCostTable({
|
|
|
625
695
|
<div>
|
|
626
696
|
<div className="flex items-center gap-2">
|
|
627
697
|
<Server className="w-4 h-4 text-theme-text-tertiary" />
|
|
628
|
-
<span className="text-sm font-semibold text-theme-text-primary">
|
|
698
|
+
<span className="text-sm font-semibold text-theme-text-primary">Cluster-wide node capacity cost</span>
|
|
629
699
|
<span className="text-[10px] text-theme-text-quaternary">current pricing</span>
|
|
630
700
|
</div>
|
|
631
701
|
<p className="text-[11px] text-theme-text-tertiary mt-0.5 ml-6">
|
|
632
702
|
Per-machine cloud pricing — namespace costs above show how this capacity is allocated
|
|
633
703
|
</p>
|
|
634
704
|
</div>
|
|
635
|
-
<
|
|
705
|
+
<div className="text-right">
|
|
706
|
+
<div className="text-sm font-semibold tabular-nums text-theme-text-primary">
|
|
707
|
+
{formatProjectedMonthlyCost(totalHourlyCost, currency)}<span className="ml-1 text-[10px] font-normal text-theme-text-tertiary">/mo</span>
|
|
708
|
+
</div>
|
|
709
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
710
|
+
{nodes.length} {nodes.length === 1 ? 'node' : 'nodes'} · current capacity
|
|
711
|
+
</span>
|
|
712
|
+
</div>
|
|
636
713
|
</div>
|
|
637
714
|
</div>
|
|
638
715
|
|
|
@@ -768,7 +845,8 @@ function apiGroupForCostWorkload(kind: string): string | undefined {
|
|
|
768
845
|
|
|
769
846
|
// --- Help dialog ---
|
|
770
847
|
|
|
771
|
-
function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () => void }) {
|
|
848
|
+
function CostHelpDialog({ currency, source, window, onClose }: { currency: string; source?: 'prometheus' | 'kubecost'; window?: string; onClose: () => void }) {
|
|
849
|
+
const rateLabels = costRateLabels(source === 'kubecost' ? window : undefined)
|
|
772
850
|
useEffect(() => {
|
|
773
851
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
774
852
|
if (e.key === 'Escape') onClose()
|
|
@@ -804,9 +882,9 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
|
|
|
804
882
|
Where do these costs come from?
|
|
805
883
|
</h3>
|
|
806
884
|
<p>
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
885
|
+
Radar reads either OpenCost-compatible metrics from Prometheus or allocation
|
|
886
|
+
and asset data from a Kubecost 3 Aggregator. This view is currently using{' '}
|
|
887
|
+
<strong>{costSourceLabel(source)}</strong>.
|
|
810
888
|
</p>
|
|
811
889
|
</section>
|
|
812
890
|
|
|
@@ -816,9 +894,11 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
|
|
|
816
894
|
</h3>
|
|
817
895
|
<p>
|
|
818
896
|
Radar labels these values <strong>{currency}</strong> and does not convert them. Auto
|
|
819
|
-
reads <code>currencyCode</code> or <code>DISPLAY_CURRENCY</code> from
|
|
820
|
-
OpenCost
|
|
821
|
-
to USD.
|
|
897
|
+
reads <code>currencyCode</code> or <code>DISPLAY_CURRENCY</code> from a
|
|
898
|
+
cluster-discovered OpenCost installation or any active Kubecost installation, then
|
|
899
|
+
falls back to USD. A manually configured Prometheus URL disables OpenCost currency
|
|
900
|
+
detection.
|
|
901
|
+
Override it in <strong>Settings → Cost</strong> or, for automation, with{' '}
|
|
822
902
|
<code>--opencost-currency</code> (Helm: <code>cost.currency</code>).
|
|
823
903
|
</p>
|
|
824
904
|
</section>
|
|
@@ -834,7 +914,7 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
|
|
|
834
914
|
useful for attribution, but it is not a direct measurement of request headroom.
|
|
835
915
|
</p>
|
|
836
916
|
<p className="mt-1.5">
|
|
837
|
-
Projected monthly and daily numbers multiply the
|
|
917
|
+
Projected monthly and daily numbers multiply the {rateLabels.rate}. They
|
|
838
918
|
are useful for budget impact, but they are not a historical invoice total. Historical
|
|
839
919
|
spend on application and workload tabs uses the selected range.
|
|
840
920
|
</p>
|
|
@@ -846,13 +926,15 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () =
|
|
|
846
926
|
How fresh is this data?
|
|
847
927
|
</h3>
|
|
848
928
|
<p>
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
929
|
+
{source === 'kubecost' ? (
|
|
930
|
+
<>Allocation rates use the latest completed Kubecost ETL window and show its data-through time. The cluster trend shows the allocation history Kubecost retained for the selected range; workload and application history are not available through this integration.</>
|
|
931
|
+
) : (
|
|
932
|
+
<>Cost rates and breakdowns are <strong>snapshots based on the last 1 hour</strong> of data. They update automatically every minute. The trend chart shows historical hourly allocation rate over the selected time range.</>
|
|
933
|
+
)}
|
|
852
934
|
</p>
|
|
853
935
|
<p className="mt-1.5">
|
|
854
|
-
|
|
855
|
-
|
|
936
|
+
Short-lived spikes or dips may not be reflected in the {rateLabels.rate}. Projected daily
|
|
937
|
+
and monthly values are estimates, not invoice totals.
|
|
856
938
|
</p>
|
|
857
939
|
</section>
|
|
858
940
|
|
|
@@ -2,6 +2,7 @@ import { clsx } from 'clsx'
|
|
|
2
2
|
import { HelpCircle } from 'lucide-react'
|
|
3
3
|
import { Tooltip } from '../ui/Tooltip'
|
|
4
4
|
import { formatCostPerHour, formatProjectedMonthlyRate } from './format'
|
|
5
|
+
import { costRateLabels } from './source'
|
|
5
6
|
|
|
6
7
|
interface CurrentAllocationUseProps {
|
|
7
8
|
currency: string
|
|
@@ -14,10 +15,11 @@ interface CurrentAllocationUseProps {
|
|
|
14
15
|
cpuUsageAvailable: boolean
|
|
15
16
|
memoryUsageAvailable: boolean
|
|
16
17
|
scopeNote?: string
|
|
18
|
+
window?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const ALLOCATION_USE_TOOLTIP =
|
|
20
|
-
'
|
|
22
|
+
'Cost allocation uses the greater of requested or observed CPU and memory. The percentages compare observed use with that allocated amount; they are not request headroom or a right-sizing recommendation.'
|
|
21
23
|
|
|
22
24
|
export function formatAllocatedUse(
|
|
23
25
|
allocationCost: number,
|
|
@@ -43,23 +45,25 @@ export function CurrentAllocationUse({
|
|
|
43
45
|
cpuUsageAvailable,
|
|
44
46
|
memoryUsageAvailable,
|
|
45
47
|
scopeNote,
|
|
48
|
+
window,
|
|
46
49
|
}: CurrentAllocationUseProps) {
|
|
47
50
|
const splitTotal = cpuCost + memoryCost
|
|
48
51
|
const cpuPct = splitTotal > 0 ? (cpuCost / splitTotal) * 100 : 0
|
|
49
52
|
const memoryPct = splitTotal > 0 ? (memoryCost / splitTotal) * 100 : 0
|
|
53
|
+
const labels = costRateLabels(window)
|
|
50
54
|
|
|
51
55
|
return (
|
|
52
56
|
<section className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
53
57
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
54
58
|
<div>
|
|
55
59
|
<div className="flex items-center gap-1.5">
|
|
56
|
-
<div className="text-sm font-semibold text-theme-text-primary">
|
|
60
|
+
<div className="text-sm font-semibold text-theme-text-primary">{labels.allocationTitle}</div>
|
|
57
61
|
<Tooltip content={ALLOCATION_USE_TOOLTIP} className="max-w-[320px] whitespace-normal text-left" delay={150}>
|
|
58
62
|
<HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
|
|
59
63
|
</Tooltip>
|
|
60
64
|
</div>
|
|
61
65
|
<div className="text-xs text-theme-text-tertiary">
|
|
62
|
-
|
|
66
|
+
{labels.allocationPeriod}
|
|
63
67
|
{scopeNote ? ` · ${scopeNote}` : ''}
|
|
64
68
|
</div>
|
|
65
69
|
</div>
|
|
@@ -69,7 +73,7 @@ export function CurrentAllocationUse({
|
|
|
69
73
|
</div>
|
|
70
74
|
{dataAvailable && (
|
|
71
75
|
<div className="text-[10px] text-theme-text-tertiary tabular-nums">
|
|
72
|
-
{formatCostPerHour(hourlyCost, currency)}
|
|
76
|
+
{formatCostPerHour(hourlyCost, currency)} · {labels.rate}
|
|
73
77
|
</div>
|
|
74
78
|
)}
|
|
75
79
|
</div>
|
|
@@ -69,6 +69,53 @@ describe('getWorkloadCostState', () => {
|
|
|
69
69
|
expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
it('keeps Kubecost current cost visible when history is unsupported', () => {
|
|
73
|
+
const current: OpenCostWorkloadDetailResponse = {
|
|
74
|
+
available: true,
|
|
75
|
+
source: 'kubecost',
|
|
76
|
+
currency: 'USD',
|
|
77
|
+
namespace: 'default',
|
|
78
|
+
kind: 'StatefulSet',
|
|
79
|
+
name: 'queue',
|
|
80
|
+
current: {
|
|
81
|
+
name: 'queue', kind: 'StatefulSet', hourlyCost: 0.2, cpuCost: 0.12,
|
|
82
|
+
memoryCost: 0.08, replicas: 1, cpuUsageAvailable: true,
|
|
83
|
+
memoryUsageAvailable: true, cpuAllocationUse: 25, memoryAllocationUse: 25,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const trend: OpenCostWorkloadTrendResponse = {
|
|
87
|
+
available: false,
|
|
88
|
+
source: 'kubecost',
|
|
89
|
+
reason: 'history_unsupported',
|
|
90
|
+
currency: 'USD',
|
|
91
|
+
namespace: 'default',
|
|
92
|
+
kind: 'StatefulSet',
|
|
93
|
+
name: 'queue',
|
|
94
|
+
range: '24h',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not let unsupported history mask current loading or errors', () => {
|
|
101
|
+
const trend: OpenCostWorkloadTrendResponse = {
|
|
102
|
+
available: false,
|
|
103
|
+
source: 'kubecost',
|
|
104
|
+
reason: 'history_unsupported',
|
|
105
|
+
currency: 'USD',
|
|
106
|
+
namespace: 'default',
|
|
107
|
+
kind: 'StatefulSet',
|
|
108
|
+
name: 'queue',
|
|
109
|
+
range: '24h',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(getWorkloadCostState(undefined, trend, { currentLoading: true })).toBe('loading')
|
|
113
|
+
expect(getWorkloadCostState(undefined, trend, { currentError: true })).toBe('load_error')
|
|
114
|
+
expect(
|
|
115
|
+
getWorkloadCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
|
|
116
|
+
).toBe('access_denied')
|
|
117
|
+
})
|
|
118
|
+
|
|
72
119
|
it('keeps current cost visible while historical owner metrics are still loading', () => {
|
|
73
120
|
const current: OpenCostWorkloadDetailResponse = {
|
|
74
121
|
available: true,
|
|
@@ -146,6 +193,9 @@ describe('getWorkloadCostState', () => {
|
|
|
146
193
|
expect(getWorkloadCostState(undefined, missing, false)).toBe('not_found')
|
|
147
194
|
expect(getWorkloadCostState(undefined, undefined, { currentError: new ApiError('denied', 403) })).toBe('access_denied')
|
|
148
195
|
expect(getWorkloadCostState(undefined, undefined, { trendError: new ApiError('missing', 404) })).toBe('not_found')
|
|
196
|
+
|
|
197
|
+
current.reason = 'configuration_mismatch'
|
|
198
|
+
expect(getWorkloadCostState(current, undefined, false)).toBe('configuration_mismatch')
|
|
149
199
|
})
|
|
150
200
|
|
|
151
201
|
it('shows Prometheus discovery as soon as one query reports it', () => {
|