@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
|
@@ -2,6 +2,7 @@ import { clsx } from 'clsx'
|
|
|
2
2
|
import { HelpCircle } from 'lucide-react'
|
|
3
3
|
import { Tooltip } from '../ui/Tooltip'
|
|
4
4
|
import { formatCostPerHour, formatProjectedMonthlyRate } from './format'
|
|
5
|
+
import { costRateLabels } from './source'
|
|
5
6
|
|
|
6
7
|
interface CurrentAllocationUseProps {
|
|
7
8
|
currency: string
|
|
@@ -14,10 +15,11 @@ interface CurrentAllocationUseProps {
|
|
|
14
15
|
cpuUsageAvailable: boolean
|
|
15
16
|
memoryUsageAvailable: boolean
|
|
16
17
|
scopeNote?: string
|
|
18
|
+
window?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const ALLOCATION_USE_TOOLTIP =
|
|
20
|
-
'
|
|
22
|
+
'Cost allocation uses the greater of requested or observed CPU and memory. The percentages compare observed use with that allocated amount; they are not request headroom or a right-sizing recommendation.'
|
|
21
23
|
|
|
22
24
|
export function formatAllocatedUse(
|
|
23
25
|
allocationCost: number,
|
|
@@ -43,23 +45,25 @@ export function CurrentAllocationUse({
|
|
|
43
45
|
cpuUsageAvailable,
|
|
44
46
|
memoryUsageAvailable,
|
|
45
47
|
scopeNote,
|
|
48
|
+
window,
|
|
46
49
|
}: CurrentAllocationUseProps) {
|
|
47
50
|
const splitTotal = cpuCost + memoryCost
|
|
48
51
|
const cpuPct = splitTotal > 0 ? (cpuCost / splitTotal) * 100 : 0
|
|
49
52
|
const memoryPct = splitTotal > 0 ? (memoryCost / splitTotal) * 100 : 0
|
|
53
|
+
const labels = costRateLabels(window)
|
|
50
54
|
|
|
51
55
|
return (
|
|
52
56
|
<section className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
53
57
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
54
58
|
<div>
|
|
55
59
|
<div className="flex items-center gap-1.5">
|
|
56
|
-
<div className="text-sm font-semibold text-theme-text-primary">
|
|
60
|
+
<div className="text-sm font-semibold text-theme-text-primary">{labels.allocationTitle}</div>
|
|
57
61
|
<Tooltip content={ALLOCATION_USE_TOOLTIP} className="max-w-[320px] whitespace-normal text-left" delay={150}>
|
|
58
62
|
<HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
|
|
59
63
|
</Tooltip>
|
|
60
64
|
</div>
|
|
61
65
|
<div className="text-xs text-theme-text-tertiary">
|
|
62
|
-
|
|
66
|
+
{labels.allocationPeriod}
|
|
63
67
|
{scopeNote ? ` · ${scopeNote}` : ''}
|
|
64
68
|
</div>
|
|
65
69
|
</div>
|
|
@@ -69,7 +73,7 @@ export function CurrentAllocationUse({
|
|
|
69
73
|
</div>
|
|
70
74
|
{dataAvailable && (
|
|
71
75
|
<div className="text-[10px] text-theme-text-tertiary tabular-nums">
|
|
72
|
-
{formatCostPerHour(hourlyCost, currency)}
|
|
76
|
+
{formatCostPerHour(hourlyCost, currency)} · {labels.rate}
|
|
73
77
|
</div>
|
|
74
78
|
)}
|
|
75
79
|
</div>
|
|
@@ -69,6 +69,53 @@ describe('getWorkloadCostState', () => {
|
|
|
69
69
|
expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
it('keeps Kubecost current cost visible when history is unsupported', () => {
|
|
73
|
+
const current: OpenCostWorkloadDetailResponse = {
|
|
74
|
+
available: true,
|
|
75
|
+
source: 'kubecost',
|
|
76
|
+
currency: 'USD',
|
|
77
|
+
namespace: 'default',
|
|
78
|
+
kind: 'StatefulSet',
|
|
79
|
+
name: 'queue',
|
|
80
|
+
current: {
|
|
81
|
+
name: 'queue', kind: 'StatefulSet', hourlyCost: 0.2, cpuCost: 0.12,
|
|
82
|
+
memoryCost: 0.08, replicas: 1, cpuUsageAvailable: true,
|
|
83
|
+
memoryUsageAvailable: true, cpuAllocationUse: 25, memoryAllocationUse: 25,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const trend: OpenCostWorkloadTrendResponse = {
|
|
87
|
+
available: false,
|
|
88
|
+
source: 'kubecost',
|
|
89
|
+
reason: 'history_unsupported',
|
|
90
|
+
currency: 'USD',
|
|
91
|
+
namespace: 'default',
|
|
92
|
+
kind: 'StatefulSet',
|
|
93
|
+
name: 'queue',
|
|
94
|
+
range: '24h',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not let unsupported history mask current loading or errors', () => {
|
|
101
|
+
const trend: OpenCostWorkloadTrendResponse = {
|
|
102
|
+
available: false,
|
|
103
|
+
source: 'kubecost',
|
|
104
|
+
reason: 'history_unsupported',
|
|
105
|
+
currency: 'USD',
|
|
106
|
+
namespace: 'default',
|
|
107
|
+
kind: 'StatefulSet',
|
|
108
|
+
name: 'queue',
|
|
109
|
+
range: '24h',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(getWorkloadCostState(undefined, trend, { currentLoading: true })).toBe('loading')
|
|
113
|
+
expect(getWorkloadCostState(undefined, trend, { currentError: true })).toBe('load_error')
|
|
114
|
+
expect(
|
|
115
|
+
getWorkloadCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
|
|
116
|
+
).toBe('access_denied')
|
|
117
|
+
})
|
|
118
|
+
|
|
72
119
|
it('keeps current cost visible while historical owner metrics are still loading', () => {
|
|
73
120
|
const current: OpenCostWorkloadDetailResponse = {
|
|
74
121
|
available: true,
|
|
@@ -146,6 +193,9 @@ describe('getWorkloadCostState', () => {
|
|
|
146
193
|
expect(getWorkloadCostState(undefined, missing, false)).toBe('not_found')
|
|
147
194
|
expect(getWorkloadCostState(undefined, undefined, { currentError: new ApiError('denied', 403) })).toBe('access_denied')
|
|
148
195
|
expect(getWorkloadCostState(undefined, undefined, { trendError: new ApiError('missing', 404) })).toBe('not_found')
|
|
196
|
+
|
|
197
|
+
current.reason = 'configuration_mismatch'
|
|
198
|
+
expect(getWorkloadCostState(current, undefined, false)).toBe('configuration_mismatch')
|
|
149
199
|
})
|
|
150
200
|
|
|
151
201
|
it('shows Prometheus discovery as soon as one query reports it', () => {
|
|
@@ -20,6 +20,14 @@ import {
|
|
|
20
20
|
} from './format'
|
|
21
21
|
import { CurrentAllocationUse } from './CurrentAllocationUse'
|
|
22
22
|
import { costUnavailableReasonFromError } from './errors'
|
|
23
|
+
import {
|
|
24
|
+
costFreshnessLabel,
|
|
25
|
+
costIntegrationUnavailableMessage,
|
|
26
|
+
costRateLabels,
|
|
27
|
+
costSourceLabel,
|
|
28
|
+
isCostDiscoveryPending,
|
|
29
|
+
} from './source'
|
|
30
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
23
31
|
|
|
24
32
|
type WorkloadCostState =
|
|
25
33
|
| 'loading'
|
|
@@ -44,8 +52,9 @@ interface WorkloadCostTabProps {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) {
|
|
55
|
+
const settingsAvailable = !useNavCustomization().embedded
|
|
47
56
|
const [range, setRange] = useState<CostTimeRange>('24h')
|
|
48
|
-
const [
|
|
57
|
+
const [discoverySince, setDiscoverySince] = useState<number | null>(null)
|
|
49
58
|
const currentQuery = useOpenCostWorkload(kind, namespace, name)
|
|
50
59
|
const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range)
|
|
51
60
|
const trendMatchesRange = trendQuery.data?.range === range
|
|
@@ -62,10 +71,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
62
71
|
})
|
|
63
72
|
|
|
64
73
|
useEffect(() => {
|
|
65
|
-
if (state
|
|
66
|
-
|
|
74
|
+
if (isCostDiscoveryPending(state)) {
|
|
75
|
+
setDiscoverySince((prev) => prev ?? Date.now())
|
|
67
76
|
} else {
|
|
68
|
-
|
|
77
|
+
setDiscoverySince(null)
|
|
69
78
|
}
|
|
70
79
|
}, [state])
|
|
71
80
|
|
|
@@ -84,22 +93,28 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
84
93
|
state === 'query_error' ||
|
|
85
94
|
state === 'access_denied' ||
|
|
86
95
|
state === 'not_found' ||
|
|
96
|
+
state === 'no_cost_source' ||
|
|
97
|
+
state === 'source_unavailable' ||
|
|
98
|
+
state === 'authentication_error' ||
|
|
99
|
+
state === 'configuration_mismatch' ||
|
|
100
|
+
state === 'deployment_configuration_error' ||
|
|
101
|
+
state === 'history_unsupported' ||
|
|
87
102
|
state === 'load_error'
|
|
88
103
|
) {
|
|
89
|
-
const discoveryAgeMs =
|
|
90
|
-
if (state
|
|
104
|
+
const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
|
|
105
|
+
if (isCostDiscoveryPending(state) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
91
106
|
return (
|
|
92
107
|
<WorkloadCostDiscovering
|
|
93
108
|
isFetching={currentQuery.isFetching || trendQuery.isFetching}
|
|
94
109
|
onRetry={() => {
|
|
95
|
-
|
|
110
|
+
setDiscoverySince(Date.now())
|
|
96
111
|
currentQuery.refetch()
|
|
97
112
|
trendQuery.refetch()
|
|
98
113
|
}}
|
|
99
114
|
/>
|
|
100
115
|
)
|
|
101
116
|
}
|
|
102
|
-
return <WorkloadCostUnavailable state={state} />
|
|
117
|
+
return <WorkloadCostUnavailable state={state} settingsAvailable={settingsAvailable} />
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
const current = currentQuery.data?.current
|
|
@@ -119,6 +134,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
119
134
|
trendLoading || state === 'partial_missing_history',
|
|
120
135
|
trendCurrency,
|
|
121
136
|
)
|
|
137
|
+
const source = currentQuery.data?.source ?? trend?.source
|
|
138
|
+
const historyUnsupported = trend?.reason === 'history_unsupported'
|
|
139
|
+
const currentWindow = currentQuery.data?.window
|
|
140
|
+
const rateLabels = costRateLabels(currentWindow)
|
|
122
141
|
|
|
123
142
|
return (
|
|
124
143
|
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
@@ -129,65 +148,76 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
129
148
|
<div>
|
|
130
149
|
<div className="flex items-center gap-1.5">
|
|
131
150
|
<div className="text-sm font-semibold text-theme-text-primary">
|
|
132
|
-
Historical compute cost
|
|
151
|
+
{historyUnsupported ? 'Compute allocation cost' : 'Historical compute cost'}
|
|
133
152
|
</div>
|
|
134
|
-
<MetricInfoTooltip content="Values are based on
|
|
153
|
+
<MetricInfoTooltip content="Values are based on CPU and memory allocation, not raw utilization. The cost provider attributes the greater of requested or observed resources." />
|
|
135
154
|
</div>
|
|
136
155
|
<div className="text-xs text-theme-text-tertiary">
|
|
137
|
-
|
|
138
|
-
ownership
|
|
156
|
+
CPU and memory allocation rate ({trendCurrency}/hr) attributed by workload ownership
|
|
139
157
|
</div>
|
|
140
158
|
</div>
|
|
141
159
|
</div>
|
|
142
|
-
<CostTimeRangeSelector value={range} onChange={setRange} />
|
|
160
|
+
{!historyUnsupported && <CostTimeRangeSelector value={range} onChange={setRange} />}
|
|
143
161
|
</div>
|
|
144
162
|
|
|
145
|
-
|
|
146
|
-
<div className="
|
|
163
|
+
{historyUnsupported ? (
|
|
164
|
+
<div className="grid gap-4 p-4 sm:grid-cols-2">
|
|
147
165
|
<MetricBlock
|
|
148
|
-
label={
|
|
149
|
-
value={
|
|
150
|
-
subvalue=
|
|
151
|
-
state === 'partial_missing_history' ? 'Historical data unavailable' : undefined
|
|
152
|
-
}
|
|
166
|
+
label={rateLabels.hourly}
|
|
167
|
+
value={hasCurrent ? formatCostPerHour(hourly, currentCurrency) : '—'}
|
|
168
|
+
subvalue="CPU and memory allocation"
|
|
153
169
|
/>
|
|
154
170
|
<MetricBlock
|
|
155
171
|
label="Projected monthly"
|
|
156
172
|
value={hasCurrent ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
157
|
-
subvalue={
|
|
158
|
-
hasCurrent
|
|
159
|
-
? `${formatCostPerHour(hourly, currentCurrency)} current rate`
|
|
160
|
-
: 'Current allocation unavailable'
|
|
161
|
-
}
|
|
173
|
+
subvalue={`${rateLabels.rate} × 730 hours`}
|
|
162
174
|
/>
|
|
163
175
|
</div>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
currency={trendCurrency}
|
|
176
|
+
) : (
|
|
177
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
178
|
+
<div className="space-y-4">
|
|
179
|
+
<MetricBlock
|
|
180
|
+
label={`Spend over ${range}`}
|
|
181
|
+
value={windowSpendValue}
|
|
182
|
+
subvalue={
|
|
183
|
+
state === 'partial_missing_history' ? 'Historical data unavailable' : undefined
|
|
184
|
+
}
|
|
174
185
|
/>
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
<MetricBlock
|
|
187
|
+
label="Projected monthly"
|
|
188
|
+
value={hasCurrent ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
189
|
+
subvalue={
|
|
190
|
+
hasCurrent
|
|
191
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
192
|
+
: 'Current allocation unavailable'
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="min-w-0">
|
|
197
|
+
{trendLoading ? (
|
|
198
|
+
<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">
|
|
199
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
200
|
+
Loading historical cost…
|
|
201
|
+
</div>
|
|
202
|
+
) : hasTrend ? (
|
|
203
|
+
<StackedAreaChart
|
|
204
|
+
series={[{ namespace: 'Allocation rate', dataPoints: points }]}
|
|
205
|
+
currency={trendCurrency}
|
|
206
|
+
/>
|
|
207
|
+
) : (
|
|
208
|
+
<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">
|
|
209
|
+
No historical workload owner cost points for this range.
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
180
213
|
</div>
|
|
181
|
-
|
|
214
|
+
)}
|
|
182
215
|
</section>
|
|
183
216
|
|
|
184
|
-
{state === 'partial_missing_history' && (
|
|
217
|
+
{state === 'partial_missing_history' && !historyUnsupported && (
|
|
185
218
|
<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">
|
|
186
219
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
187
|
-
<span>
|
|
188
|
-
Current cost is available, but historical workload owner metrics are not available for
|
|
189
|
-
this range.
|
|
190
|
-
</span>
|
|
220
|
+
<span>Current cost is available, but historical workload owner metrics are not available for this range.</span>
|
|
191
221
|
</div>
|
|
192
222
|
)}
|
|
193
223
|
{state === 'partial_missing_current' && (
|
|
@@ -200,13 +230,13 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
200
230
|
)}
|
|
201
231
|
|
|
202
232
|
<div className="grid gap-4 md:grid-cols-2">
|
|
203
|
-
<MetricTile label="
|
|
233
|
+
<MetricTile label="Live replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
|
|
204
234
|
<MetricTile
|
|
205
235
|
label="Projected daily"
|
|
206
236
|
value={hasCurrent ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
|
|
207
237
|
subvalue={
|
|
208
238
|
hasCurrent
|
|
209
|
-
? `${formatCostPerHour(hourly, currentCurrency)}
|
|
239
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
210
240
|
: 'Current allocation unavailable'
|
|
211
241
|
}
|
|
212
242
|
/>
|
|
@@ -222,15 +252,19 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
|
|
|
222
252
|
memoryAllocationUse={current?.memoryAllocationUse ?? 0}
|
|
223
253
|
cpuUsageAvailable={current?.cpuUsageAvailable ?? false}
|
|
224
254
|
memoryUsageAvailable={current?.memoryUsageAvailable ?? false}
|
|
255
|
+
window={source === 'kubecost' ? currentWindow : undefined}
|
|
225
256
|
/>
|
|
226
257
|
|
|
227
258
|
<div className="text-xs text-theme-text-tertiary">
|
|
228
|
-
|
|
259
|
+
{costSourceLabel(source)} · {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', currentQuery.data?.dataThrough)}.{' '}
|
|
229
260
|
{currentCurrency !== DEFAULT_COST_CURRENCY && (
|
|
230
261
|
<>Labeled {currentCurrency}; no conversion. </>
|
|
231
262
|
)}
|
|
232
|
-
|
|
233
|
-
|
|
263
|
+
{historyUnsupported
|
|
264
|
+
? 'Historical workload charts are not available for Kubecost yet. '
|
|
265
|
+
: 'Historical spend uses the selected range. '}
|
|
266
|
+
Projected monthly values multiply the {rateLabels.rate}. Storage/PVC attribution
|
|
267
|
+
remains at namespace and cluster level.
|
|
234
268
|
</div>
|
|
235
269
|
</div>
|
|
236
270
|
)
|
|
@@ -260,14 +294,19 @@ export function getWorkloadCostState(
|
|
|
260
294
|
if (trendHasData) return 'partial_missing_current'
|
|
261
295
|
const reason =
|
|
262
296
|
current?.reason ??
|
|
263
|
-
trend?.reason ??
|
|
264
297
|
costUnavailableReasonFromError(queryStatus.currentError) ??
|
|
298
|
+
trend?.reason ??
|
|
265
299
|
costUnavailableReasonFromError(queryStatus.trendError)
|
|
266
300
|
if (
|
|
267
301
|
reason === 'no_prometheus' ||
|
|
302
|
+
reason === 'no_cost_source' ||
|
|
268
303
|
reason === 'query_error' ||
|
|
269
304
|
reason === 'access_denied' ||
|
|
270
|
-
reason === 'not_found'
|
|
305
|
+
reason === 'not_found' ||
|
|
306
|
+
reason === 'source_unavailable' ||
|
|
307
|
+
reason === 'authentication_error' ||
|
|
308
|
+
reason === 'configuration_mismatch' ||
|
|
309
|
+
reason === 'deployment_configuration_error'
|
|
271
310
|
)
|
|
272
311
|
return reason
|
|
273
312
|
if (queryError) return 'load_error'
|
|
@@ -289,11 +328,11 @@ function WorkloadCostDiscovering({
|
|
|
289
328
|
<Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
|
|
290
329
|
<div>
|
|
291
330
|
<p className="text-sm font-medium text-theme-text-primary">
|
|
292
|
-
Looking for
|
|
331
|
+
Looking for cost data…
|
|
293
332
|
</p>
|
|
294
333
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
295
|
-
|
|
296
|
-
|
|
334
|
+
Radar is checking OpenCost metrics in a PromQL-compatible backend and a local Kubecost
|
|
335
|
+
3 Aggregator. First discovery can take a few seconds.
|
|
297
336
|
</p>
|
|
298
337
|
</div>
|
|
299
338
|
<button
|
|
@@ -308,19 +347,28 @@ function WorkloadCostDiscovering({
|
|
|
308
347
|
)
|
|
309
348
|
}
|
|
310
349
|
|
|
311
|
-
function WorkloadCostUnavailable({
|
|
350
|
+
function WorkloadCostUnavailable({
|
|
351
|
+
state,
|
|
352
|
+
settingsAvailable,
|
|
353
|
+
}: {
|
|
354
|
+
state: CostUnavailableReason | 'load_error'
|
|
355
|
+
settingsAvailable: boolean
|
|
356
|
+
}) {
|
|
312
357
|
const message =
|
|
313
|
-
state
|
|
314
|
-
|
|
358
|
+
costIntegrationUnavailableMessage(state, settingsAvailable) ??
|
|
359
|
+
(state === 'no_prometheus'
|
|
360
|
+
? 'No compatible metrics backend was found. OpenCost workload cost requires OpenCost metrics in a PromQL-compatible backend.'
|
|
315
361
|
: state === 'query_error'
|
|
316
|
-
? 'Cost data is temporarily unavailable.
|
|
362
|
+
? 'Cost data is temporarily unavailable. A metrics backend was found, but workload cost queries failed.'
|
|
363
|
+
: state === 'history_unsupported'
|
|
364
|
+
? 'Historical workload cost is not available for Kubecost yet.'
|
|
317
365
|
: state === 'access_denied'
|
|
318
366
|
? 'You do not have access to view cost for this workload.'
|
|
319
367
|
: state === 'not_found'
|
|
320
368
|
? 'This workload no longer exists.'
|
|
321
369
|
: state === 'load_error'
|
|
322
370
|
? 'Could not load workload cost data. Check access to this workload and try again.'
|
|
323
|
-
: '
|
|
371
|
+
: 'No workload cost data was returned for this workload by the active cost source.')
|
|
324
372
|
|
|
325
373
|
return (
|
|
326
374
|
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { costDataThroughLabel, costFreshnessLabel, costIntegrationUnavailableMessage, costRateLabels, costSourceLabel, isCostDiscoveryPending } from './source'
|
|
3
|
+
|
|
4
|
+
describe('cost source presentation', () => {
|
|
5
|
+
it('distinguishes Prometheus windows from Kubecost ETL freshness', () => {
|
|
6
|
+
expect(costSourceLabel('prometheus')).toBe('OpenCost via Prometheus')
|
|
7
|
+
expect(costSourceLabel('kubecost')).toBe('Kubecost Aggregator')
|
|
8
|
+
expect(costFreshnessLabel('prometheus', '1h')).toBe('last 1h average')
|
|
9
|
+
expect(costFreshnessLabel('kubecost', '1h', '2026-08-26T13:58:00Z')).toContain('1-hour allocation average')
|
|
10
|
+
expect(costFreshnessLabel('kubecost', '1d', '2026-08-26T13:58:00Z')).toContain('1-day allocation average')
|
|
11
|
+
expect(costDataThroughLabel('2026-08-26T13:58:00Z')).toContain('2026')
|
|
12
|
+
expect(costDataThroughLabel('invalid')).toBe('')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('labels fallback allocation windows without calling a daily average current', () => {
|
|
16
|
+
expect(costRateLabels('1h').hourly).toBe('Hourly (1-hour average)')
|
|
17
|
+
expect(costRateLabels('1d').rate).toBe('1-day average hourly rate')
|
|
18
|
+
expect(costRateLabels().allocationTitle).toBe('Current allocation and use')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps both absent-source reasons inside the discovery grace period', () => {
|
|
22
|
+
expect(isCostDiscoveryPending('no_prometheus')).toBe(true)
|
|
23
|
+
expect(isCostDiscoveryPending('no_cost_source')).toBe(true)
|
|
24
|
+
expect(isCostDiscoveryPending('source_unavailable')).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does not point embedded users at standalone Settings', () => {
|
|
28
|
+
expect(costIntegrationUnavailableMessage('no_cost_source', true)).toContain('Settings → Metrics')
|
|
29
|
+
expect(costIntegrationUnavailableMessage('no_cost_source', false)).toContain('host application')
|
|
30
|
+
expect(costIntegrationUnavailableMessage('source_unavailable', false)).not.toContain('Settings')
|
|
31
|
+
expect(costIntegrationUnavailableMessage('authentication_error', false)).not.toContain('Settings')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { CostDataSource, CostUnavailableReason } from '../../api/client'
|
|
2
|
+
|
|
3
|
+
export function costSourceLabel(source?: CostDataSource): string {
|
|
4
|
+
return source === 'kubecost' ? 'Kubecost Aggregator' : 'OpenCost via Prometheus'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isCostDiscoveryPending(reason?: string): boolean {
|
|
8
|
+
return reason === 'no_prometheus' || reason === 'no_cost_source'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function costConfigurationAction(reason?: CostUnavailableReason): {
|
|
12
|
+
section: 'prometheus' | 'cost'
|
|
13
|
+
label: string
|
|
14
|
+
} {
|
|
15
|
+
return reason === 'no_prometheus'
|
|
16
|
+
? { section: 'prometheus', label: 'Configure metrics' }
|
|
17
|
+
: { section: 'cost', label: 'Configure cost source' }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function costRateLabels(window?: string): {
|
|
21
|
+
hourly: string
|
|
22
|
+
rate: string
|
|
23
|
+
allocationTitle: string
|
|
24
|
+
allocationPeriod: string
|
|
25
|
+
} {
|
|
26
|
+
if (window === '1d') {
|
|
27
|
+
return {
|
|
28
|
+
hourly: 'Hourly (1-day average)',
|
|
29
|
+
rate: '1-day average hourly rate',
|
|
30
|
+
allocationTitle: '1-day average allocation and use',
|
|
31
|
+
allocationPeriod: 'Allocation and observed use: 1-day average',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (window === '1h') {
|
|
35
|
+
return {
|
|
36
|
+
hourly: 'Hourly (1-hour average)',
|
|
37
|
+
rate: '1-hour average hourly rate',
|
|
38
|
+
allocationTitle: '1-hour average allocation and use',
|
|
39
|
+
allocationPeriod: 'Allocation and observed use: 1-hour average',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
hourly: 'Current hourly',
|
|
44
|
+
rate: 'current hourly rate',
|
|
45
|
+
allocationTitle: 'Current allocation and use',
|
|
46
|
+
allocationPeriod: 'Allocation and CPU use: 1h average · Memory use: current',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function costFreshnessLabel(
|
|
51
|
+
source?: CostDataSource,
|
|
52
|
+
window?: string,
|
|
53
|
+
dataThrough?: string,
|
|
54
|
+
): string {
|
|
55
|
+
if (source !== 'kubecost') return window ? `last ${window} average` : 'current allocation average'
|
|
56
|
+
const average = window === '1d'
|
|
57
|
+
? '1-day allocation average'
|
|
58
|
+
: window === '1h'
|
|
59
|
+
? '1-hour allocation average'
|
|
60
|
+
: 'current allocation average'
|
|
61
|
+
if (!dataThrough) return `latest Kubecost ${average}`
|
|
62
|
+
const timestamp = costDataThroughLabel(dataThrough)
|
|
63
|
+
if (!timestamp) return `latest Kubecost ${average}`
|
|
64
|
+
return `Kubecost ${average} · data through ${timestamp}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function costDataThroughLabel(dataThrough?: string): string {
|
|
68
|
+
if (!dataThrough) return ''
|
|
69
|
+
const timestamp = new Date(dataThrough)
|
|
70
|
+
if (Number.isNaN(timestamp.getTime())) return ''
|
|
71
|
+
return timestamp.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function costIntegrationUnavailableMessage(
|
|
75
|
+
reason?: CostUnavailableReason | 'load_error',
|
|
76
|
+
settingsAvailable = true,
|
|
77
|
+
): string | undefined {
|
|
78
|
+
switch (reason) {
|
|
79
|
+
case 'no_cost_source':
|
|
80
|
+
return settingsAvailable
|
|
81
|
+
? 'No compatible cost source was found. Connect OpenCost metrics in Settings → Metrics or configure Kubecost in Settings → Cost.'
|
|
82
|
+
: 'No compatible cost source is configured for this cluster. Configure OpenCost metrics or Kubecost in the host application or Radar deployment.'
|
|
83
|
+
case 'source_unavailable':
|
|
84
|
+
return settingsAvailable
|
|
85
|
+
? 'Kubecost Aggregator is unavailable. Check the URL, network path, and cluster ID in Settings → Cost.'
|
|
86
|
+
: 'Kubecost Aggregator is unavailable. Update this cluster’s cost-source configuration in the host application or Radar deployment.'
|
|
87
|
+
case 'deployment_configuration_error':
|
|
88
|
+
return 'Cost collection is misconfigured by this Radar deployment. Update its environment variables or Helm cost values, then restart Radar.'
|
|
89
|
+
case 'authentication_error':
|
|
90
|
+
return settingsAvailable
|
|
91
|
+
? 'Kubecost rejected the configured API key. Update it in Settings → Cost.'
|
|
92
|
+
: 'Kubecost rejected the configured API key. Update this cluster’s credential in the host application or Radar deployment.'
|
|
93
|
+
case 'configuration_mismatch':
|
|
94
|
+
return settingsAvailable
|
|
95
|
+
? 'Saved Kubecost settings are not valid for this cluster. Update the cluster ID or local API key in Settings → Cost.'
|
|
96
|
+
: 'Kubecost settings are not valid for this cluster. Update the cluster ID or credential in the host application or Radar deployment.'
|
|
97
|
+
default:
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -41,9 +41,12 @@ describe("AgentControls execution profile explanation", () => {
|
|
|
41
41
|
const html = renderAgent("cursor-agent", ["full-local"], "safeguarded");
|
|
42
42
|
expect(html).toContain("must use this agent");
|
|
43
43
|
expect(html).toContain("normal setup");
|
|
44
|
-
expect(html).toContain("
|
|
45
|
-
expect(html).toContain("
|
|
46
|
-
expect(html).toContain("
|
|
44
|
+
expect(html).toContain("--force");
|
|
45
|
+
expect(html).toContain("auto-approves its built-in tools");
|
|
46
|
+
expect(html).toContain("including your global servers");
|
|
47
|
+
expect(html).toContain("does not reliably confine those tools");
|
|
48
|
+
expect(html).not.toContain("still enables the agent CLI");
|
|
49
|
+
expect(html).not.toContain("does not constrain external MCP servers");
|
|
47
50
|
expect(html).not.toContain("always runs this agent with safeguards");
|
|
48
51
|
});
|
|
49
52
|
|
|
@@ -104,7 +107,12 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
104
107
|
expect(html).toContain("border-amber-500/40");
|
|
105
108
|
expect(html).toContain("text-amber-500");
|
|
106
109
|
expect(html).toContain("Radar cannot constrain");
|
|
107
|
-
expect(html).toContain("
|
|
110
|
+
expect(html).toContain("--force");
|
|
111
|
+
expect(html).toContain("auto-approves its built-in tools");
|
|
112
|
+
expect(html).toContain("including your global servers");
|
|
113
|
+
expect(html).toContain("does not reliably confine those tools");
|
|
114
|
+
expect(html).not.toContain("still enables the agent CLI");
|
|
115
|
+
expect(html).not.toContain("does not constrain external MCP servers");
|
|
108
116
|
expect(html).not.toContain("text-accent");
|
|
109
117
|
});
|
|
110
118
|
|