@skyhook-io/radar-app 1.12.2 → 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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
@@ -12,15 +12,27 @@ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
12
12
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
13
13
  import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
14
14
  import {
15
- useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
15
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus, useCapabilities,
16
+ useOpenCostSummary,
16
17
  } from '../../api/client'
17
18
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
18
- import { Input, SelectMenu } from '@skyhook-io/k8s-ui'
19
+ import { Input, ResourceRefBadge, SelectMenu, type ResourceRef } from '@skyhook-io/k8s-ui'
20
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
19
21
  import { Tooltip } from '../ui/Tooltip'
20
22
  import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
21
23
  import { MyPermissionsContent } from './MyPermissionsDialog'
22
24
  import { useDiagnose } from '../diagnose/DiagnoseContext'
23
25
  import { currencyOptionsForValue } from './currency-options'
26
+ import { versionUpdateURL } from '../../utils/version'
27
+ import {
28
+ costConfigurationAction,
29
+ costFreshnessLabel,
30
+ costIntegrationUnavailableMessage,
31
+ costSourceLabel,
32
+ } from '../cost/source'
33
+ import { costSourceApplyLabel, shouldOfferCostReview, shouldShowSettingsFooter } from './settings-state'
34
+ import type { SettingsSectionId } from './settings-state'
35
+ export type { SettingsSectionId } from './settings-state'
24
36
 
25
37
  // The loopback URL an MCP client is told to connect to. Shared by the overview
26
38
  // row and the MCP section: both must carry the base path, or the URL they
@@ -42,9 +54,13 @@ interface Config {
42
54
  historyLimit?: number
43
55
  prometheusUrl?: string
44
56
  opencostCurrency?: string
57
+ costSource?: 'auto' | 'prometheus' | 'kubecost'
58
+ kubecostUrl?: string
59
+ kubecostClusterId?: string
45
60
  argoCdUrl?: string
46
61
  argoCdInsecureTls?: boolean
47
62
  mcp?: boolean | null
63
+ restoreLastDesktopContext?: boolean | null
48
64
  }
49
65
 
