@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
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
import { isOpenCostWorkloadKind } from './kinds'
|
|
26
26
|
import { CurrentAllocationUse } from './CurrentAllocationUse'
|
|
27
27
|
import { costUnavailableReasonFromError } from './errors'
|
|
28
|
+
import {
|
|
29
|
+
costFreshnessLabel,
|
|
30
|
+
costIntegrationUnavailableMessage,
|
|
31
|
+
costRateLabels,
|
|
32
|
+
costSourceLabel,
|
|
33
|
+
isCostDiscoveryPending,
|
|
34
|
+
} from './source'
|
|
35
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
28
36
|
|
|
29
37
|
type ApplicationCostState =
|
|
30
38
|
| 'loading'
|
|
@@ -53,8 +61,9 @@ export function ApplicationCostTab({
|
|
|
53
61
|
workloads,
|
|
54
62
|
onSelectWorkloadCost,
|
|
55
63
|
}: ApplicationCostTabProps) {
|
|
64
|
+
const settingsAvailable = !useNavCustomization().embedded
|
|
56
65
|
const [range, setRange] = useState<CostTimeRange>('24h')
|
|
57
|
-
const [
|
|
66
|
+
const [discoverySince, setDiscoverySince] = useState<number | null>(null)
|
|
58
67
|
const supportedWorkloads = useMemo(() => applicationCostWorkloads(workloads), [workloads])
|
|
59
68
|
const unsupportedCount = workloads.length - supportedWorkloads.length
|
|
60
69
|
const queriesEnabled = supportedWorkloads.length > 0
|
|
@@ -77,10 +86,10 @@ export function ApplicationCostTab({
|
|
|
77
86
|
})
|
|
78
87
|
|
|
79
88
|
useEffect(() => {
|
|
80
|
-
if (state
|
|
81
|
-
|
|
89
|
+
if (isCostDiscoveryPending(state)) {
|
|
90
|
+
setDiscoverySince((prev) => prev ?? Date.now())
|
|
82
91
|
} else {
|
|
83
|
-
|
|
92
|
+
setDiscoverySince(null)
|
|
84
93
|
}
|
|
85
94
|
}, [state])
|
|
86
95
|
|
|
@@ -96,6 +105,7 @@ export function ApplicationCostTab({
|
|
|
96
105
|
<ApplicationCostUnavailable
|
|
97
106
|
state="no_metrics"
|
|
98
107
|
message="No steady-state workloads in this app are currently cost-attributed."
|
|
108
|
+
settingsAvailable={settingsAvailable}
|
|
99
109
|
/>
|
|
100
110
|
)
|
|
101
111
|
}
|
|
@@ -115,22 +125,28 @@ export function ApplicationCostTab({
|
|
|
115
125
|
state === 'query_error' ||
|
|
116
126
|
state === 'access_denied' ||
|
|
117
127
|
state === 'not_found' ||
|
|
128
|
+
state === 'no_cost_source' ||
|
|
129
|
+
state === 'source_unavailable' ||
|
|
130
|
+
state === 'authentication_error' ||
|
|
131
|
+
state === 'configuration_mismatch' ||
|
|
132
|
+
state === 'deployment_configuration_error' ||
|
|
133
|
+
state === 'history_unsupported' ||
|
|
118
134
|
state === 'load_error'
|
|
119
135
|
) {
|
|
120
|
-
const discoveryAgeMs =
|
|
121
|
-
if (state
|
|
136
|
+
const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
|
|
137
|
+
if (isCostDiscoveryPending(state) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
122
138
|
return (
|
|
123
139
|
<ApplicationCostDiscovering
|
|
124
140
|
isFetching={currentQuery.isFetching || trendQuery.isFetching}
|
|
125
141
|
onRetry={() => {
|
|
126
|
-
|
|
142
|
+
setDiscoverySince(Date.now())
|
|
127
143
|
currentQuery.refetch()
|
|
128
144
|
trendQuery.refetch()
|
|
129
145
|
}}
|
|
130
146
|
/>
|
|
131
147
|
)
|
|
132
148
|
}
|
|
133
|
-
return <ApplicationCostUnavailable state={state} />
|
|
149
|
+
return <ApplicationCostUnavailable state={state} settingsAvailable={settingsAvailable} />
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
const current = currentQuery.data
|
|
@@ -151,12 +167,16 @@ export function ApplicationCostTab({
|
|
|
151
167
|
const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
|
|
152
168
|
const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
|
|
153
169
|
const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY
|
|
170
|
+
const source = current?.source ?? trend?.source
|
|
171
|
+
const historyUnsupported = trend?.reason === 'history_unsupported'
|
|
172
|
+
const currentWindow = current?.window
|
|
173
|
+
const rateLabels = costRateLabels(currentWindow)
|
|
154
174
|
|
|
155
175
|
return (
|
|
156
176
|
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
157
177
|
{(current?.partial ||
|
|
158
178
|
trend?.partial ||
|
|
159
|
-
state === 'partial_missing_history' ||
|
|
179
|
+
(state === 'partial_missing_history' && !historyUnsupported) ||
|
|
160
180
|
state === 'partial_missing_current' ||
|
|
161
181
|
unsupportedCount > 0) && (
|
|
162
182
|
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
|
|
@@ -185,61 +205,76 @@ export function ApplicationCostTab({
|
|
|
185
205
|
<div className="text-sm font-semibold text-theme-text-primary">
|
|
186
206
|
Application compute cost
|
|
187
207
|
</div>
|
|
188
|
-
<CostInfoTooltip content="Values are based on
|
|
208
|
+
<CostInfoTooltip content="Values are based on CPU and memory allocation grouped by the workloads in this application. The cost provider attributes the greater of requested or observed resources." />
|
|
189
209
|
</div>
|
|
190
210
|
<div className="text-xs text-theme-text-tertiary">
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
CPU and memory allocation rate ({trendCurrency}/hr) for Deployment, StatefulSet,
|
|
212
|
+
and DaemonSet workloads
|
|
193
213
|
</div>
|
|
194
214
|
</div>
|
|
195
215
|
</div>
|
|
196
|
-
<CostTimeRangeSelector value={range} onChange={setRange} />
|
|
216
|
+
{!historyUnsupported && <CostTimeRangeSelector value={range} onChange={setRange} />}
|
|
197
217
|
</div>
|
|
198
218
|
|
|
199
|
-
|
|
200
|
-
<div className="
|
|
219
|
+
{historyUnsupported ? (
|
|
220
|
+
<div className="grid gap-4 p-4 sm:grid-cols-2">
|
|
201
221
|
<CostMetricBlock
|
|
202
|
-
label={
|
|
203
|
-
value={
|
|
204
|
-
|
|
205
|
-
trend?.windowTotalCost ?? 0,
|
|
206
|
-
trendLoading || state === 'partial_missing_history',
|
|
207
|
-
trendCurrency,
|
|
208
|
-
)}
|
|
209
|
-
subvalue={
|
|
210
|
-
state === 'partial_missing_history'
|
|
211
|
-
? 'Historical data incomplete'
|
|
212
|
-
: `${included} of ${total} workloads included`
|
|
213
|
-
}
|
|
222
|
+
label={rateLabels.hourly}
|
|
223
|
+
value={totals ? formatCostPerHour(hourly, currentCurrency) : '—'}
|
|
224
|
+
subvalue={`${included} of ${total} workloads included`}
|
|
214
225
|
/>
|
|
215
226
|
<CostMetricBlock
|
|
216
227
|
label="Projected monthly"
|
|
217
228
|
value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
218
|
-
subvalue={
|
|
219
|
-
totals
|
|
220
|
-
? `${formatCostPerHour(hourly, currentCurrency)} current rate`
|
|
221
|
-
: 'Current allocation unavailable'
|
|
222
|
-
}
|
|
229
|
+
subvalue={`${rateLabels.rate} × 730 hours`}
|
|
223
230
|
/>
|
|
224
231
|
</div>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
232
|
+
) : (
|
|
233
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
|
|
234
|
+
<div className="space-y-4">
|
|
235
|
+
<CostMetricBlock
|
|
236
|
+
label={`Spend over ${range}`}
|
|
237
|
+
value={formatHistoricalSpend(
|
|
238
|
+
points.length,
|
|
239
|
+
trend?.windowTotalCost ?? 0,
|
|
240
|
+
trendLoading || state === 'partial_missing_history',
|
|
241
|
+
trendCurrency,
|
|
242
|
+
)}
|
|
243
|
+
subvalue={
|
|
244
|
+
state === 'partial_missing_history'
|
|
245
|
+
? 'Historical data incomplete'
|
|
246
|
+
: `${included} of ${total} workloads included`
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
<CostMetricBlock
|
|
250
|
+
label="Projected monthly"
|
|
251
|
+
value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
252
|
+
subvalue={
|
|
253
|
+
totals
|
|
254
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
255
|
+
: 'Current allocation unavailable'
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="min-w-0">
|
|
260
|
+
{trendLoading ? (
|
|
261
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
262
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
263
|
+
Loading historical cost…
|
|
264
|
+
</div>
|
|
265
|
+
) : hasTrend && chartSeries.length > 0 ? (
|
|
266
|
+
<div className="min-w-0">
|
|
267
|
+
<StackedAreaChart series={chartSeries} currency={trendCurrency} />
|
|
268
|
+
<ChartLegend series={chartSeries} />
|
|
269
|
+
</div>
|
|
270
|
+
) : (
|
|
271
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
272
|
+
No historical workload owner cost points for this range.
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
241
276
|
</div>
|
|
242
|
-
|
|
277
|
+
)}
|
|
243
278
|
</section>
|
|
244
279
|
|
|
245
280
|
<div className="grid gap-4 md:grid-cols-2">
|
|
@@ -257,7 +292,7 @@ export function ApplicationCostTab({
|
|
|
257
292
|
value={totals ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
|
|
258
293
|
subvalue={
|
|
259
294
|
totals
|
|
260
|
-
? `${formatCostPerHour(hourly, currentCurrency)}
|
|
295
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
261
296
|
: 'Current allocation unavailable'
|
|
262
297
|
}
|
|
263
298
|
/>
|
|
@@ -274,6 +309,7 @@ export function ApplicationCostTab({
|
|
|
274
309
|
cpuUsageAvailable={totals?.cpuUsageAvailable ?? false}
|
|
275
310
|
memoryUsageAvailable={totals?.memoryUsageAvailable ?? false}
|
|
276
311
|
scopeNote="Included workloads only"
|
|
312
|
+
window={source === 'kubecost' ? currentWindow : undefined}
|
|
277
313
|
/>
|
|
278
314
|
|
|
279
315
|
<section className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
@@ -283,7 +319,7 @@ export function ApplicationCostTab({
|
|
|
283
319
|
Workload contributors
|
|
284
320
|
</div>
|
|
285
321
|
<div className="text-xs text-theme-text-tertiary">
|
|
286
|
-
Projected monthly from
|
|
322
|
+
Projected monthly from the {rateLabels.rate}, sorted by spend
|
|
287
323
|
</div>
|
|
288
324
|
</div>
|
|
289
325
|
<div className="text-xs text-theme-text-tertiary">{rows.length} tracked</div>
|
|
@@ -315,13 +351,15 @@ export function ApplicationCostTab({
|
|
|
315
351
|
</section>
|
|
316
352
|
|
|
317
353
|
<div className="text-xs text-theme-text-tertiary">
|
|
318
|
-
|
|
354
|
+
{costSourceLabel(source)} · {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', current?.dataThrough)}.{' '}
|
|
319
355
|
{currentCurrency !== DEFAULT_COST_CURRENCY && (
|
|
320
356
|
<>Labeled {currentCurrency}; no conversion. </>
|
|
321
357
|
)}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
358
|
+
{historyUnsupported
|
|
359
|
+
? 'Historical application charts are not available for Kubecost yet. '
|
|
360
|
+
: 'Historical spend uses the selected range. '}
|
|
361
|
+
Projected monthly values multiply the {rateLabels.rate}. Batch/job cost is separate;
|
|
362
|
+
storage/PVC and network costs remain at namespace and cluster level.
|
|
325
363
|
</div>
|
|
326
364
|
</div>
|
|
327
365
|
)
|
|
@@ -348,14 +386,19 @@ export function getApplicationCostState(
|
|
|
348
386
|
if (trendHasData) return 'partial_missing_current'
|
|
349
387
|
const reason =
|
|
350
388
|
current?.reason ??
|
|
351
|
-
trend?.reason ??
|
|
352
389
|
costUnavailableReasonFromError(status.currentError) ??
|
|
390
|
+
trend?.reason ??
|
|
353
391
|
costUnavailableReasonFromError(status.trendError)
|
|
354
392
|
if (
|
|
355
393
|
reason === 'no_prometheus' ||
|
|
394
|
+
reason === 'no_cost_source' ||
|
|
356
395
|
reason === 'query_error' ||
|
|
357
396
|
reason === 'access_denied' ||
|
|
358
|
-
reason === 'not_found'
|
|
397
|
+
reason === 'not_found' ||
|
|
398
|
+
reason === 'source_unavailable' ||
|
|
399
|
+
reason === 'authentication_error' ||
|
|
400
|
+
reason === 'configuration_mismatch' ||
|
|
401
|
+
reason === 'deployment_configuration_error'
|
|
359
402
|
)
|
|
360
403
|
return reason
|
|
361
404
|
if (queryError) return 'load_error'
|
|
@@ -462,10 +505,13 @@ function applicationCostKey(ref: { kind: string; namespace: string; name: string
|
|
|
462
505
|
}
|
|
463
506
|
|
|
464
507
|
function reasonLabel(reason?: CostUnavailableReason) {
|
|
465
|
-
if (reason === 'no_prometheus') return '
|
|
508
|
+
if (reason === 'no_prometheus') return 'Metrics backend not found'
|
|
509
|
+
if (reason === 'no_cost_source') return 'Cost source not found'
|
|
466
510
|
if (reason === 'query_error') return 'Cost query failed'
|
|
467
511
|
if (reason === 'access_denied') return 'No access to this workload'
|
|
468
512
|
if (reason === 'not_found') return 'Workload not found'
|
|
513
|
+
if (reason === 'configuration_mismatch') return 'Kubecost settings are not valid for this cluster'
|
|
514
|
+
if (reason === 'deployment_configuration_error') return 'Radar cost deployment is misconfigured'
|
|
469
515
|
return 'No workload cost metrics'
|
|
470
516
|
}
|
|
471
517
|
|
|
@@ -482,11 +528,11 @@ function ApplicationCostDiscovering({
|
|
|
482
528
|
<Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
|
|
483
529
|
<div>
|
|
484
530
|
<p className="text-sm font-medium text-theme-text-primary">
|
|
485
|
-
Looking for
|
|
531
|
+
Looking for cost data…
|
|
486
532
|
</p>
|
|
487
533
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
488
|
-
|
|
489
|
-
|
|
534
|
+
Radar is checking OpenCost metrics in a PromQL-compatible backend and a local Kubecost
|
|
535
|
+
3 Aggregator. First discovery can take a few seconds.
|
|
490
536
|
</p>
|
|
491
537
|
</div>
|
|
492
538
|
<button
|
|
@@ -504,23 +550,28 @@ function ApplicationCostDiscovering({
|
|
|
504
550
|
function ApplicationCostUnavailable({
|
|
505
551
|
state,
|
|
506
552
|
message,
|
|
553
|
+
settingsAvailable,
|
|
507
554
|
}: {
|
|
508
555
|
state: CostUnavailableReason | 'load_error'
|
|
509
556
|
message?: string
|
|
557
|
+
settingsAvailable: boolean
|
|
510
558
|
}) {
|
|
511
559
|
const text =
|
|
512
560
|
message ??
|
|
561
|
+
costIntegrationUnavailableMessage(state, settingsAvailable) ??
|
|
513
562
|
(state === 'no_prometheus'
|
|
514
|
-
? '
|
|
563
|
+
? 'No compatible metrics backend was found. OpenCost application cost requires OpenCost metrics in a PromQL-compatible backend.'
|
|
515
564
|
: state === 'query_error'
|
|
516
|
-
? 'Cost data is temporarily unavailable.
|
|
565
|
+
? 'Cost data is temporarily unavailable. A metrics backend was found, but application cost queries failed.'
|
|
566
|
+
: state === 'history_unsupported'
|
|
567
|
+
? 'Historical application cost is not available for Kubecost yet.'
|
|
517
568
|
: state === 'access_denied'
|
|
518
569
|
? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
|
|
519
570
|
: state === 'not_found'
|
|
520
571
|
? 'Cost data is unavailable because the referenced workloads no longer exist.'
|
|
521
572
|
: state === 'load_error'
|
|
522
573
|
? 'Could not load application cost data. Check access to these workloads and try again.'
|
|
523
|
-
: '
|
|
574
|
+
: 'No workload cost data was returned for this application by the active cost source.')
|
|
524
575
|
return (
|
|
525
576
|
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
526
577
|
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { OpenCostTrendSeries } from '../../api/client'
|
|
3
|
+
import { kubecostRetentionNote, kubecostTrendIsStale, kubecostTrendUnavailableMessage } from './CostTrendChart'
|
|
4
|
+
|
|
5
|
+
const hour = 60 * 60
|
|
6
|
+
|
|
7
|
+
function series(...timestamps: number[]): OpenCostTrendSeries[] {
|
|
8
|
+
return [{
|
|
9
|
+
namespace: 'default',
|
|
10
|
+
dataPoints: timestamps.map((timestamp) => ({ timestamp, value: 1 })),
|
|
11
|
+
}]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('kubecostRetentionNote', () => {
|
|
15
|
+
it('describes a material retention gap', () => {
|
|
16
|
+
const start = 1_000
|
|
17
|
+
expect(kubecostRetentionNote(series(start + 4 * hour, start + 5 * hour), start, start + 7 * 24 * hour))
|
|
18
|
+
.toContain('Kubecost history is available from')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('stays quiet when the first hourly bucket follows the requested start', () => {
|
|
22
|
+
const start = 1_000
|
|
23
|
+
expect(kubecostRetentionNote(series(start + hour, start + 2 * hour), start, start + 24 * hour)).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('stays quiet when there is only one retained sample', () => {
|
|
27
|
+
const start = 1_000
|
|
28
|
+
expect(kubecostRetentionNote(series(start + 4 * hour), start, start + 24 * hour)).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('allows the normal first daily bucket without calling retention partial', () => {
|
|
32
|
+
const start = 1_000
|
|
33
|
+
expect(kubecostRetentionNote(series(start + 24 * hour, start + 48 * hour), start, start + 7 * 24 * hour)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('kubecostTrendIsStale', () => {
|
|
38
|
+
it('allows normal hourly ingestion lag', () => {
|
|
39
|
+
const now = Date.parse('2026-08-30T12:00:00Z')
|
|
40
|
+
expect(kubecostTrendIsStale('2026-08-30T10:00:00Z', '6h', now)).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('flags a source beyond the range lag budget', () => {
|
|
44
|
+
const now = Date.parse('2026-08-30T12:00:00Z')
|
|
45
|
+
expect(kubecostTrendIsStale('2026-08-30T02:00:00Z', '24h', now)).toBe(true)
|
|
46
|
+
expect(kubecostTrendIsStale('2026-08-28T02:00:00Z', '7d', now)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('stays quiet without a valid retained timestamp', () => {
|
|
50
|
+
expect(kubecostTrendIsStale(undefined, '6h')).toBe(false)
|
|
51
|
+
expect(kubecostTrendIsStale('invalid', '6h')).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('kubecostTrendUnavailableMessage', () => {
|
|
56
|
+
it('distinguishes an empty namespace scope from missing Kubecost history', () => {
|
|
57
|
+
expect(kubecostTrendUnavailableMessage('no_metrics', true)).toContain('current namespace scope')
|
|
58
|
+
expect(kubecostTrendUnavailableMessage('no_metrics', false)).toContain('Kubecost has no retained')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('distinguishes insufficient scoped history', () => {
|
|
62
|
+
expect(kubecostTrendUnavailableMessage('insufficient_history', true)).toContain('namespace scope')
|
|
63
|
+
expect(kubecostTrendUnavailableMessage('insufficient_history', false)).toContain('two retained samples')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useCallback } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Loader2, TrendingUp } from 'lucide-react'
|
|
4
|
-
import {
|
|
4
|
+
import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
|
|
5
|
+
import {
|
|
6
|
+
useOpenCostTrend,
|
|
7
|
+
type CostTimeRange,
|
|
8
|
+
type CostUnavailableReason,
|
|
9
|
+
type OpenCostTrendSeries,
|
|
10
|
+
} from '../../api/client'
|
|
5
11
|
import { DEFAULT_COST_CURRENCY, formatCostAxis, formatCostPerHour } from './format'
|
|
12
|
+
import { costDataThroughLabel } from './source'
|
|
6
13
|
|
|
7
14
|
const SERIES_COLORS = [
|
|
8
15
|
'#3b82f6', // blue-500
|
|
@@ -22,7 +29,7 @@ const TIME_RANGES: { value: CostTimeRange; label: string }[] = [
|
|
|
22
29
|
{ value: '7d', label: '7d' },
|
|
23
30
|
]
|
|
24
31
|
|
|
25
|
-
export function CostTrendChart() {
|
|
32
|
+
export function CostTrendChart({ namespaceScoped = false }: { namespaceScoped?: boolean }) {
|
|
26
33
|
const [timeRange, setTimeRange] = useState<CostTimeRange>('24h')
|
|
27
34
|
const { data, isLoading } = useOpenCostTrend(timeRange)
|
|
28
35
|
|
|
@@ -37,11 +44,16 @@ export function CostTrendChart() {
|
|
|
37
44
|
)
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
const showKubecostUnavailable = data?.source === 'kubecost' && data.available === false
|
|
48
|
+
if ((!data?.available || !data.series?.length) && !showKubecostUnavailable) {
|
|
41
49
|
return null
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
const currency = data.currency ?? DEFAULT_COST_CURRENCY
|
|
53
|
+
const retentionNote = data.available ? kubecostRetentionNote(data.series ?? [], data.windowStart, data.windowEnd) : null
|
|
54
|
+
const dataThrough = data.source === 'kubecost' ? costDataThroughLabel(data.dataThrough) : ''
|
|
55
|
+
const stale = data.source === 'kubecost' && kubecostTrendIsStale(data.dataThrough, timeRange)
|
|
56
|
+
const lag = stale ? formatCompactAge(data.dataThrough) : ''
|
|
45
57
|
|
|
46
58
|
return (
|
|
47
59
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
@@ -50,16 +62,32 @@ export function CostTrendChart() {
|
|
|
50
62
|
<TrendingUp className="w-4 h-4 text-theme-text-tertiary" />
|
|
51
63
|
<div>
|
|
52
64
|
<div className="text-xs font-medium text-theme-text-secondary">Cost rate trend</div>
|
|
53
|
-
<div className=
|
|
54
|
-
|
|
65
|
+
<div className={clsx('text-[10px]', stale ? 'text-warning-text' : 'text-theme-text-tertiary')}>
|
|
66
|
+
{data.source === 'kubecost'
|
|
67
|
+
? `Retained Kubecost namespace cost (${currency}/hr)${dataThrough ? ` · data through ${dataThrough}` : ''}${lag ? ` · ${lag} behind` : ''}`
|
|
68
|
+
: `Historical OpenCost CPU and memory allocation (${currency}/hr)`}
|
|
55
69
|
</div>
|
|
56
70
|
</div>
|
|
57
71
|
</div>
|
|
58
72
|
<CostTimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
|
59
73
|
</div>
|
|
60
|
-
<div className="p-4">
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
<div className="p-4 min-h-[300px]">
|
|
75
|
+
{showKubecostUnavailable ? (
|
|
76
|
+
<div className="min-h-[268px] flex items-center justify-center px-6 text-center text-xs text-theme-text-tertiary">
|
|
77
|
+
{kubecostTrendUnavailableMessage(data.reason, namespaceScoped)}
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
<StackedAreaChart
|
|
82
|
+
series={data.series ?? []}
|
|
83
|
+
currency={currency}
|
|
84
|
+
windowStart={data.windowStart}
|
|
85
|
+
windowEnd={data.windowEnd}
|
|
86
|
+
/>
|
|
87
|
+
<div className="h-4 mt-1 text-[10px] text-theme-text-tertiary">{retentionNote}</div>
|
|
88
|
+
<ChartLegend series={data.series ?? []} />
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
63
91
|
</div>
|
|
64
92
|
</div>
|
|
65
93
|
)
|
|
@@ -68,9 +96,13 @@ export function CostTrendChart() {
|
|
|
68
96
|
export function StackedAreaChart({
|
|
69
97
|
series,
|
|
70
98
|
currency,
|
|
99
|
+
windowStart,
|
|
100
|
+
windowEnd,
|
|
71
101
|
}: {
|
|
72
102
|
series: OpenCostTrendSeries[]
|
|
73
103
|
currency: string
|
|
104
|
+
windowStart?: number
|
|
105
|
+
windowEnd?: number
|
|
74
106
|
}) {
|
|
75
107
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
76
108
|
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
@@ -98,8 +130,10 @@ export function StackedAreaChart({
|
|
|
98
130
|
const timestamps = Array.from(tsSet).sort((a, b) => a - b)
|
|
99
131
|
if (timestamps.length < 2) return null
|
|
100
132
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
133
|
+
const dataMinTs = timestamps[0]
|
|
134
|
+
const dataMaxTs = timestamps[timestamps.length - 1]
|
|
135
|
+
const minTs = windowStart && windowStart < dataMinTs ? windowStart : dataMinTs
|
|
136
|
+
const maxTs = windowEnd && windowEnd > dataMaxTs ? windowEnd : dataMaxTs
|
|
103
137
|
|
|
104
138
|
const seriesLookups = series.map((s) => {
|
|
105
139
|
const map = new Map<number, number>()
|
|
@@ -140,7 +174,7 @@ export function StackedAreaChart({
|
|
|
140
174
|
const xTickCount = 6
|
|
141
175
|
const xTicks = Array.from({ length: xTickCount + 1 }, (_, i) => {
|
|
142
176
|
const ts = minTs + ((maxTs - minTs) / xTickCount) * i
|
|
143
|
-
return { ts, x: toX(ts), label: formatTimestamp(ts) }
|
|
177
|
+
return { ts, x: toX(ts), label: formatTimestamp(ts, maxTs - minTs) }
|
|
144
178
|
})
|
|
145
179
|
|
|
146
180
|
// Build stacked area paths
|
|
@@ -185,7 +219,7 @@ export function StackedAreaChart({
|
|
|
185
219
|
xTicks,
|
|
186
220
|
paths,
|
|
187
221
|
}
|
|
188
|
-
}, [series, currency, plotHeight, plotWidth])
|
|
222
|
+
}, [series, currency, plotHeight, plotWidth, windowStart, windowEnd])
|
|
189
223
|
|
|
190
224
|
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
|
|
191
225
|
const hoverData = useMemo(() => {
|
|
@@ -417,13 +451,69 @@ function formatCostTooltip(value: number, currency: string): string {
|
|
|
417
451
|
return formatCostPerHour(value, currency)
|
|
418
452
|
}
|
|
419
453
|
|
|
420
|
-
function formatTimestamp(unix: number): string {
|
|
454
|
+
function formatTimestamp(unix: number, spanSeconds: number): string {
|
|
421
455
|
const d = new Date(unix * 1000)
|
|
422
|
-
|
|
423
|
-
const diffHours = (now.getTime() - d.getTime()) / (1000 * 60 * 60)
|
|
424
|
-
// Show date+time for ranges > 24h, just time otherwise
|
|
425
|
-
if (diffHours > 36) {
|
|
456
|
+
if (spanSeconds > 36 * 60 * 60) {
|
|
426
457
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
427
458
|
}
|
|
428
459
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
429
460
|
}
|
|
461
|
+
|
|
462
|
+
export function kubecostRetentionNote(
|
|
463
|
+
series: OpenCostTrendSeries[],
|
|
464
|
+
windowStart?: number,
|
|
465
|
+
windowEnd?: number,
|
|
466
|
+
): string | null {
|
|
467
|
+
if (!windowStart || !windowEnd) return null
|
|
468
|
+
const timestamps = Array.from(new Set(series.flatMap((item) => item.dataPoints.map((point) => point.timestamp)))).sort(
|
|
469
|
+
(a, b) => a - b,
|
|
470
|
+
)
|
|
471
|
+
if (timestamps.length < 2) return null
|
|
472
|
+
|
|
473
|
+
const intervals = timestamps.slice(1).map((timestamp, index) => timestamp - timestamps[index]).filter((value) => value > 0)
|
|
474
|
+
const typicalInterval = intervals.sort((a, b) => a - b)[Math.floor(intervals.length / 2)] ?? 0
|
|
475
|
+
const materialGap = Math.max(2 * 60 * 60, typicalInterval * 1.5)
|
|
476
|
+
if (timestamps[0] - windowStart <= materialGap) return null
|
|
477
|
+
|
|
478
|
+
const start = new Date(timestamps[0] * 1000).toLocaleString([], {
|
|
479
|
+
month: 'short',
|
|
480
|
+
day: 'numeric',
|
|
481
|
+
hour: '2-digit',
|
|
482
|
+
minute: '2-digit',
|
|
483
|
+
})
|
|
484
|
+
return `Kubecost history is available from ${start}.`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function kubecostTrendIsStale(
|
|
488
|
+
dataThrough: string | undefined,
|
|
489
|
+
range: CostTimeRange,
|
|
490
|
+
now = Date.now(),
|
|
491
|
+
): boolean {
|
|
492
|
+
if (!dataThrough) return false
|
|
493
|
+
const timestamp = Date.parse(dataThrough)
|
|
494
|
+
if (!Number.isFinite(timestamp)) return false
|
|
495
|
+
const lagBudget = range === '7d' ? 24 * 60 * 60 * 1000 : 6 * 60 * 60 * 1000
|
|
496
|
+
return now - timestamp > lagBudget
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function kubecostTrendUnavailableMessage(
|
|
500
|
+
reason?: CostUnavailableReason,
|
|
501
|
+
namespaceScoped = false,
|
|
502
|
+
): string {
|
|
503
|
+
switch (reason) {
|
|
504
|
+
case 'no_metrics':
|
|
505
|
+
if (namespaceScoped) return 'No allocation history is visible in the current namespace scope.'
|
|
506
|
+
return 'Kubecost has no retained allocation history for this range.'
|
|
507
|
+
case 'insufficient_history':
|
|
508
|
+
if (namespaceScoped) return 'The current namespace scope does not have enough allocation history to draw this range.'
|
|
509
|
+
return 'Kubecost needs at least two retained samples to draw this range.'
|
|
510
|
+
case 'authentication_error':
|
|
511
|
+
return 'Kubecost rejected Radar’s API key. Check Settings → Cost.'
|
|
512
|
+
case 'source_unavailable':
|
|
513
|
+
return 'Radar could not reach Kubecost. Check Settings → Cost.'
|
|
514
|
+
case 'configuration_mismatch':
|
|
515
|
+
return 'Kubecost returned data for a different cluster. Check the cluster ID in Settings → Cost.'
|
|
516
|
+
default:
|
|
517
|
+
return 'Kubecost history could not be loaded.'
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { resourceKindForCostWorkload } from './CostView'
|
|
2
|
+
import { costUnavailableMessage, resourceKindForCostWorkload } from './CostView'
|
|
3
|
+
import { costConfigurationAction } from './source'
|
|
3
4
|
|
|
4
5
|
describe('resourceKindForCostWorkload', () => {
|
|
5
6
|
it('does not link standalone aggregate rows as pods', () => {
|
|
@@ -10,3 +11,37 @@ describe('resourceKindForCostWorkload', () => {
|
|
|
10
11
|
expect(resourceKindForCostWorkload('staticpod')).toBe('Pod')
|
|
11
12
|
})
|
|
12
13
|
})
|
|
14
|
+
|
|
15
|
+
describe('costUnavailableMessage', () => {
|
|
16
|
+
it('gives actionable Kubecost source and authentication guidance', () => {
|
|
17
|
+
expect(costUnavailableMessage('source_unavailable')).toContain('Settings → Cost')
|
|
18
|
+
expect(costUnavailableMessage('authentication_error')).toContain('API key')
|
|
19
|
+
expect(costUnavailableMessage('configuration_mismatch')).toContain('not valid for this cluster')
|
|
20
|
+
expect(costUnavailableMessage('deployment_configuration_error')).toContain('Helm cost values')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('distinguishes a scoped empty result from missing source data', () => {
|
|
24
|
+
expect(costUnavailableMessage('no_metrics', { namespaceScoped: true })).toBe(
|
|
25
|
+
'No allocation data is visible in the current namespace scope',
|
|
26
|
+
)
|
|
27
|
+
expect(costUnavailableMessage('no_metrics')).toContain('selected source')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('does not point embedded deployments at standalone Settings', () => {
|
|
31
|
+
expect(costUnavailableMessage('no_cost_source', { settingsAvailable: false })).toContain('host application')
|
|
32
|
+
expect(costUnavailableMessage('no_cost_source', { settingsAvailable: false })).not.toContain('Settings')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('costConfigurationAction', () => {
|
|
37
|
+
it('routes missing Prometheus to Metrics and other source failures to Cost', () => {
|
|
38
|
+
expect(costConfigurationAction('no_prometheus')).toEqual({
|
|
39
|
+
section: 'prometheus',
|
|
40
|
+
label: 'Configure metrics',
|
|
41
|
+
})
|
|
42
|
+
expect(costConfigurationAction('no_cost_source')).toEqual({
|
|
43
|
+
section: 'cost',
|
|
44
|
+
label: 'Configure cost source',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|