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