50
66
  interface ConfigResponse {
@@ -53,6 +69,9 @@ interface ConfigResponse {
53
69
  isDesktop: boolean
54
70
  openCostCurrencyManaged?: boolean
55
71
  prometheusHeaderKeys?: string[]
72
+ kubecostApiKeySet?: boolean
73
+ kubecostEnvManaged?: boolean
74
+ kubecostEnvError?: string
56
75
  // True when an Argo CD auth token is stored. The token itself is never
57
76
  // returned — the card shows a "configured" placeholder and omits the token
58
77
  // from the PUT unless the user changes or clears it.
@@ -69,23 +88,26 @@ interface ConfigResponse {
69
88
  argoCdCliSession?: { server: string; user: string; insecure?: boolean }
70
89
  }
71
90
 
91
+ interface CostSourceApplyResponse {
92
+ source: 'prometheus' | 'kubecost'
93
+ address?: string
94
+ service?: ResourceRef & { kind: 'Service'; port: number }
95
+ apiKeySet: boolean
96
+ }
97
+
72
98
  interface SettingsDialogProps {
73
99
  open: boolean
74
100
  onClose: () => void
75
101
  initialSection?: SettingsSectionId
102
+ onNavigateToResource?: (resource: ResourceRef) => void
76
103
  }
77
104
 
78
105
  // The settings surface splits into three honest apply buckets:
79
- // • Persisted config (kubeconfig, server, timeline, MCP, cost currency)
80
- // saved by the owner-gated footer. Currency applies live unless a startup
81
- // flag owns it; the rest restart.
82
- // • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints
106
+ // • Persisted startup config (kubeconfig, server, timeline, MCP) saved by
107
+ // the owner-gated footer and applied after restart.
108
+ // Live integrations (Prometheus, cost source, Argo CD) — their own Apply/Connect endpoints
83
109
  // re-point the running server; effect immediately, NOT part of footer dirty.
84
- // • AI diagnose client-side prefs, self-saving, editable by everyone.
85
- export type SettingsSectionId =
86
- | 'overview' | 'perms' | 'connection' | 'prometheus' | 'cost' | 'argocd' | 'ai' | 'advanced'
87
-
88
- // Persisted footer fields include startup settings plus the live currency override.
110
+ // • Self-saving preferences (cost currency, AI diagnose) applied immediately.
89
111
  // Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through
90
112
  // their own controls and are excluded here. Every field is normalized so
91
113
  // unset≡default doesn't read as a change.
@@ -101,7 +123,7 @@ function normalizeStartup(c: Config) {
101
123
  timelineDbPath: c.timelineDbPath ?? '',
102
124
  historyLimit: c.historyLimit ?? null,
103
125
  mcp: c.mcp ?? true,
104
- opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '',
126
+ restoreLastDesktopContext: c.restoreLastDesktopContext ?? true,
105
127
  }
106
128
  }
107
129
 
@@ -109,6 +131,7 @@ export function SettingsDialog({
109
131
  open,
110
132
  onClose,
111
133
  initialSection = 'overview',
134
+ onNavigateToResource,
112
135
  }: SettingsDialogProps) {
113
136
  const queryClient = useQueryClient()
114
137
  const dialogRef = useRef<HTMLDivElement>(null)
@@ -130,6 +153,12 @@ export function SettingsDialog({
130
153
  const [loadError, setLoadError] = useState<string | null>(null)
131
154
  const [section, setSection] = useState<SettingsSectionId>('overview')
132
155
  const [confirmingClose, setConfirmingClose] = useState(false)
156
+ const [costCredentialDirty, setCostCredentialDirty] = useState(false)
157
+ const [costDraftReset, setCostDraftReset] = useState(0)
158
+ const [costCurrencySaving, setCostCurrencySaving] = useState(false)
159
+ const configSavingRef = useRef(false)
160
+ const costCurrencySavingRef = useRef(false)
161
+ const pendingCloseActionRef = useRef<(() => void) | null>(null)
133
162
  const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
134
163
  open && section === 'argocd'
135
164
  )
@@ -159,7 +188,8 @@ export function SettingsDialog({
159
188
  const clusterDirty =
160
189
  edN.kubeconfig !== svN.kubeconfig ||
161
190
  edN.kubeconfigDirs !== svN.kubeconfigDirs ||
162
- edN.namespace !== svN.namespace
191
+ edN.namespace !== svN.namespace ||
192
+ edN.restoreLastDesktopContext !== svN.restoreLastDesktopContext
163
193
  const serverDirty =
164
194
  edN.port !== svN.port || edN.noBrowser !== svN.noBrowser || edN.browser !== svN.browser
165
195
  const mcpDirty = edN.mcp !== svN.mcp
@@ -167,11 +197,15 @@ export function SettingsDialog({
167
197
  edN.timelineStorage !== svN.timelineStorage ||
168
198
  edN.timelineDbPath !== svN.timelineDbPath ||
169
199
  edN.historyLimit !== svN.historyLimit
170
- const costDirty = edN.opencostCurrency !== svN.opencostCurrency
200
+ const costSourceDirty =
201
+ (editedConfig.costSource ?? 'auto') !== (configData?.file.costSource ?? 'auto') ||
202
+ (editedConfig.kubecostUrl ?? '').trim() !== (configData?.file.kubecostUrl ?? '').trim() ||
203
+ (editedConfig.kubecostClusterId ?? '').trim() !== (configData?.file.kubecostClusterId ?? '').trim()
204
+ const costIntegrationDirty = costSourceDirty || costCredentialDirty
171
205
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
172
206
  const connectionDirty = clusterDirty || serverDirty
173
207
  const advancedDirty = mcpDirty || timelineDirty
174
- const configDirty = configData != null && (connectionDirty || costDirty || advancedDirty)
208
+ const configDirty = configData != null && (connectionDirty || advancedDirty)
175
209
 
176
210
  // Load config on open + snapshot AI prefs + pick a default section that's
177
211
  // actually accessible to the current identity.
@@ -180,6 +214,9 @@ export function SettingsDialog({
180
214
  setSaveMessage(null)
181
215
  setLoadError(null)
182
216
  setConfirmingClose(false)
217
+ pendingCloseActionRef.current = null
218
+ setCostCredentialDirty(false)
219
+ setCostDraftReset((current) => current + 1)
183
220
  setAiSaved(false)
184
221
  setAiDraft({
185
222
  agent: diag.selectedAgent,
@@ -223,7 +260,8 @@ export function SettingsDialog({
223
260
  }, [])
224
261
 
225
262
  const saveConfig = useCallback(async (): Promise<boolean> => {
226
- if (!configData) return false
263
+ if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
264
+ configSavingRef.current = true
227
265
  setSaving(true)
228
266
  setSaveMessage(null)
229
267
  try {
@@ -231,10 +269,14 @@ export function SettingsDialog({
231
269
  // LAST-COMMITTED values (from configData.file), never an un-applied draft:
232
270
  // sending a typed-but-not-applied argoCdUrl trips the server-side origin
233
271
  // guard that clears the stored Argo token, and a stale value would revert a
234
- // live-applied integration. configData.file is kept in sync on Apply/Connect.
272
+ // live-applied integration. configData.file is kept in sync after every live save.
235
273
  const body: Config = {
236
274
  ...editedConfig,
275
+ opencostCurrency: configData.file.opencostCurrency,
237
276
  prometheusUrl: configData.file.prometheusUrl,
277
+ costSource: configData.file.costSource,
278
+ kubecostUrl: configData.file.kubecostUrl,
279
+ kubecostClusterId: configData.file.kubecostClusterId,
238
280
  argoCdUrl: configData.file.argoCdUrl,
239
281
  argoCdInsecureTls: configData.file.argoCdInsecureTls,
240
282
  }
@@ -253,31 +295,56 @@ export function SettingsDialog({
253
295
  const committed = { ...body, opencostCurrency: saved.opencostCurrency }
254
296
  setEditedConfig((prev) => ({ ...prev, opencostCurrency: saved.opencostCurrency }))
255
297
  setConfigData((prev) => (prev ? { ...prev, file: committed } : prev))
256
- if (costDirty && !configData.openCostCurrencyManaged) {
257
- void queryClient.invalidateQueries({
258
- predicate: (query) =>
259
- typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
260
- })
261
- }
262
- if (costDirty && configData.openCostCurrencyManaged) {
263
- setSaveMessage(connectionDirty || advancedDirty
264
- ? 'Saved. CLI/Helm currency remains active; restart without that override to apply it. Restart Radar for other changes.'
265
- : 'Saved. CLI/Helm currency remains active; restart without that override to apply this setting.')
266
- } else {
267
- setSaveMessage(connectionDirty || advancedDirty
268
- ? costDirty
269
- ? 'Saved. Currency applied immediately; restart Radar for other changes.'
270
- : 'Saved. Restart Radar to apply.'
271
- : 'Saved. Applied immediately.')
272
- }
298
+ setSaveMessage('Saved. Restart Radar to apply.')
273
299
  return true
274
300
  } catch (err) {
275
301
  setSaveMessage(`Error: ${err}`)
276
302
  return false
277
303
  } finally {
304
+ configSavingRef.current = false
278
305
  setSaving(false)
279
306
  }
280
- }, [editedConfig, configData, costDirty, connectionDirty, advancedDirty, queryClient])
307
+ }, [editedConfig, configData])
308
+
309
+ const saveCostCurrency = useCallback(async (value: string): Promise<void> => {
310
+ if (!configData) throw new Error('Radar configuration is not available')
311
+ if (configSavingRef.current) throw new Error('Other settings are being saved')
312
+ if (costCurrencySavingRef.current) throw new Error('A currency change is already being saved')
313
+ costCurrencySavingRef.current = true
314
+ setCostCurrencySaving(true)
315
+ try {
316
+ const body: Config = {
317
+ ...configData.file,
318
+ opencostCurrency: value || undefined,
319
+ }
320
+ const res = await fetch(apiUrl('/config'), {
321
+ method: 'PUT',
322
+ credentials: getCredentialsMode(),
323
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
324
+ body: JSON.stringify(body),
325
+ })
326
+ if (!res.ok) {
327
+ const data = await res.json().catch(() => null)
328
+ const message = data !== null && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
329
+ ? data.error
330
+ : res.statusText
331
+ throw new Error(message)
332
+ }
333
+ const saved = await res.json() as Config
334
+ setEditedConfig((prev) => ({ ...prev, opencostCurrency: saved.opencostCurrency }))
335
+ setConfigData((prev) => prev ? {
336
+ ...prev,
337
+ file: { ...prev.file, opencostCurrency: saved.opencostCurrency },
338
+ } : prev)
339
+ void queryClient.invalidateQueries({
340
+ predicate: (query) =>
341
+ typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
342
+ })
343
+ } finally {
344
+ costCurrencySavingRef.current = false
345
+ setCostCurrencySaving(false)
346
+ }
347
+ }, [configData, queryClient])
281
348
 
282
349
  // AI prefs are client-side (localStorage) — commit the staged draft now.
283
350
  // setSelectedAgent clears model/effort (they're agent-specific), so set the
@@ -297,23 +364,48 @@ export function SettingsDialog({
297
364
  // what's saved.
298
365
  if (!configData) return
299
366
  setEditedConfig(configData.file)
367
+ setCostCredentialDirty(false)
368
+ setCostDraftReset((current) => current + 1)
300
369
  setSaveMessage(null)
301
370
  }, [configData])
302
371
 
372
+ const finishClose = useCallback(() => {
373
+ const action = pendingCloseActionRef.current
374
+ pendingCloseActionRef.current = null
375
+ onClose()
376
+ action?.()
377
+ }, [onClose])
378
+
303
379
  const handleSaveAndClose = useCallback(async () => {
304
380
  const ok = await saveConfig()
305
- if (ok) onClose()
306
- }, [saveConfig, onClose])
381
+ if (ok) finishClose()
382
+ }, [saveConfig, finishClose])
383
+
384
+ const reviewCostDraft = useCallback(() => {
385
+ setConfirmingClose(false)
386
+ setSection('cost')
387
+ requestAnimationFrame(() => {
388
+ const applyButton = document.getElementById('cost-apply-source')
389
+ applyButton?.scrollIntoView({ block: 'center' })
390
+ applyButton?.focus()
391
+ })
392
+ }, [])
307
393
 
308
394
  // Close guard: a pending startup edit prompts an inline confirm rather than
309
395
  // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
310
396
  // drop it on close.
311
- const requestCloseRef = useRef<() => void>(() => {})
312
- requestCloseRef.current = () => {
313
- if (canEditConfig && configDirty) setConfirmingClose(true)
314
- else onClose()
397
+ const requestCloseRef = useRef<(afterClose?: () => void) => void>(() => {})
398
+ requestCloseRef.current = (afterClose) => {
399
+ pendingCloseActionRef.current = afterClose ?? null
400
+ if (canEditConfig && (configDirty || costIntegrationDirty)) setConfirmingClose(true)
401
+ else finishClose()
315
402
  }
316
403
 
404
+ const navigateFromSettings = useCallback((resource: ResourceRef) => {
405
+ if (!onNavigateToResource) return
406
+ requestCloseRef.current(() => onNavigateToResource(resource))
407
+ }, [onNavigateToResource])
408
+
317
409
  useEffect(() => {
318
410
  if (!open) return
319
411
  const handleDocumentKeyDown = (event: KeyboardEvent) => {
@@ -357,14 +449,22 @@ export function SettingsDialog({
357
449
  { id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
358
450
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
359
451
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
360
- { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false },
361
- { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costDirty },
452
+ { id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: false },
453
+ { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
362
454
  { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
363
455
  { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
364
456
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
365
457
  ]
366
458
 
367
- const showFooter = canEditConfig && (confirmingClose || configDirty || !!saveMessage)
459
+ const offerCostReview = shouldOfferCostReview(costIntegrationDirty, section)
460
+ const showFooter = shouldShowSettingsFooter({
461
+ canEditConfig,
462
+ confirmingClose,
463
+ configDirty,
464
+ costIntegrationDirty,
465
+ section,
466
+ hasSaveMessage: Boolean(saveMessage),
467
+ })
368
468
 
369
469
  return createPortal(
370
470
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -465,7 +565,7 @@ export function SettingsDialog({
465
565
  )}
466
566
 
467
567
  {/* Overview — status at a glance; the landing section */}
468
- <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel">
568
+ <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
469
569
  <div className="mb-1">
470
570
  <h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
471
571
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -478,7 +578,7 @@ export function SettingsDialog({
478
578
  </div>
479
579
 
480
580
  {/* My permissions — usable by everyone, rendered inline (no launcher) */}
481
- <div className={clsx(section !== 'perms' && 'hidden')} role="tabpanel">
581
+ <div className={clsx(section !== 'perms' && 'hidden')} role="tabpanel" inert={section !== 'perms' || undefined}>
482
582
  <div className="mb-1">
483
583
  <h3 className="text-base font-semibold text-theme-text-primary">My permissions</h3>
484
584
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -504,6 +604,7 @@ export function SettingsDialog({
504
604
  <ClusterSection
505
605
  config={editedConfig}
506
606
  effectiveConfig={configData?.effective}
607
+ isDesktop={isDesktop}
507
608
  onChange={updateConfigField}
508
609
  />
509
610
  </div>
@@ -519,11 +620,11 @@ export function SettingsDialog({
519
620
  </div>
520
621
  </SectionPane>
521
622
 
522
- {/* Prometheus — live */}
623
+ {/* Metrics backend — live */}
523
624
  <SectionPane
524
625
  id="prometheus"
525
626
  active={section}
526
- title="Prometheus"
627
+ title="Metrics"
527
628
  caption="Applies immediately — no restart."
528
629
  live
529
630
  locked={!canEditConfig}
@@ -544,17 +645,42 @@ export function SettingsDialog({
544
645
  id="cost"
545
646
  active={section}
546
647
  title="Cost"
547
- caption={configData?.openCostCurrencyManaged
548
- ? 'Saved to config. A CLI or Helm override is currently active.'
549
- : 'Saved to config and applied immediately.'}
550
- live={!configData?.openCostCurrencyManaged}
648
+ caption="Choose where Radar gets cost data and how amounts are labeled."
649
+ live
551
650
  locked={!canEditConfig}
552
651
  >
553
652
  <CostSection
554
653
  currency={editedConfig.opencostCurrency ?? ''}
654
+ source={editedConfig.costSource ?? 'auto'}
655
+ url={editedConfig.kubecostUrl ?? ''}
656
+ clusterId={editedConfig.kubecostClusterId ?? ''}
657
+ apiKeySet={configData?.kubecostApiKeySet ?? false}
658
+ sourceEnvManaged={configData?.kubecostEnvManaged ?? false}
659
+ sourceEnvError={configData?.kubecostEnvError}
660
+ draftDirty={costIntegrationDirty}
661
+ resetVersion={costDraftReset}
555
662
  managed={configData?.openCostCurrencyManaged ?? false}
556
663
  effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
557
- onChange={(value) => updateConfigField('opencostCurrency', value || undefined)}
664
+ deploymentMode={deploymentMode}
665
+ settingsSaving={saving}
666
+ onNavigateToResource={navigateFromSettings}
667
+ onApplyCurrency={saveCostCurrency}
668
+ onChangeSource={(value) => updateConfigField('costSource', value)}
669
+ onChangeUrl={(value) => updateConfigField('kubecostUrl', value || undefined)}
670
+ onChangeClusterId={(value) => updateConfigField('kubecostClusterId', value || undefined)}
671
+ onCredentialDirtyChange={setCostCredentialDirty}
672
+ onApplied={({ source, url, clusterId, apiKeySet }) => {
673
+ setCostCredentialDirty(false)
674
+ setEditedConfig((prev) => ({ ...prev, costSource: source, kubecostUrl: url || undefined, kubecostClusterId: clusterId || undefined }))
675
+ setConfigData((prev) => prev ? {
676
+ ...prev,
677
+ file: { ...prev.file, costSource: source, kubecostUrl: url || undefined, kubecostClusterId: clusterId || undefined },
678
+ kubecostApiKeySet: apiKeySet,
679
+ } : prev)
680
+ void queryClient.invalidateQueries({
681
+ predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
682
+ })
683
+ }}
558
684
  />
559
685
  </SectionPane>
560
686
 
@@ -599,7 +725,7 @@ export function SettingsDialog({
599
725
  {/* AI diagnose — self-saving, usable by everyone. Same heading block
600
726
  as every other tab; the body is the agent controls (when a CLI is
601
727
  installed) or an enable explainer (when not). */}
602
- <div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel">
728
+ <div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel" inert={section !== 'ai' || undefined}>
603
729
  <div className="mb-4">
604
730
  <h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
605
731
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -687,30 +813,54 @@ export function SettingsDialog({
687
813
  <div className="flex items-center justify-between gap-3 px-4 py-2.5">
688
814
  {confirmingClose ? (
689
815
  <>
690
- <span className="text-xs text-theme-text-secondary">Unsaved changes.</span>
816
+ <span className="text-xs text-theme-text-secondary">
817
+ {costIntegrationDirty && configDirty
818
+ ? 'Cost source changes are not applied, and other changes are unsaved.'
819
+ : costIntegrationDirty
820
+ ? 'Cost source changes have not been applied.'
821
+ : 'Unsaved changes.'}
822
+ </span>
691
823
  <div className="flex items-center gap-2">
692
824
  <button
693
- onClick={() => setConfirmingClose(false)}
825
+ onClick={() => {
826
+ pendingCloseActionRef.current = null
827
+ setConfirmingClose(false)
828
+ }}
694
829
  disabled={saving}
695
830
  className="px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50"
696
831
  >
697
832
  Keep editing
698
833
  </button>
699
834
  <button
700
- onClick={onClose}
835
+ onClick={finishClose}
701
836
  disabled={saving}
702
837
  className="px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50"
703
838
  >
704
839
  Discard
705
840
  </button>
706
- <button
707
- onClick={handleSaveAndClose}
708
- disabled={saving}
709
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
710
- >
711
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
712
- Save
713
- </button>
841
+ {offerCostReview && (
842
+ <button
843
+ onClick={reviewCostDraft}
844
+ className={clsx(
845
+ 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
846
+ configDirty
847
+ ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
848
+ : 'btn-brand',
849
+ )}
850
+ >
851
+ Review Cost
852
+ </button>
853
+ )}
854
+ {configDirty && (
855
+ <button
856
+ onClick={costIntegrationDirty ? saveConfig : handleSaveAndClose}
857
+ disabled={saving || costCurrencySaving}
858
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
859
+ >
860
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
861
+ {costIntegrationDirty ? 'Save other changes' : 'Save'}
862
+ </button>
863
+ )}
714
864
  </div>
715
865
  </>
716
866
  ) : (
@@ -719,7 +869,7 @@ export function SettingsDialog({
719
869
  <Tooltip content="Discard unsaved changes and revert to the last saved values">
720
870
  <button
721
871
  onClick={discardChanges}
722
- disabled={saving || !configDirty}
872
+ disabled={saving || (!configDirty && !costIntegrationDirty)}
723
873
  className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50 disabled:pointer-events-none"
724
874
  >
725
875
  <RotateCcw className="w-3.5 h-3.5" />
@@ -732,14 +882,31 @@ export function SettingsDialog({
732
882
  </span>
733
883
  )}
734
884
  </div>
735
- <button
736
- onClick={saveConfig}
737
- disabled={saving || !configDirty}
738
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
739
- >
740
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
741
- Save
742
- </button>
885
+ <div className="flex items-center gap-2">
886
+ {offerCostReview && (
887
+ <button
888
+ onClick={reviewCostDraft}
889
+ className={clsx(
890
+ 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
891
+ configDirty
892
+ ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
893
+ : 'btn-brand',
894
+ )}
895
+ >
896
+ Review Cost
897
+ </button>
898
+ )}
899
+ {configDirty && (
900
+ <button
901
+ onClick={saveConfig}
902
+ disabled={saving || costCurrencySaving}
903
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
904
+ >
905
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
906
+ Save
907
+ </button>
908
+ )}
909
+ </div>
743
910
  </>
744
911
  )}
745
912
  </div>
@@ -843,7 +1010,7 @@ function SectionPane({
843
1010
  children: ReactNode
844
1011
  }) {
845
1012
  return (
846
- <div className={clsx(active !== id && 'hidden')} role="tabpanel">
1013
+ <div className={clsx(active !== id && 'hidden')} role="tabpanel" inert={active !== id || undefined}>
847
1014
  <div className="mb-4">
848
1015
  <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
849
1016
  {!locked && caption && <SectionCaption live={live}>{caption}</SectionCaption>}
@@ -906,7 +1073,10 @@ interface OverviewRow {
906
1073
  function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s: SettingsSectionId) => void }) {
907
1074
  const { data: cluster } = useClusterInfo()
908
1075
  const { data: prom } = usePrometheusStatus()
1076
+ const { data: cost } = useOpenCostSummary()
909
1077
  const { data: argo } = useArgoStatus(active)
1078
+ const { data: capabilitiesData } = useCapabilities()
1079
+ const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
910
1080
  const { data: version } = useVersionCheck()
911
1081
  const capabilities = useCapabilitiesContext()
912
1082
  const diag = useDiagnose()
@@ -917,6 +1087,16 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
917
1087
  diag.agents.find((a) => a.name === diag.selectedAgent)?.label ?? diag.agents[0]?.label
918
1088
  const mcpOn = capabilities.mcpEnabled
919
1089
  const mcpUrl = mcpLoopbackUrl()
1090
+ const costMissing = cost?.reason === 'no_prometheus' || cost?.reason === 'no_cost_source' || cost?.reason === 'no_metrics'
1091
+ const costUnavailableDetail = cost?.reason === 'no_prometheus'
1092
+ ? 'Connect OpenCost metrics in Metrics.'
1093
+ : cost?.reason === 'no_metrics'
1094
+ ? 'The active cost source returned no allocation data.'
1095
+ : cost?.reason === 'access_denied'
1096
+ ? 'Your current permissions do not allow cost data.'
1097
+ : cost?.reason
1098
+ ? costIntegrationUnavailableMessage(cost.reason) ?? 'The active cost source query failed.'
1099
+ : undefined
920
1100
 
921
1101
  const rows: OverviewRow[] = [
922
1102
  {
@@ -926,11 +1106,23 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
926
1106
  detail: cluster ? `Kubernetes ${cluster.kubernetesVersion} · ${cluster.nodeCount} nodes` : undefined,
927
1107
  },
928
1108
  {
929
- id: 'prometheus', icon: Activity, label: 'Prometheus',
1109
+ id: 'prometheus', icon: Activity, label: 'Metrics',
930
1110
  tone: prom?.connected ? 'ok' : prom?.available ? 'warn' : 'off',
931
1111
  value: prom?.connected ? 'Connected' : prom?.available ? 'Not reachable' : 'Not configured',
932
1112
  detail: prom?.connected ? prom.address : undefined,
933
1113
  },
1114
+ {
1115
+ id: costConfigurationAction(cost?.reason).section, icon: Coins, label: 'Cost',
1116
+ tone: cost?.available ? 'ok' : cost ? (costMissing ? 'off' : 'warn') : 'unknown',
1117
+ value: cost?.available
1118
+ ? costSourceLabel(cost.source)
1119
+ : cost
1120
+ ? costMissing ? 'No cost data' : 'Unavailable'
1121
+ : 'Checking…',
1122
+ detail: cost?.available
1123
+ ? costFreshnessLabel(cost.source, cost.window, cost.dataThrough)
1124
+ : costUnavailableDetail,
1125
+ },
934
1126
  {
935
1127
  id: 'argocd', icon: GitBranch, label: 'Argo CD',
936
1128
  tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
@@ -963,9 +1155,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
963
1155
 
964
1156
  return (
965
1157
  <div className="space-y-4">
966
- {version?.updateAvailable && (
1158
+ {version?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud' && (
967
1159
  <a
968
- href={version.releaseUrl}
1160
+ href={versionUpdateURL(deploymentMode, version.releaseUrl)}
969
1161
  target="_blank"
970
1162
  rel="noreferrer"
971
1163
  className="flex items-center gap-2 px-3 py-2 text-xs rounded-md border border-skyhook-500/30 bg-skyhook-500/10 hover:bg-skyhook-500/15 transition-colors"
@@ -994,10 +1186,10 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
994
1186
  <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
995
1187
  <span className="text-sm text-theme-text-primary w-24 shrink-0 truncate">{row.label}</span>
996
1188
  <OverviewStatus tone={row.tone} />
997
- <div className="flex-1 min-w-0 flex items-baseline gap-1.5">
998
- <span className="text-sm text-theme-text-secondary shrink-0">{row.value}</span>
1189
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
1190
+ <span className="truncate text-sm text-theme-text-secondary">{row.value}</span>
999
1191
  {row.detail && (
1000
- <span className="text-xs text-theme-text-tertiary truncate">{row.detail}</span>
1192
+ <span className="break-words text-xs leading-4 text-theme-text-tertiary">{row.detail}</span>
1001
1193
  )}
1002
1194
  </div>
1003
1195
  {row.copyable && (
@@ -1052,10 +1244,12 @@ function AIUnavailableNotice() {
1052
1244
  function ClusterSection({
1053
1245
  config,
1054
1246
  effectiveConfig,
1247
+ isDesktop,
1055
1248
  onChange,
1056
1249
  }: {
1057
1250
  config: Config
1058
1251
  effectiveConfig?: Config
1252
+ isDesktop: boolean
1059
1253
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
1060
1254
  }) {
1061
1255
  const kubeconfigDirs = effectiveConfig ? (effectiveConfig.kubeconfigDirs ?? []) : config.kubeconfigDirs
@@ -1087,6 +1281,14 @@ function ClusterSection({
1087
1281
  placeholder="All namespaces"
1088
1282
  onChange={(v) => onChange('namespace', v || undefined)}
1089
1283
  />
1284
+ {isDesktop && (
1285
+ <ConfigToggle
1286
+ label="Reopen on the last used cluster"
1287
+ description="Come back to the cluster you were working in. Turn off to use your kubeconfig's current context on the next Desktop start."
1288
+ value={config.restoreLastDesktopContext ?? true}
1289
+ onChange={(v) => onChange('restoreLastDesktopContext', v ? undefined : false)}
1290
+ />
1291
+ )}
1090
1292
  </>
1091
1293
  )
1092
1294
  }
@@ -1179,40 +1381,405 @@ function TimelineSection({
1179
1381
 
1180
1382
  function CostSection({
1181
1383
  currency,
1384
+ source,
1385
+ url,
1386
+ clusterId,
1387
+ apiKeySet,
1388
+ sourceEnvManaged,
1389
+ sourceEnvError,
1390
+ draftDirty,
1391
+ resetVersion,
1182
1392
  managed,
1183
1393
  effectiveCurrency,
1184
- onChange,
1394
+ deploymentMode,
1395
+ settingsSaving,
1396
+ onNavigateToResource,
1397
+ onApplyCurrency,
1398
+ onChangeSource,
1399
+ onChangeUrl,
1400
+ onChangeClusterId,
1401
+ onCredentialDirtyChange,
1402
+ onApplied,
1185
1403
  }: {
1186
1404
  currency: string
1405
+ source: 'auto' | 'prometheus' | 'kubecost'
1406
+ url: string
1407
+ clusterId: string
1408
+ apiKeySet: boolean
1409
+ sourceEnvManaged: boolean
1410
+ sourceEnvError?: string
1411
+ draftDirty: boolean
1412
+ resetVersion: number
1187
1413
  managed: boolean
1188
1414
  effectiveCurrency: string
1189
- onChange: (value: string) => void
1415
+ deploymentMode: 'local' | 'in-cluster' | 'cloud'
1416
+ settingsSaving: boolean
1417
+ onNavigateToResource?: (resource: ResourceRef) => void
1418
+ onApplyCurrency: (value: string) => Promise<void>
1419
+ onChangeSource: (value: 'auto' | 'prometheus' | 'kubecost') => void
1420
+ onChangeUrl: (value: string) => void
1421
+ onChangeClusterId: (value: string) => void
1422
+ onCredentialDirtyChange: (dirty: boolean) => void
1423
+ onApplied: (value: { source: 'auto' | 'prometheus' | 'kubecost'; url: string; clusterId: string; apiKeySet: boolean }) => void
1190
1424
  }) {
1425
+ const [apply, setApply] = useState<CostApplyState>({ status: 'idle' })
1426
+ const [apiKey, setApiKey] = useState('')
1427
+ const [apiKeyTouched, setApiKeyTouched] = useState(false)
1428
+ const [apiKeyCleared, setApiKeyCleared] = useState(false)
1429
+ const [advancedOpen, setAdvancedOpen] = useState(false)
1430
+ const [currencyDraft, setCurrencyDraft] = useState(currency)
1431
+ const [currencySave, setCurrencySave] = useState<CurrencySaveState>({ status: 'idle' })
1432
+ const apiKeyWillBeUsed = !apiKeyCleared && ((apiKeyTouched && apiKey !== '') || apiKeySet)
1433
+ const apiKeyUsesPlainHTTP = apiKeyWillBeUsed && /^http:\/\//i.test(url.trim())
1434
+
1435
+ useEffect(() => {
1436
+ setApiKey('')
1437
+ setApiKeyTouched(false)
1438
+ setApiKeyCleared(false)
1439
+ setApply({ status: 'idle' })
1440
+ setCurrencySave({ status: 'idle' })
1441
+ }, [resetVersion])
1442
+
1443
+ useEffect(() => {
1444
+ setCurrencyDraft(currency)
1445
+ }, [currency])
1446
+
1447
+ useEffect(() => {
1448
+ onCredentialDirtyChange(apiKeyTouched || apiKeyCleared)
1449
+ }, [apiKeyCleared, apiKeyTouched, onCredentialDirtyChange])
1450
+
1451
+ useEffect(() => {
1452
+ if (url.trim() || clusterId.trim()) setAdvancedOpen(true)
1453
+ }, [clusterId, url])
1454
+
1455
+ const applySource = async () => {
1456
+ setApply({ status: 'applying' })
1457
+ let sentKey: string | undefined
1458
+ if (apiKeyCleared) {
1459
+ sentKey = ''
1460
+ } else if (apiKeyTouched && apiKey !== '') {
1461
+ sentKey = apiKey
1462
+ }
1463
+ try {
1464
+ const res = await fetch(apiUrl('/integrations/cost'), {
1465
+ method: 'PUT',
1466
+ credentials: getCredentialsMode(),
1467
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
1468
+ body: JSON.stringify({
1469
+ source,
1470
+ url: url.trim(),
1471
+ clusterId: clusterId.trim(),
1472
+ ...(sentKey !== undefined ? { apiKey: sentKey } : {}),
1473
+ }),
1474
+ })
1475
+ const data: unknown = await res.json().catch(() => null)
1476
+ if (!res.ok) {
1477
+ const error =
1478
+ data !== null &&
1479
+ typeof data === 'object' &&
1480
+ 'error' in data &&
1481
+ typeof data.error === 'string'
1482
+ ? data.error
1483
+ : res.statusText
1484
+ setApply({ status: 'failed', error })
1485
+ return
1486
+ }
1487
+ if (
1488
+ data === null ||
1489
+ typeof data !== 'object' ||
1490
+ !('source' in data) ||
1491
+ (data.source !== 'prometheus' && data.source !== 'kubecost') ||
1492
+ !('apiKeySet' in data) ||
1493
+ typeof data.apiKeySet !== 'boolean' ||
1494
+ ('address' in data && data.address !== undefined && typeof data.address !== 'string') ||
1495
+ ('service' in data &&
1496
+ data.service !== undefined &&
1497
+ (data.service === null ||
1498
+ typeof data.service !== 'object' ||
1499
+ !('kind' in data.service) ||
1500
+ data.service.kind !== 'Service' ||
1501
+ !('namespace' in data.service) ||
1502
+ typeof data.service.namespace !== 'string' ||
1503
+ !('name' in data.service) ||
1504
+ typeof data.service.name !== 'string' ||
1505
+ !('port' in data.service) ||
1506
+ typeof data.service.port !== 'number' ||
1507
+ !Number.isInteger(data.service.port) ||
1508
+ data.service.port <= 0))
1509
+ ) {
1510
+ throw new Error('Radar returned an invalid cost source response')
1511
+ }
1512
+ const applied = data as CostSourceApplyResponse
1513
+ setApiKey('')
1514
+ setApiKeyTouched(false)
1515
+ setApiKeyCleared(false)
1516
+ onApplied({ source, url: url.trim(), clusterId: clusterId.trim(), apiKeySet: applied.apiKeySet })
1517
+ setApply({ status: 'connected', source: applied.source, address: applied.address, service: applied.service })
1518
+ } catch (err) {
1519
+ setApply({ status: 'failed', error: String(err) })
1520
+ }
1521
+ }
1522
+
1523
+ const saveCurrency = async (value: string) => {
1524
+ if (currencySave.status === 'saving') return
1525
+ setCurrencyDraft(value)
1526
+ setCurrencySave({ status: 'saving' })
1527
+ try {
1528
+ await onApplyCurrency(value)
1529
+ setCurrencySave({ status: 'saved' })
1530
+ } catch (err) {
1531
+ setCurrencyDraft(currency)
1532
+ setCurrencySave({
1533
+ status: 'failed',
1534
+ error: err instanceof Error ? err.message : String(err),
1535
+ })
1536
+ }
1537
+ }
1538
+
1191
1539
  return (
1192
- <div>
1193
- <label className="mb-1 block text-sm font-medium text-theme-text-primary">
1194
- Currency override
1195
- </label>
1196
- <p className="mb-1 text-xs text-theme-text-tertiary">
1197
- Choose a currency, or use Auto to read <code>currencyCode</code> or{' '}
1198
- <code>DISPLAY_CURRENCY</code> from an active OpenCost/Kubecost installation, then fall back
1199
- to USD. A custom Prometheus URL disables detection. Radar labels values but does not convert
1200
- them.
1201
- </p>
1202
- <SelectMenu
1203
- value={currency}
1204
- options={currencyOptionsForValue(currency)}
1205
- onChange={onChange}
1206
- ariaLabel="Currency override"
1207
- searchPlaceholder="Search currencies by name or code"
1208
- className="w-full"
1209
- />
1210
- {managed && (
1211
- <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
1212
- Currently managed by CLI or Helm: {effectiveCurrency || 'Auto'}. Saved changes apply
1213
- after Radar starts without that override.
1214
- </p>
1540
+ <div className="space-y-5">
1541
+ {sourceEnvManaged && (
1542
+ <div id="cost-source-managed" className={clsx(
1543
+ 'rounded-md border p-3',
1544
+ sourceEnvError
1545
+ ? 'border-red-500/30 bg-red-500/[0.07]'
1546
+ : 'border-skyhook-500/30 bg-skyhook-500/[0.07]',
1547
+ )}>
1548
+ <p className={clsx(
1549
+ 'flex items-center gap-1.5 text-sm font-medium',
1550
+ sourceEnvError ? 'text-red-600 dark:text-red-400/90' : 'text-theme-text-primary',
1551
+ )}>
1552
+ {sourceEnvError
1553
+ ? <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
1554
+ : <Terminal className="h-3.5 w-3.5 shrink-0 text-skyhook-500" />}
1555
+ {sourceEnvError ? 'Deployment cost configuration is invalid' : 'Configured by the deployment'}
1556
+ </p>
1557
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1558
+ {sourceEnvError ?? 'Edit the RADAR_COST_SOURCE and RADAR_KUBECOST_* environment variables or Helm values, then restart Radar to change the source.'}
1559
+ </p>
1560
+ </div>
1215
1561
  )}
1562
+
1563
+ <div className="space-y-4 rounded-lg border border-theme-border bg-theme-base/40 p-4">
1564
+ <p className="text-sm font-semibold text-theme-text-primary">Cost source</p>
1565
+ <div>
1566
+ <label htmlFor="cost-source" className="mb-1 block text-sm font-medium text-theme-text-primary">Data source</label>
1567
+ <p id="cost-source-help" className="mb-1 text-xs text-theme-text-tertiary">
1568
+ Automatic uses existing OpenCost metrics first, then Kubecost. Leave it selected unless
1569
+ you need to force one source.
1570
+ </p>
1571
+ <SelectMenu
1572
+ id="cost-source"
1573
+ value={source}
1574
+ options={[
1575
+ { value: 'auto', label: 'Automatic (recommended)' },
1576
+ { value: 'prometheus', label: 'OpenCost metrics only' },
1577
+ { value: 'kubecost', label: 'Kubecost only' },
1578
+ ]}
1579
+ onChange={(value) => { onChangeSource(value as typeof source); setApply({ status: 'idle' }) }}
1580
+ disabled={sourceEnvManaged}
1581
+ className="w-full"
1582
+ ariaLabel="Cost data source"
1583
+ ariaDescribedBy={sourceEnvManaged ? 'cost-source-help cost-source-managed' : 'cost-source-help'}
1584
+ />
1585
+ </div>
1586
+
1587
+ {source !== 'prometheus' && (
1588
+ <div className="space-y-3 border-t border-theme-border-subtle pt-4">
1589
+ <p className="text-xs font-medium text-theme-text-secondary">
1590
+ {source === 'auto' ? 'Kubecost fallback' : 'Kubecost connection'}
1591
+ </p>
1592
+ <div>
1593
+ <button
1594
+ type="button"
1595
+ onClick={() => setAdvancedOpen((open) => !open)}
1596
+ className="flex w-full items-center gap-1.5 rounded-md py-1 text-left text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1597
+ aria-expanded={advancedOpen}
1598
+ aria-controls="cost-advanced-connection"
1599
+ >
1600
+ <CollapseChevron open={advancedOpen} className="h-3.5 w-3.5" />
1601
+ Advanced connection settings
1602
+ </button>
1603
+ <Collapse open={advancedOpen}>
1604
+ <div id="cost-advanced-connection" className="pt-3">
1605
+ <div className="space-y-3 rounded-md border border-theme-border-subtle bg-theme-base/60 p-3">
1606
+ <div>
1607
+ <label htmlFor="cost-kubecost-url" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost URL</label>
1608
+ <p id="cost-kubecost-url-help" className="mb-1 text-xs text-theme-text-tertiary">
1609
+ Leave blank when Kubecost runs in this cluster. Enter the central Aggregator URL
1610
+ for an agent-only or federated setup.
1611
+ </p>
1612
+ <Input
1613
+ id="cost-kubecost-url"
1614
+ aria-describedby={sourceEnvManaged ? 'cost-kubecost-url-help cost-source-managed' : 'cost-kubecost-url-help'}
1615
+ value={url}
1616
+ onChange={(event) => { onChangeUrl(event.target.value); setApply({ status: 'idle' }) }}
1617
+ disabled={sourceEnvManaged}
1618
+ placeholder="Auto-discover, or https://kubecost.example.com"
1619
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
1620
+ />
1621
+ </div>
1622
+ <div>
1623
+ <label htmlFor="cost-kubecost-cluster-id" className="mb-1 block text-sm font-medium text-theme-text-primary">Cluster ID</label>
1624
+ <p id="cost-kubecost-cluster-id-help" className="mb-1 text-xs text-theme-text-tertiary">
1625
+ Usually detected automatically. Set it only if detection fails or the Kubecost
1626
+ server contains data for more than one cluster. Use the <code>CLUSTER_ID</code>{' '}
1627
+ value configured on the FinOps Agent or Aggregator.
1628
+ </p>
1629
+ <Input
1630
+ id="cost-kubecost-cluster-id"
1631
+ aria-describedby={sourceEnvManaged ? 'cost-kubecost-cluster-id-help cost-source-managed' : 'cost-kubecost-cluster-id-help'}
1632
+ value={clusterId}
1633
+ onChange={(event) => { onChangeClusterId(event.target.value); setApply({ status: 'idle' }) }}
1634
+ disabled={sourceEnvManaged}
1635
+ placeholder="Auto-detect CLUSTER_ID"
1636
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
1637
+ />
1638
+ </div>
1639
+ </div>
1640
+ </div>
1641
+ </Collapse>
1642
+ </div>
1643
+ <div>
1644
+ <label htmlFor="cost-kubecost-api-key" className="mb-1 block text-sm font-medium text-theme-text-primary">API key (optional)</label>
1645
+ <p id="cost-kubecost-api-key-help" className="mb-1 text-xs text-theme-text-tertiary">
1646
+ Only needed when Kubecost requires authentication.{' '}
1647
+ {url.trim() === '' && (
1648
+ <>Auto-discovery can use the Aggregator&apos;s <code>tcp-api-rbac:9008</code> endpoint when SAML/OIDC protects port 9004; setting a key disables that fallback. </>
1649
+ )}
1650
+ {deploymentMode === 'local' ? (
1651
+ <>Radar stores it unencrypted in this machine&apos;s owner-only config file (<code>~/.radar/config.json</code>) and never returns it through the API.</>
1652
+ ) : (
1653
+ <>For this in-cluster deployment, create a Kubernetes Secret and set the Helm value <code>cost.kubecost.existingSecret</code>. A key entered here is stored unencrypted in this pod&apos;s temporary owner-only config file and can disappear when the pod restarts. Radar never returns it through the API.</>
1654
+ )}
1655
+ {apiKeySet && !apiKeyCleared ? ' A key is configured.' : ''}
1656
+ </p>
1657
+ <div className="flex items-center gap-2">
1658
+ <Input
1659
+ id="cost-kubecost-api-key"
1660
+ aria-describedby={sourceEnvManaged ? 'cost-kubecost-api-key-help cost-source-managed' : 'cost-kubecost-api-key-help'}
1661
+ type="password"
1662
+ value={apiKey}
1663
+ onChange={(event) => { setApiKey(event.target.value); setApiKeyTouched(true); setApiKeyCleared(false); setApply({ status: 'idle' }) }}
1664
+ autoComplete="off"
1665
+ spellCheck={false}
1666
+ disabled={sourceEnvManaged}
1667
+ placeholder={apiKeySet && !apiKeyCleared ? 'Configured — enter to replace' : 'Optional API key'}
1668
+ className="min-w-0 flex-1 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
1669
+ />
1670
+ {apiKeySet && !apiKeyCleared && !sourceEnvManaged && (
1671
+ <button
1672
+ type="button"
1673
+ aria-label="Clear saved Kubecost API key"
1674
+ onClick={() => { setApiKey(''); setApiKeyTouched(false); setApiKeyCleared(true); setApply({ status: 'idle' }) }}
1675
+ className="px-2 py-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
1676
+ >
1677
+ Clear
1678
+ </button>
1679
+ )}
1680
+ </div>
1681
+ {apiKeyUsesPlainHTTP && (
1682
+ <p className="mt-1 flex items-center gap-1 text-xs text-warning-text">
1683
+ <AlertTriangle className="h-3 w-3 shrink-0" /> The API key will be sent over unencrypted HTTP. Use HTTPS unless this is a trusted private network.
1684
+ </p>
1685
+ )}
1686
+ </div>
1687
+ </div>
1688
+ )}
1689
+
1690
+ {!sourceEnvManaged && <div>
1691
+ <button
1692
+ id="cost-apply-source"
1693
+ type="button"
1694
+ onClick={applySource}
1695
+ disabled={apply.status === 'applying'}
1696
+ aria-busy={apply.status === 'applying'}
1697
+ className="flex min-w-40 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium btn-brand disabled:opacity-50"
1698
+ >
1699
+ {apply.status === 'applying' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plug className="h-3.5 w-3.5" />}
1700
+ {apply.status === 'applying' ? 'Applying…' : costSourceApplyLabel(source)}
1701
+ </button>
1702
+ <div className="mt-1 min-h-7 text-xs">
1703
+ {apply.status === 'applying' && (
1704
+ <p key="applying" role="status" aria-live="polite" className="animate-status-enter text-theme-text-tertiary">
1705
+ {source === 'prometheus' ? 'Applying source…' : 'Testing connection and applying source…'}
1706
+ </p>
1707
+ )}
1708
+ {apply.status === 'connected' && (
1709
+ <div key="connected" className="animate-status-enter flex flex-wrap items-center gap-x-1.5 gap-y-1">
1710
+ <p role="status" aria-live="polite" className="flex items-center gap-1 text-green-600 dark:text-green-400/80">
1711
+ <Check className="h-3 w-3 shrink-0" /> Applied · active source: {costSourceLabel(apply.source)}
1712
+ <span className="sr-only">
1713
+ {apply.service
1714
+ ? ` · Service ${apply.service.namespace}/${apply.service.name}, port ${apply.service.port}`
1715
+ : apply.address
1716
+ ? ` · ${apply.address}`
1717
+ : ''}
1718
+ </span>
1719
+ </p>
1720
+ {apply.service ? (
1721
+ <>
1722
+ <ResourceRefBadge resourceRef={apply.service} onClick={onNavigateToResource} />
1723
+ <span className="text-theme-text-tertiary">in {apply.service.namespace}</span>
1724
+ <span className="text-theme-text-tertiary">· port <span className="font-mono">{apply.service.port}</span></span>
1725
+ </>
1726
+ ) : apply.address ? (
1727
+ <span className="break-all font-mono text-theme-text-tertiary">{apply.address}</span>
1728
+ ) : null}
1729
+ </div>
1730
+ )}
1731
+ {apply.status === 'failed' && (
1732
+ <p key="failed" role="alert" className="animate-status-enter text-red-600 dark:text-red-400/80">{apply.error}</p>
1733
+ )}
1734
+ {draftDirty && apply.status === 'idle' && (
1735
+ <p key="dirty" role="status" aria-live="polite" className="animate-status-enter flex items-center gap-1 text-warning-text">
1736
+ <AlertTriangle className="h-3 w-3 shrink-0" /> Unapplied source changes
1737
+ </p>
1738
+ )}
1739
+ </div>
1740
+ </div>}
1741
+ </div>
1742
+
1743
+ <div className="rounded-lg border border-theme-border bg-theme-base/40 p-4">
1744
+ <label htmlFor="cost-currency" className="block text-sm font-semibold text-theme-text-primary">
1745
+ Display currency
1746
+ </label>
1747
+ <p id="cost-currency-help" className="mb-1 mt-0.5 text-xs text-theme-text-tertiary">
1748
+ Auto uses the currency reported by the active cost source, or USD when unavailable.
1749
+ Overrides relabel amounts; Radar does not convert them.
1750
+ </p>
1751
+ <SelectMenu
1752
+ id="cost-currency"
1753
+ value={currencyDraft}
1754
+ options={currencyOptionsForValue(currencyDraft)}
1755
+ onChange={(value) => { void saveCurrency(value) }}
1756
+ ariaLabel="Display currency"
1757
+ ariaDescribedBy="cost-currency-help"
1758
+ searchPlaceholder="Search currencies by name or code"
1759
+ disabled={settingsSaving || currencySave.status === 'saving'}
1760
+ className="w-full"
1761
+ />
1762
+ {currencySave.status === 'saving' && (
1763
+ <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-theme-text-tertiary">
1764
+ <Loader2 className="h-3 w-3 animate-spin" /> Saving…
1765
+ </p>
1766
+ )}
1767
+ {currencySave.status === 'saved' && (
1768
+ <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1769
+ <Check className="h-3 w-3" /> {managed ? 'Saved for when the CLI or Helm override is removed' : 'Saved'}
1770
+ </p>
1771
+ )}
1772
+ {currencySave.status === 'failed' && (
1773
+ <p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400/80">
1774
+ Could not save: {currencySave.error}
1775
+ </p>
1776
+ )}
1777
+ {managed && (
1778
+ <p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
1779
+ CLI or Helm currently sets {effectiveCurrency || 'Auto'}; that override stays active until it is removed.
1780
+ </p>
1781
+ )}
1782
+ </div>
1216
1783
  </div>
1217
1784
  )
1218
1785
  }
@@ -1319,6 +1886,18 @@ type ApplyState =
1319
1886
  | { status: 'unreachable'; error: string } // persisted, but the probe failed
1320
1887
  | { status: 'failed'; error: string } // request itself failed — nothing saved
1321
1888
 
1889
+ type CostApplyState =
1890
+ | { status: 'idle' }
1891
+ | { status: 'applying' }
1892
+ | { status: 'connected'; source: 'prometheus' | 'kubecost'; address?: string; service?: CostSourceApplyResponse['service'] }
1893
+ | { status: 'failed'; error: string }
1894
+
1895
+ type CurrencySaveState =
1896
+ | { status: 'idle' }
1897
+ | { status: 'saving' }
1898
+ | { status: 'saved' }
1899
+ | { status: 'failed'; error: string }
1900
+
1322
1901
  type HeaderRow = { key: string; value: string }
1323
1902
 
1324
1903
  function PrometheusConfigField({
@@ -1410,7 +1989,8 @@ function PrometheusConfigField({
1410
1989
  Server URL
1411
1990
  </label>
1412
1991
  <p className="text-xs text-theme-text-tertiary mb-1">
1413
- Manual Prometheus / VictoriaMetrics URL — set this to skip auto-discovery.
1992
+ Manual PromQL-compatible query URL — works with Prometheus, VictoriaMetrics, Thanos, and
1993
+ Mimir. Set this to skip auto-discovery.
1414
1994
  </p>
1415
1995
  <div className="flex items-center gap-2">
1416
1996
  <Input