@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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. 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 [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
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 === 'no_prometheus') {
81
- setNoPrometheusSince((prev) => prev ?? Date.now())
89
+ if (isCostDiscoveryPending(state)) {
90
+ setDiscoverySince((prev) => prev ?? Date.now())
82
91
  } else {
83
- setNoPrometheusSince(null)
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 = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
121
- if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
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
- setNoPrometheusSince(Date.now())
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 OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
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
- OpenCost CPU and memory allocation rate ({trendCurrency}/hr) for Deployment,
192
- StatefulSet, and DaemonSet workloads
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
- <div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
200
- <div className="space-y-4">
219
+ {historyUnsupported ? (
220
+ <div className="grid gap-4 p-4 sm:grid-cols-2">
201
221
  <CostMetricBlock
202
- label={`Spend over ${range}`}
203
- value={formatHistoricalSpend(
204
- points.length,
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
- <div className="min-w-0">
226
- {trendLoading ? (
227
- <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">
228
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
229
- Loading historical cost…
230
- </div>
231
- ) : hasTrend && chartSeries.length > 0 ? (
232
- <div className="min-w-0">
233
- <StackedAreaChart series={chartSeries} currency={trendCurrency} />
234
- <ChartLegend series={chartSeries} />
235
- </div>
236
- ) : (
237
- <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">
238
- No historical workload owner cost points for this range.
239
- </div>
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
- </div>
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)} current hourly rate`
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 current allocation, sorted by spend
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
- Powered by OpenCost via Prometheus.{' '}
354
+ {costSourceLabel(source)} &middot; {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', current?.dataThrough)}.{' '}
319
355
  {currentCurrency !== DEFAULT_COST_CURRENCY && (
320
356
  <>Labeled {currentCurrency}; no conversion. </>
321
357
  )}
322
- Historical spend uses the selected range; projected monthly values multiply current hourly
323
- allocation. Batch/job cost is separate; storage/PVC and network costs remain at namespace
324
- and cluster level.
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 'Prometheus not found'
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 Prometheus cost data…
531
+ Looking for cost data…
486
532
  </p>
487
533
  <p className="mt-1 text-xs text-theme-text-tertiary">
488
- First discovery can take a few seconds while Radar checks cluster services and opens a
489
- local port-forward.
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
- ? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.'
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. Prometheus was found, but application cost queries failed.'
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
- : 'OpenCost workload metrics were not found for this application.')
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 { useOpenCostTrend, type CostTimeRange, type OpenCostTrendSeries } from '../../api/client'
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
- if (!data?.available || !data.series?.length) {
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="text-[10px] text-theme-text-tertiary">
54
- Historical OpenCost allocation rate ({currency}/hr)
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
- <StackedAreaChart series={data.series} currency={currency} />
62
- <ChartLegend series={data.series} />
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 minTs = timestamps[0]
102
- const maxTs = timestamps[timestamps.length - 1]
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
- const now = new Date()
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
+ })