@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.
@@ -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
- 'OpenCost 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.'
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">Current allocation and use</div>
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
- Allocation and CPU use: 1h average · Memory use: current
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)} current rate
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 [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
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 === 'no_prometheus') {
66
- setNoPrometheusSince((prev) => prev ?? Date.now())
74
+ if (isCostDiscoveryPending(state)) {
75
+ setDiscoverySince((prev) => prev ?? Date.now())
67
76
  } else {
68
- setNoPrometheusSince(null)
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 = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
90
- if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
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
- setNoPrometheusSince(Date.now())
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 OpenCost CPU and memory allocation over time, not raw utilization. OpenCost allocation uses the greater of requested or observed resources." />
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
- OpenCost CPU and memory allocation rate ({trendCurrency}/hr) attributed by workload
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
- <div className="grid gap-4 p-4 lg:grid-cols-[220px_minmax(0,1fr)]">
146
- <div className="space-y-4">
163
+ {historyUnsupported ? (
164
+ <div className="grid gap-4 p-4 sm:grid-cols-2">
147
165
  <MetricBlock
148
- label={`Spend over ${range}`}
149
- value={windowSpendValue}
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
- <div className="min-w-0">
165
- {trendLoading ? (
166
- <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">
167
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
168
- Loading historical cost…
169
- </div>
170
- ) : hasTrend ? (
171
- <StackedAreaChart
172
- series={[{ namespace: 'Allocation rate', dataPoints: points }]}
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
- <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">
177
- No historical workload owner cost points for this range.
178
- </div>
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
- </div>
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="Replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
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)} current hourly rate`
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
- Powered by OpenCost via Prometheus.{' '}
259
+ {costSourceLabel(source)} &middot; {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', currentQuery.data?.dataThrough)}.{' '}
229
260
  {currentCurrency !== DEFAULT_COST_CURRENCY && (
230
261
  <>Labeled {currentCurrency}; no conversion. </>
231
262
  )}
232
- Historical spend uses the selected range; projected monthly values multiply the current
233
- hourly allocation. Storage/PVC attribution remains at namespace and cluster level.
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 Prometheus cost data…
331
+ Looking for cost data…
293
332
  </p>
294
333
  <p className="mt-1 text-xs text-theme-text-tertiary">
295
- First discovery can take a few seconds while Radar checks cluster services and opens a
296
- local port-forward.
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({ state }: { state: CostUnavailableReason | 'load_error' }) {
350
+ function WorkloadCostUnavailable({
351
+ state,
352
+ settingsAvailable,
353
+ }: {
354
+ state: CostUnavailableReason | 'load_error'
355
+ settingsAvailable: boolean
356
+ }) {
312
357
  const message =
313
- state === 'no_prometheus'
314
- ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.'
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. Prometheus was found, but workload cost queries failed.'
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
- : 'OpenCost workload metrics were not found for this workload.'
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("always loads your global MCP servers");
45
- expect(html).toContain("still enables the agent CLI");
46
- expect(html).toContain("does not constrain external MCP servers");
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("always loads your global MCP servers");
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