@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.12.3",
3
+ "version": "1.13.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,7 +35,7 @@
35
35
  "diff": "^9.0.0",
36
36
  "monaco-editor": "0.55.1",
37
37
  "react-markdown": "^10.1.0",
38
- "react-virtuoso": "^4.18.11",
38
+ "react-virtuoso": "^4.18.12",
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "^4.4.2",
41
41
  "yaml": "^2.9.0"
@@ -69,8 +69,8 @@
69
69
  "eslint": "^10.8.0",
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
- "globals": "^17.9.0",
73
- "lucide-react": "^1.33.0",
72
+ "globals": "^17.11.0",
73
+ "lucide-react": "^1.37.0",
74
74
  "postcss": "^8.5.26",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
@@ -80,7 +80,7 @@
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
82
82
  "typescript-eslint": "^8.67.0",
83
- "vite": "^8.2.1"
83
+ "vite": "^8.2.2"
84
84
  },
85
85
  "sideEffects": [
86
86
  "*.css"
package/src/App.tsx CHANGED
@@ -2507,6 +2507,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2507
2507
  open={showSettings}
2508
2508
  initialSection={settingsSection}
2509
2509
  onClose={() => setShowSettings(false)}
2510
+ onNavigateToResource={navigateToResourceList}
2510
2511
  />
2511
2512
 
2512
2513
  {/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
package/src/api/client.ts CHANGED
@@ -882,24 +882,36 @@ export interface OpenCostNamespaceCost {
882
882
 
883
883
  export type CostUnavailableReason =
884
884
  | "no_prometheus"
885
+ | "no_cost_source"
885
886
  | "no_metrics"
886
887
  | "query_error"
887
888
  | "access_denied"
888
- | "not_found";
889
+ | "not_found"
890
+ | "source_unavailable"
891
+ | "authentication_error"
892
+ | "configuration_mismatch"
893
+ | "deployment_configuration_error"
894
+ | "history_unsupported"
895
+ | "insufficient_history";
896
+
897
+ export type CostDataSource = "prometheus" | "kubecost";
889
898
 
890
899
  export interface OpenCostSummary {
891
900
  available: boolean;
892
901
  reason?: CostUnavailableReason;
902
+ source?: CostDataSource;
903
+ dataThrough?: string;
893
904
  currency?: string;
894
905
  window?: string;
895
906
  totalHourlyCost?: number;
896
907
  totalStorageCost?: number;
897
908
  totalIdleCost?: number;
898
909
  clusterEfficiency?: number;
910
+ namespaceScope?: string[];
899
911
  namespaces?: OpenCostNamespaceCost[];
900
912
  }
901
913
 
902
- const noPrometheusFirstSeenAt = new Map<string, number>();
914
+ const costDiscoveryFirstSeenAt = new Map<string, number>();
903
915
 
904
916
  function costRefetchInterval(
905
917
  defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
@@ -915,15 +927,18 @@ function costRefetchInterval(
915
927
  }) => {
916
928
  const data = query.state.data;
917
929
  const queryID = `${contextName ?? "unknown"}:${query.queryHash ?? JSON.stringify(query.queryKey ?? "opencost")}`;
918
- if (data?.available === false && data.reason === "no_prometheus") {
930
+ if (
931
+ data?.available === false &&
932
+ (data.reason === "no_prometheus" || data.reason === "no_cost_source")
933
+ ) {
919
934
  const now = Date.now();
920
- const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now;
921
- noPrometheusFirstSeenAt.set(queryID, firstSeenAt);
935
+ const firstSeenAt = costDiscoveryFirstSeenAt.get(queryID) ?? now;
936
+ costDiscoveryFirstSeenAt.set(queryID, firstSeenAt);
922
937
  return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
923
938
  ? COST_DISCOVERY_RETRY_INTERVAL_MS
924
939
  : defaultInterval;
925
940
  }
926
- noPrometheusFirstSeenAt.delete(queryID);
941
+ costDiscoveryFirstSeenAt.delete(queryID);
927
942
  return defaultInterval;
928
943
  };
929
944
  }
@@ -963,6 +978,9 @@ export interface OpenCostWorkloadCost {
963
978
  export interface OpenCostWorkloadResponse {
964
979
  available: boolean;
965
980
  reason?: CostUnavailableReason;
981
+ source?: CostDataSource;
982
+ window?: string;
983
+ dataThrough?: string;
966
984
  currency?: string;
967
985
  namespace: string;
968
986
  workloads: OpenCostWorkloadCost[];
@@ -991,6 +1009,9 @@ export function useOpenCostWorkloads(
991
1009
  export interface OpenCostWorkloadDetailResponse {
992
1010
  available: boolean;
993
1011
  reason?: CostUnavailableReason;
1012
+ source?: CostDataSource;
1013
+ window?: string;
1014
+ dataThrough?: string;
994
1015
  currency?: string;
995
1016
  namespace: string;
996
1017
  kind: string;
@@ -1036,8 +1057,12 @@ export interface OpenCostTrendSeries {
1036
1057
  export interface OpenCostTrendResponse {
1037
1058
  available: boolean;
1038
1059
  reason?: CostUnavailableReason;
1060
+ source?: CostDataSource;
1039
1061
  currency?: string;
1040
1062
  range: string;
1063
+ windowStart?: number;
1064
+ windowEnd?: number;
1065
+ dataThrough?: string;
1041
1066
  series?: OpenCostTrendSeries[];
1042
1067
  }
1043
1068
 
@@ -1058,6 +1083,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
1058
1083
  export interface OpenCostWorkloadTrendResponse {
1059
1084
  available: boolean;
1060
1085
  reason?: CostUnavailableReason;
1086
+ source?: CostDataSource;
1061
1087
  currency?: string;
1062
1088
  namespace: string;
1063
1089
  kind: string;
@@ -1131,6 +1157,9 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
1131
1157
  export interface OpenCostApplicationCostResponse {
1132
1158
  available: boolean;
1133
1159
  reason?: CostUnavailableReason;
1160
+ source?: CostDataSource;
1161
+ window?: string;
1162
+ dataThrough?: string;
1134
1163
  currency?: string;
1135
1164
  partial?: boolean;
1136
1165
  totals: OpenCostApplicationCostTotals;
@@ -1146,6 +1175,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
1146
1175
  export interface OpenCostApplicationCostTrendResponse {
1147
1176
  available: boolean;
1148
1177
  reason?: CostUnavailableReason;
1178
+ source?: CostDataSource;
1149
1179
  currency?: string;
1150
1180
  range: string;
1151
1181
  partial?: boolean;
@@ -1238,6 +1268,8 @@ export interface OpenCostNodeCost {
1238
1268
  export interface OpenCostNodeResponse {
1239
1269
  available: boolean;
1240
1270
  reason?: CostUnavailableReason;
1271
+ source?: CostDataSource;
1272
+ dataThrough?: string;
1241
1273
  currency?: string;
1242
1274
  nodes?: OpenCostNodeCost[];
1243
1275
  }
@@ -53,6 +53,48 @@ describe('getApplicationCostState', () => {
53
53
  ).toBe('partial_missing_history')
54
54
  })
55
55
 
56
+ it('keeps Kubecost application totals visible when history is unsupported', () => {
57
+ const current: OpenCostApplicationCostResponse = {
58
+ available: true,
59
+ source: 'kubecost',
60
+ currency: 'USD',
61
+ totals: {
62
+ hourlyCost: 0.4, cpuCost: 0.25, memoryCost: 0.15, replicas: 3,
63
+ cpuUsageAvailable: true, memoryUsageAvailable: true,
64
+ cpuAllocationUse: 40, memoryAllocationUse: 60,
65
+ },
66
+ coverage: { total: 1, included: 1 },
67
+ workloads: [],
68
+ }
69
+ const trend: OpenCostApplicationCostTrendResponse = {
70
+ available: false,
71
+ source: 'kubecost',
72
+ reason: 'history_unsupported',
73
+ currency: 'USD',
74
+ range: '24h',
75
+ coverage: { total: 1, included: 0 },
76
+ }
77
+
78
+ expect(getApplicationCostState(current, trend, {})).toBe('partial_missing_history')
79
+ })
80
+
81
+ it('does not let unsupported history mask current loading or errors', () => {
82
+ const trend: OpenCostApplicationCostTrendResponse = {
83
+ available: false,
84
+ source: 'kubecost',
85
+ reason: 'history_unsupported',
86
+ currency: 'USD',
87
+ range: '24h',
88
+ coverage: { total: 1, included: 0 },
89
+ }
90
+
91
+ expect(getApplicationCostState(undefined, trend, { currentLoading: true })).toBe('loading')
92
+ expect(getApplicationCostState(undefined, trend, { currentError: true })).toBe('load_error')
93
+ expect(
94
+ getApplicationCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
95
+ ).toBe('access_denied')
96
+ })
97
+
56
98
  it('uses historical data when current app metrics are absent but history exists', () => {
57
99
  const current: OpenCostApplicationCostResponse = {
58
100
  available: false,
@@ -206,5 +248,8 @@ describe('getApplicationCostState', () => {
206
248
  }),
207
249
  ).toBe('access_denied')
208
250
  expect(getApplicationCostState(current, undefined, { trendError: true })).toBe('access_denied')
251
+
252
+ current.reason = 'configuration_mismatch'
253
+ expect(getApplicationCostState(current, undefined, {})).toBe('configuration_mismatch')
209
254
  })
210
255
  })
@@ -25,6 +25,14 @@ import {
25
25
  import { isOpenCostWorkloadKind } from './kinds'
26
26
  import { CurrentAllocationUse } from './CurrentAllocationUse'
27
27
  import { costUnavailableReasonFromError } from './errors'
28
+ import {
29
+ costFreshnessLabel,
30
+ costIntegrationUnavailableMessage,
31
+ costRateLabels,
32
+ costSourceLabel,
33
+ isCostDiscoveryPending,
34
+ } from './source'
35
+ import { useNavCustomization } from '../../context/NavCustomization'
28
36
 
29
37
  type ApplicationCostState =
30
38
  | 'loading'
@@ -53,8 +61,9 @@ export function ApplicationCostTab({
53
61
  workloads,
54
62
  onSelectWorkloadCost,
55
63
  }: ApplicationCostTabProps) {
64
+ const settingsAvailable = !useNavCustomization().embedded
56
65
  const [range, setRange] = useState<CostTimeRange>('24h')
57
- const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
66
+ const [discoverySince, setDiscoverySince] = useState<number | null>(null)
58
67
  const supportedWorkloads = useMemo(() => applicationCostWorkloads(workloads), [workloads])
59
68
  const unsupportedCount = workloads.length - supportedWorkloads.length
60
69
  const queriesEnabled = supportedWorkloads.length > 0
@@ -77,10 +86,10 @@ export function ApplicationCostTab({
77
86
  })
78
87
 
79
88
  useEffect(() => {
80
- if (state === 'no_prometheus') {
81
- setNoPrometheusSince((prev) => prev ?? Date.now())
89
+ if (isCostDiscoveryPending(state)) {
90
+ setDiscoverySince((prev) => prev ?? Date.now())
82
91
  } else {
83
- setNoPrometheusSince(null)
92
+ setDiscoverySince(null)
84
93
  }
85
94
  }, [state])
86
95
 
@@ -96,6 +105,7 @@ export function ApplicationCostTab({
96
105
  <ApplicationCostUnavailable
97
106
  state="no_metrics"
98
107
  message="No steady-state workloads in this app are currently cost-attributed."
108
+ settingsAvailable={settingsAvailable}
99
109
  />
100
110
  )
101
111
  }
@@ -115,22 +125,28 @@ export function ApplicationCostTab({
115
125
  state === 'query_error' ||
116
126
  state === 'access_denied' ||
117
127
  state === 'not_found' ||
128
+ state === 'no_cost_source' ||
129
+ state === 'source_unavailable' ||
130
+ state === 'authentication_error' ||
131
+ state === 'configuration_mismatch' ||
132
+ state === 'deployment_configuration_error' ||
133
+ state === 'history_unsupported' ||
118
134
  state === 'load_error'
119
135
  ) {
120
- const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
121
- if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
136
+ const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
137
+ if (isCostDiscoveryPending(state) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
122
138
  return (
123
139
  <ApplicationCostDiscovering
124
140
  isFetching={currentQuery.isFetching || trendQuery.isFetching}
125
141
  onRetry={() => {
126
- setNoPrometheusSince(Date.now())
142
+ setDiscoverySince(Date.now())
127
143
  currentQuery.refetch()
128
144
  trendQuery.refetch()
129
145
  }}
130
146
  />
131
147
  )
132
148
  }
133
- return <ApplicationCostUnavailable state={state} />
149
+ return <ApplicationCostUnavailable state={state} settingsAvailable={settingsAvailable} />
134
150
  }
135
151
 
136
152
  const current = currentQuery.data
@@ -151,12 +167,16 @@ export function ApplicationCostTab({
151
167
  const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
152
168
  const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
153
169
  const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY
170
+ const source = current?.source ?? trend?.source
171
+ const historyUnsupported = trend?.reason === 'history_unsupported'
172
+ const currentWindow = current?.window
173
+ const rateLabels = costRateLabels(currentWindow)
154
174
 
155
175
  return (
156
176
  <div className="mx-auto w-full max-w-[1600px] space-y-4">
157
177
  {(current?.partial ||
158
178
  trend?.partial ||
159
- state === 'partial_missing_history' ||
179
+ (state === 'partial_missing_history' && !historyUnsupported) ||
160
180
  state === 'partial_missing_current' ||
161
181
  unsupportedCount > 0) && (
162
182
  <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
@@ -185,61 +205,76 @@ export function ApplicationCostTab({
185
205
  <div className="text-sm font-semibold text-theme-text-primary">
186
206
  Application compute cost
187
207
  </div>
188
- <CostInfoTooltip content="Values are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
208
+ <CostInfoTooltip content="Values are based on CPU and memory allocation grouped by the workloads in this application. The cost provider attributes the greater of requested or observed resources." />
189
209
  </div>
190
210
  <div className="text-xs text-theme-text-tertiary">
191
- OpenCost CPU and memory allocation rate ({trendCurrency}/hr) for Deployment,
192
- StatefulSet, and DaemonSet workloads
211
+ CPU and memory allocation rate ({trendCurrency}/hr) for Deployment, StatefulSet,
212
+ and DaemonSet workloads
193
213
  </div>
194
214
  </div>
195
215
  </div>
196
- <CostTimeRangeSelector value={range} onChange={setRange} />
216
+ {!historyUnsupported && <CostTimeRangeSelector value={range} onChange={setRange} />}
197
217
  </div>
198
218
 
199
- <div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
200
- <div className="space-y-4">
219
+ {historyUnsupported ? (
220
+ <div className="grid gap-4 p-4 sm:grid-cols-2">
201
221
  <CostMetricBlock
202
- label={`Spend over ${range}`}
203
- value={formatHistoricalSpend(
204
- points.length,
205
- trend?.windowTotalCost ?? 0,
206
- trendLoading || state === 'partial_missing_history',
207
- trendCurrency,
208
- )}
209
- subvalue={
210
- state === 'partial_missing_history'
211
- ? 'Historical data incomplete'
212
- : `${included} of ${total} workloads included`
213
- }
222
+ label={rateLabels.hourly}
223
+ value={totals ? formatCostPerHour(hourly, currentCurrency) : '—'}
224
+ subvalue={`${included} of ${total} workloads included`}
214
225
  />
215
226
  <CostMetricBlock
216
227
  label="Projected monthly"
217
228
  value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
218
- subvalue={
219
- totals
220
- ? `${formatCostPerHour(hourly, currentCurrency)} current rate`
221
- : 'Current allocation unavailable'
222
- }
229
+ subvalue={`${rateLabels.rate} × 730 hours`}
223
230
  />
224
231
  </div>
225
- <div className="min-w-0">
226
- {trendLoading ? (
227
- <div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
228
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
229
- Loading historical cost…
230
- </div>
231
- ) : hasTrend && chartSeries.length > 0 ? (
232
- <div className="min-w-0">
233
- <StackedAreaChart series={chartSeries} currency={trendCurrency} />
234
- <ChartLegend series={chartSeries} />
235
- </div>
236
- ) : (
237
- <div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
238
- No historical workload owner cost points for this range.
239
- </div>
240
- )}
232
+ ) : (
233
+ <div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
234
+ <div className="space-y-4">
235
+ <CostMetricBlock
236
+ label={`Spend over ${range}`}
237
+ value={formatHistoricalSpend(
238
+ points.length,
239
+ trend?.windowTotalCost ?? 0,
240
+ trendLoading || state === 'partial_missing_history',
241
+ trendCurrency,
242
+ )}
243
+ subvalue={
244
+ state === 'partial_missing_history'
245
+ ? 'Historical data incomplete'
246
+ : `${included} of ${total} workloads included`
247
+ }
248
+ />
249
+ <CostMetricBlock
250
+ label="Projected monthly"
251
+ value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
252
+ subvalue={
253
+ totals
254
+ ? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
255
+ : 'Current allocation unavailable'
256
+ }
257
+ />
258
+ </div>
259
+ <div className="min-w-0">
260
+ {trendLoading ? (
261
+ <div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
262
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
263
+ Loading historical cost…
264
+ </div>
265
+ ) : hasTrend && chartSeries.length > 0 ? (
266
+ <div className="min-w-0">
267
+ <StackedAreaChart series={chartSeries} currency={trendCurrency} />
268
+ <ChartLegend series={chartSeries} />
269
+ </div>
270
+ ) : (
271
+ <div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
272
+ No historical workload owner cost points for this range.
273
+ </div>
274
+ )}
275
+ </div>
241
276
  </div>
242
- </div>
277
+ )}
243
278
  </section>
244
279
 
245
280
  <div className="grid gap-4 md:grid-cols-2">
@@ -257,7 +292,7 @@ export function ApplicationCostTab({
257
292
  value={totals ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
258
293
  subvalue={
259
294
  totals
260
- ? `${formatCostPerHour(hourly, currentCurrency)} current hourly rate`
295
+ ? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
261
296
  : 'Current allocation unavailable'
262
297
  }
263
298
  />
@@ -274,6 +309,7 @@ export function ApplicationCostTab({
274
309
  cpuUsageAvailable={totals?.cpuUsageAvailable ?? false}
275
310
  memoryUsageAvailable={totals?.memoryUsageAvailable ?? false}
276
311
  scopeNote="Included workloads only"
312
+ window={source === 'kubecost' ? currentWindow : undefined}
277
313
  />
278
314
 
279
315
  <section className="rounded-lg border border-theme-border bg-theme-surface/50">
@@ -283,7 +319,7 @@ export function ApplicationCostTab({
283
319
  Workload contributors
284
320
  </div>
285
321
  <div className="text-xs text-theme-text-tertiary">
286
- Projected monthly from current allocation, sorted by spend
322
+ Projected monthly from the {rateLabels.rate}, sorted by spend
287
323
  </div>
288
324
  </div>
289
325
  <div className="text-xs text-theme-text-tertiary">{rows.length} tracked</div>
@@ -315,13 +351,15 @@ export function ApplicationCostTab({
315
351
  </section>
316
352
 
317
353
  <div className="text-xs text-theme-text-tertiary">
318
- Powered by OpenCost via Prometheus.{' '}
354
+ {costSourceLabel(source)} &middot; {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', current?.dataThrough)}.{' '}
319
355
  {currentCurrency !== DEFAULT_COST_CURRENCY && (
320
356
  <>Labeled {currentCurrency}; no conversion. </>
321
357
  )}
322
- Historical spend uses the selected range; projected monthly values multiply current hourly
323
- allocation. Batch/job cost is separate; storage/PVC and network costs remain at namespace
324
- and cluster level.
358
+ {historyUnsupported
359
+ ? 'Historical application charts are not available for Kubecost yet. '
360
+ : 'Historical spend uses the selected range. '}
361
+ Projected monthly values multiply the {rateLabels.rate}. Batch/job cost is separate;
362
+ storage/PVC and network costs remain at namespace and cluster level.
325
363
  </div>
326
364
  </div>
327
365
  )
@@ -348,14 +386,19 @@ export function getApplicationCostState(
348
386
  if (trendHasData) return 'partial_missing_current'
349
387
  const reason =
350
388
  current?.reason ??
351
- trend?.reason ??
352
389
  costUnavailableReasonFromError(status.currentError) ??
390
+ trend?.reason ??
353
391
  costUnavailableReasonFromError(status.trendError)
354
392
  if (
355
393
  reason === 'no_prometheus' ||
394
+ reason === 'no_cost_source' ||
356
395
  reason === 'query_error' ||
357
396
  reason === 'access_denied' ||
358
- reason === 'not_found'
397
+ reason === 'not_found' ||
398
+ reason === 'source_unavailable' ||
399
+ reason === 'authentication_error' ||
400
+ reason === 'configuration_mismatch' ||
401
+ reason === 'deployment_configuration_error'
359
402
  )
360
403
  return reason
361
404
  if (queryError) return 'load_error'
@@ -462,10 +505,13 @@ function applicationCostKey(ref: { kind: string; namespace: string; name: string
462
505
  }
463
506
 
464
507
  function reasonLabel(reason?: CostUnavailableReason) {
465
- if (reason === 'no_prometheus') return 'Prometheus not found'
508
+ if (reason === 'no_prometheus') return 'Metrics backend not found'
509
+ if (reason === 'no_cost_source') return 'Cost source not found'
466
510
  if (reason === 'query_error') return 'Cost query failed'
467
511
  if (reason === 'access_denied') return 'No access to this workload'
468
512
  if (reason === 'not_found') return 'Workload not found'
513
+ if (reason === 'configuration_mismatch') return 'Kubecost settings are not valid for this cluster'
514
+ if (reason === 'deployment_configuration_error') return 'Radar cost deployment is misconfigured'
469
515
  return 'No workload cost metrics'
470
516
  }
471
517
 
@@ -482,11 +528,11 @@ function ApplicationCostDiscovering({
482
528
  <Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
483
529
  <div>
484
530
  <p className="text-sm font-medium text-theme-text-primary">
485
- Looking for Prometheus cost data…
531
+ Looking for cost data…
486
532
  </p>
487
533
  <p className="mt-1 text-xs text-theme-text-tertiary">
488
- First discovery can take a few seconds while Radar checks cluster services and opens a
489
- local port-forward.
534
+ Radar is checking OpenCost metrics in a PromQL-compatible backend and a local Kubecost
535
+ 3 Aggregator. First discovery can take a few seconds.
490
536
  </p>
491
537
  </div>
492
538
  <button
@@ -504,23 +550,28 @@ function ApplicationCostDiscovering({
504
550
  function ApplicationCostUnavailable({
505
551
  state,
506
552
  message,
553
+ settingsAvailable,
507
554
  }: {
508
555
  state: CostUnavailableReason | 'load_error'
509
556
  message?: string
557
+ settingsAvailable: boolean
510
558
  }) {
511
559
  const text =
512
560
  message ??
561
+ costIntegrationUnavailableMessage(state, settingsAvailable) ??
513
562
  (state === 'no_prometheus'
514
- ? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.'
563
+ ? 'No compatible metrics backend was found. OpenCost application cost requires OpenCost metrics in a PromQL-compatible backend.'
515
564
  : state === 'query_error'
516
- ? 'Cost data is temporarily unavailable. Prometheus was found, but application cost queries failed.'
565
+ ? 'Cost data is temporarily unavailable. A metrics backend was found, but application cost queries failed.'
566
+ : state === 'history_unsupported'
567
+ ? 'Historical application cost is not available for Kubecost yet.'
517
568
  : state === 'access_denied'
518
569
  ? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
519
570
  : state === 'not_found'
520
571
  ? 'Cost data is unavailable because the referenced workloads no longer exist.'
521
572
  : state === 'load_error'
522
573
  ? 'Could not load application cost data. Check access to these workloads and try again.'
523
- : 'OpenCost workload metrics were not found for this application.')
574
+ : 'No workload cost data was returned for this application by the active cost source.')
524
575
  return (
525
576
  <div className="flex h-full min-h-[320px] items-center justify-center">
526
577
  <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { OpenCostTrendSeries } from '../../api/client'
3
+ import { kubecostRetentionNote, kubecostTrendIsStale, kubecostTrendUnavailableMessage } from './CostTrendChart'
4
+
5
+ const hour = 60 * 60
6
+
7
+ function series(...timestamps: number[]): OpenCostTrendSeries[] {
8
+ return [{
9
+ namespace: 'default',
10
+ dataPoints: timestamps.map((timestamp) => ({ timestamp, value: 1 })),
11
+ }]
12
+ }
13
+
14
+ describe('kubecostRetentionNote', () => {
15
+ it('describes a material retention gap', () => {
16
+ const start = 1_000
17
+ expect(kubecostRetentionNote(series(start + 4 * hour, start + 5 * hour), start, start + 7 * 24 * hour))
18
+ .toContain('Kubecost history is available from')
19
+ })
20
+
21
+ it('stays quiet when the first hourly bucket follows the requested start', () => {
22
+ const start = 1_000
23
+ expect(kubecostRetentionNote(series(start + hour, start + 2 * hour), start, start + 24 * hour)).toBeNull()
24
+ })
25
+
26
+ it('stays quiet when there is only one retained sample', () => {
27
+ const start = 1_000
28
+ expect(kubecostRetentionNote(series(start + 4 * hour), start, start + 24 * hour)).toBeNull()
29
+ })
30
+
31
+ it('allows the normal first daily bucket without calling retention partial', () => {
32
+ const start = 1_000
33
+ expect(kubecostRetentionNote(series(start + 24 * hour, start + 48 * hour), start, start + 7 * 24 * hour)).toBeNull()
34
+ })
35
+ })
36
+
37
+ describe('kubecostTrendIsStale', () => {
38
+ it('allows normal hourly ingestion lag', () => {
39
+ const now = Date.parse('2026-08-30T12:00:00Z')
40
+ expect(kubecostTrendIsStale('2026-08-30T10:00:00Z', '6h', now)).toBe(false)
41
+ })
42
+
43
+ it('flags a source beyond the range lag budget', () => {
44
+ const now = Date.parse('2026-08-30T12:00:00Z')
45
+ expect(kubecostTrendIsStale('2026-08-30T02:00:00Z', '24h', now)).toBe(true)
46
+ expect(kubecostTrendIsStale('2026-08-28T02:00:00Z', '7d', now)).toBe(true)
47
+ })
48
+
49
+ it('stays quiet without a valid retained timestamp', () => {
50
+ expect(kubecostTrendIsStale(undefined, '6h')).toBe(false)
51
+ expect(kubecostTrendIsStale('invalid', '6h')).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe('kubecostTrendUnavailableMessage', () => {
56
+ it('distinguishes an empty namespace scope from missing Kubecost history', () => {
57
+ expect(kubecostTrendUnavailableMessage('no_metrics', true)).toContain('current namespace scope')
58
+ expect(kubecostTrendUnavailableMessage('no_metrics', false)).toContain('Kubecost has no retained')
59
+ })
60
+
61
+ it('distinguishes insufficient scoped history', () => {
62
+ expect(kubecostTrendUnavailableMessage('insufficient_history', true)).toContain('namespace scope')
63
+ expect(kubecostTrendUnavailableMessage('insufficient_history', false)).toContain('two retained samples')
64
+ })
65
+ })