@skyhook-io/radar-app 1.12.3 → 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.
@@ -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
+ })
@@ -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 [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
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 === 'no_prometheus') {
69
- setNoPrometheusSince((prev) => prev ?? Date.now())
88
+ if (data?.available === false && isCostDiscoveryPending(data.reason)) {
89
+ setDiscoverySince((prev) => prev ?? Date.now())
70
90
  } else {
71
- setNoPrometheusSince(null)
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 = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
86
- if (reason === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
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 Prometheus cost data…
114
+ Looking for cost data…
95
115
  </p>
96
116
  <p className="mt-1 text-xs text-theme-text-tertiary">
97
- First discovery can take a few seconds while Radar checks cluster services and
98
- opens a local port-forward.
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
- setNoPrometheusSince(Date.now())
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
- reason === 'no_prometheus'
118
- ? 'Prometheus not found — OpenCost requires Prometheus or VictoriaMetrics'
119
- : reason === 'no_metrics'
120
- ? 'OpenCost metrics not found Prometheus is available but no cost metrics were detected'
121
- : reason === 'query_error'
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
- <button
132
- onClick={onBack}
133
- className="text-xs text-skyhook-400 hover:text-skyhook-300 transition-colors"
134
- >
135
- Back to Dashboard
136
- </button>
137
- {reason === 'no_prometheus' && (
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
- setNoPrometheusSince(Date.now())
164
+ setDiscoverySince(Date.now())
141
165
  refetch()
142
166
  }}
143
167
  disabled={isFetching}
144
- className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
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
- projected from last 1h average
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
- Cluster Resource Cost
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 current rate
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="Projected from current hourly rate — not historical spend"
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="Projected monthly CPU and memory allocation from the current hourly rate"
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} &middot; current rates based on last 1h average &middot;
394
+ {currency} &middot; {costFreshnessLabel(data.source, data.window, data.dataThrough)} &middot;
350
395
  *monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo
351
396
  {currency !== DEFAULT_COST_CURRENCY && (
352
397
  <> &middot; no conversion</>
353
398
  )}
354
399
  </span>
355
- <span className="text-indigo-500 font-medium">Powered by OpenCost</span>
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">Node Costs</span>
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
- <span className="text-xs text-theme-text-tertiary">{nodes.length} nodes</span>
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
- Cost data comes from <strong>OpenCost</strong>, an open-source tool that combines your
808
- cloud provider's pricing (how much each node costs per hour) with Kubernetes resource
809
- allocation data. This gives you a cost value for each workload running on your cluster.
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 an active
820
- OpenCost/Kubecost installation when Prometheus is cluster-discovered, then falls back
821
- to USD. Override it in <strong>Settings Cost</strong> or, for automation, with{' '}
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 current hourly allocation rate. They
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
- Cost rates and breakdowns are <strong>snapshots based on the last 1 hour</strong> of
850
- data. They update automatically every minute. The trend chart shows historical hourly
851
- allocation rate over the selected time range (6 hours, 24 hours, or 7 days).
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
- Because costs are based on a 1-hour window, short-lived spikes or dips may not be
855
- reflected. The trend chart gives you the longer-term rate picture.
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