@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.
@@ -13,15 +13,26 @@ import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
13
13
  import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
14
14
  import {
15
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'
24
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'
25
36
 
26
37
  // The loopback URL an MCP client is told to connect to. Shared by the overview
27
38
  // row and the MCP section: both must carry the base path, or the URL they
@@ -43,6 +54,9 @@ interface Config {
43
54
  historyLimit?: number
44
55
  prometheusUrl?: string
45
56
  opencostCurrency?: string
57
+ costSource?: 'auto' | 'prometheus' | 'kubecost'
58
+ kubecostUrl?: string
59
+ kubecostClusterId?: string
46
60
  argoCdUrl?: string
47
61
  argoCdInsecureTls?: boolean
48
62
  mcp?: boolean | null
@@ -55,6 +69,9 @@ interface ConfigResponse {
55
69
  isDesktop: boolean
56
70
  openCostCurrencyManaged?: boolean
57
71
  prometheusHeaderKeys?: string[]
72
+ kubecostApiKeySet?: boolean
73
+ kubecostEnvManaged?: boolean
74
+ kubecostEnvError?: string
58
75
  // True when an Argo CD auth token is stored. The token itself is never
59
76
  // returned — the card shows a "configured" placeholder and omits the token
60
77
  // from the PUT unless the user changes or clears it.
@@ -71,23 +88,26 @@ interface ConfigResponse {
71
88
  argoCdCliSession?: { server: string; user: string; insecure?: boolean }
72
89
  }
73
90
 
91
+ interface CostSourceApplyResponse {
92
+ source: 'prometheus' | 'kubecost'
93
+ address?: string
94
+ service?: ResourceRef & { kind: 'Service'; port: number }
95
+ apiKeySet: boolean
96
+ }
97
+
74
98
  interface SettingsDialogProps {
75
99
  open: boolean
76
100
  onClose: () => void
77
101
  initialSection?: SettingsSectionId
102
+ onNavigateToResource?: (resource: ResourceRef) => void
78
103
  }
79
104
 
80
105
  // The settings surface splits into three honest apply buckets:
81
- // • Persisted config (kubeconfig, server, timeline, MCP, cost currency)
82
- // saved by the owner-gated footer. Currency applies live unless a startup
83
- // flag owns it; the rest restart.
84
- // • 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
85
109
  // re-point the running server; effect immediately, NOT part of footer dirty.
86
- // • AI diagnose client-side prefs, self-saving, editable by everyone.
87
- export type SettingsSectionId =
88
- | 'overview' | 'perms' | 'connection' | 'prometheus' | 'cost' | 'argocd' | 'ai' | 'advanced'
89
-
90
- // Persisted footer fields include startup settings plus the live currency override.
110
+ // • Self-saving preferences (cost currency, AI diagnose) applied immediately.
91
111
  // Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through
92
112
  // their own controls and are excluded here. Every field is normalized so
93
113
  // unset≡default doesn't read as a change.
@@ -103,7 +123,6 @@ function normalizeStartup(c: Config) {
103
123
  timelineDbPath: c.timelineDbPath ?? '',
104
124
  historyLimit: c.historyLimit ?? null,
105
125
  mcp: c.mcp ?? true,
106
- opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '',
107
126
  restoreLastDesktopContext: c.restoreLastDesktopContext ?? true,
108
127
  }
109
128
  }
@@ -112,6 +131,7 @@ export function SettingsDialog({
112
131
  open,
113
132
  onClose,
114
133
  initialSection = 'overview',
134
+ onNavigateToResource,
115
135
  }: SettingsDialogProps) {
116
136
  const queryClient = useQueryClient()
117
137
  const dialogRef = useRef<HTMLDivElement>(null)
@@ -133,6 +153,12 @@ export function SettingsDialog({
133
153
  const [loadError, setLoadError] = useState<string | null>(null)
134
154
  const [section, setSection] = useState<SettingsSectionId>('overview')
135
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)
136
162
  const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
137
163
  open && section === 'argocd'
138
164
  )
@@ -171,11 +197,15 @@ export function SettingsDialog({
171
197
  edN.timelineStorage !== svN.timelineStorage ||
172
198
  edN.timelineDbPath !== svN.timelineDbPath ||
173
199
  edN.historyLimit !== svN.historyLimit
174
- 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
175
205
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
176
206
  const connectionDirty = clusterDirty || serverDirty
177
207
  const advancedDirty = mcpDirty || timelineDirty
178
- const configDirty = configData != null && (connectionDirty || costDirty || advancedDirty)
208
+ const configDirty = configData != null && (connectionDirty || advancedDirty)
179
209
 
180
210
  // Load config on open + snapshot AI prefs + pick a default section that's
181
211
  // actually accessible to the current identity.
@@ -184,6 +214,9 @@ export function SettingsDialog({
184
214
  setSaveMessage(null)
185
215
  setLoadError(null)
186
216
  setConfirmingClose(false)
217
+ pendingCloseActionRef.current = null
218
+ setCostCredentialDirty(false)
219
+ setCostDraftReset((current) => current + 1)
187
220
  setAiSaved(false)
188
221
  setAiDraft({
189
222
  agent: diag.selectedAgent,
@@ -227,7 +260,8 @@ export function SettingsDialog({
227
260
  }, [])
228
261
 
229
262
  const saveConfig = useCallback(async (): Promise<boolean> => {
230
- if (!configData) return false
263
+ if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
264
+ configSavingRef.current = true
231
265
  setSaving(true)
232
266
  setSaveMessage(null)
233
267
  try {
@@ -235,10 +269,14 @@ export function SettingsDialog({
235
269
  // LAST-COMMITTED values (from configData.file), never an un-applied draft:
236
270
  // sending a typed-but-not-applied argoCdUrl trips the server-side origin
237
271
  // guard that clears the stored Argo token, and a stale value would revert a
238
- // 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.
239
273
  const body: Config = {
240
274
  ...editedConfig,
275
+ opencostCurrency: configData.file.opencostCurrency,
241
276
  prometheusUrl: configData.file.prometheusUrl,
277
+ costSource: configData.file.costSource,
278
+ kubecostUrl: configData.file.kubecostUrl,
279
+ kubecostClusterId: configData.file.kubecostClusterId,
242
280
  argoCdUrl: configData.file.argoCdUrl,
243
281
  argoCdInsecureTls: configData.file.argoCdInsecureTls,
244
282
  }
@@ -257,31 +295,56 @@ export function SettingsDialog({
257
295
  const committed = { ...body, opencostCurrency: saved.opencostCurrency }
258
296
  setEditedConfig((prev) => ({ ...prev, opencostCurrency: saved.opencostCurrency }))
259
297
  setConfigData((prev) => (prev ? { ...prev, file: committed } : prev))
260
- if (costDirty && !configData.openCostCurrencyManaged) {
261
- void queryClient.invalidateQueries({
262
- predicate: (query) =>
263
- typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
264
- })
265
- }
266
- if (costDirty && configData.openCostCurrencyManaged) {
267
- setSaveMessage(connectionDirty || advancedDirty
268
- ? 'Saved. CLI/Helm currency remains active; restart without that override to apply it. Restart Radar for other changes.'
269
- : 'Saved. CLI/Helm currency remains active; restart without that override to apply this setting.')
270
- } else {
271
- setSaveMessage(connectionDirty || advancedDirty
272
- ? costDirty
273
- ? 'Saved. Currency applied immediately; restart Radar for other changes.'
274
- : 'Saved. Restart Radar to apply.'
275
- : 'Saved. Applied immediately.')
276
- }
298
+ setSaveMessage('Saved. Restart Radar to apply.')
277
299
  return true
278
300
  } catch (err) {
279
301
  setSaveMessage(`Error: ${err}`)
280
302
  return false
281
303
  } finally {
304
+ configSavingRef.current = false
282
305
  setSaving(false)
283
306
  }
284
- }, [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])
285
348
 
286
349
  // AI prefs are client-side (localStorage) — commit the staged draft now.
287
350
  // setSelectedAgent clears model/effort (they're agent-specific), so set the
@@ -301,23 +364,48 @@ export function SettingsDialog({
301
364
  // what's saved.
302
365
  if (!configData) return
303
366
  setEditedConfig(configData.file)
367
+ setCostCredentialDirty(false)
368
+ setCostDraftReset((current) => current + 1)
304
369
  setSaveMessage(null)
305
370
  }, [configData])
306
371
 
372
+ const finishClose = useCallback(() => {
373
+ const action = pendingCloseActionRef.current
374
+ pendingCloseActionRef.current = null
375
+ onClose()
376
+ action?.()
377
+ }, [onClose])
378
+
307
379
  const handleSaveAndClose = useCallback(async () => {
308
380
  const ok = await saveConfig()
309
- if (ok) onClose()
310
- }, [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
+ }, [])
311
393
 
312
394
  // Close guard: a pending startup edit prompts an inline confirm rather than
313
395
  // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
314
396
  // drop it on close.
315
- const requestCloseRef = useRef<() => void>(() => {})
316
- requestCloseRef.current = () => {
317
- if (canEditConfig && configDirty) setConfirmingClose(true)
318
- 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()
319
402
  }
320
403
 
404
+ const navigateFromSettings = useCallback((resource: ResourceRef) => {
405
+ if (!onNavigateToResource) return
406
+ requestCloseRef.current(() => onNavigateToResource(resource))
407
+ }, [onNavigateToResource])
408
+
321
409
  useEffect(() => {
322
410
  if (!open) return
323
411
  const handleDocumentKeyDown = (event: KeyboardEvent) => {
@@ -361,14 +449,22 @@ export function SettingsDialog({
361
449
  { id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
362
450
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
363
451
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
364
- { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false },
365
- { 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 },
366
454
  { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
367
455
  { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
368
456
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
369
457
  ]
370
458
 
371
- 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
+ })
372
468
 
373
469
  return createPortal(
374
470
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -469,7 +565,7 @@ export function SettingsDialog({
469
565
  )}
470
566
 
471
567
  {/* Overview — status at a glance; the landing section */}
472
- <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel">
568
+ <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
473
569
  <div className="mb-1">
474
570
  <h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
475
571
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -482,7 +578,7 @@ export function SettingsDialog({
482
578
  </div>
483
579
 
484
580
  {/* My permissions — usable by everyone, rendered inline (no launcher) */}
485
- <div className={clsx(section !== 'perms' && 'hidden')} role="tabpanel">
581
+ <div className={clsx(section !== 'perms' && 'hidden')} role="tabpanel" inert={section !== 'perms' || undefined}>
486
582
  <div className="mb-1">
487
583
  <h3 className="text-base font-semibold text-theme-text-primary">My permissions</h3>
488
584
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -524,11 +620,11 @@ export function SettingsDialog({
524
620
  </div>
525
621
  </SectionPane>
526
622
 
527
- {/* Prometheus — live */}
623
+ {/* Metrics backend — live */}
528
624
  <SectionPane
529
625
  id="prometheus"
530
626
  active={section}
531
- title="Prometheus"
627
+ title="Metrics"
532
628
  caption="Applies immediately — no restart."
533
629
  live
534
630
  locked={!canEditConfig}
@@ -549,17 +645,42 @@ export function SettingsDialog({
549
645
  id="cost"
550
646
  active={section}
551
647
  title="Cost"
552
- caption={configData?.openCostCurrencyManaged
553
- ? 'Saved to config. A CLI or Helm override is currently active.'
554
- : 'Saved to config and applied immediately.'}
555
- live={!configData?.openCostCurrencyManaged}
648
+ caption="Choose where Radar gets cost data and how amounts are labeled."
649
+ live
556
650
  locked={!canEditConfig}
557
651
  >
558
652
  <CostSection
559
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}
560
662
  managed={configData?.openCostCurrencyManaged ?? false}
561
663
  effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
562
- 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
+ }}
563
684
  />
564
685
  </SectionPane>
565
686
 
@@ -604,7 +725,7 @@ export function SettingsDialog({
604
725
  {/* AI diagnose — self-saving, usable by everyone. Same heading block
605
726
  as every other tab; the body is the agent controls (when a CLI is
606
727
  installed) or an enable explainer (when not). */}
607
- <div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel">
728
+ <div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel" inert={section !== 'ai' || undefined}>
608
729
  <div className="mb-4">
609
730
  <h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
610
731
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
@@ -692,30 +813,54 @@ export function SettingsDialog({
692
813
  <div className="flex items-center justify-between gap-3 px-4 py-2.5">
693
814
  {confirmingClose ? (
694
815
  <>
695
- <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>
696
823
  <div className="flex items-center gap-2">
697
824
  <button
698
- onClick={() => setConfirmingClose(false)}
825
+ onClick={() => {
826
+ pendingCloseActionRef.current = null
827
+ setConfirmingClose(false)
828
+ }}
699
829
  disabled={saving}
700
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"
701
831
  >
702
832
  Keep editing
703
833
  </button>
704
834
  <button
705
- onClick={onClose}
835
+ onClick={finishClose}
706
836
  disabled={saving}
707
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"
708
838
  >
709
839
  Discard
710
840
  </button>
711
- <button
712
- onClick={handleSaveAndClose}
713
- disabled={saving}
714
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
715
- >
716
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
717
- Save
718
- </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
+ )}
719
864
  </div>
720
865
  </>
721
866
  ) : (
@@ -724,7 +869,7 @@ export function SettingsDialog({
724
869
  <Tooltip content="Discard unsaved changes and revert to the last saved values">
725
870
  <button
726
871
  onClick={discardChanges}
727
- disabled={saving || !configDirty}
872
+ disabled={saving || (!configDirty && !costIntegrationDirty)}
728
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"
729
874
  >
730
875
  <RotateCcw className="w-3.5 h-3.5" />
@@ -737,14 +882,31 @@ export function SettingsDialog({
737
882
  </span>
738
883
  )}
739
884
  </div>
740
- <button
741
- onClick={saveConfig}
742
- disabled={saving || !configDirty}
743
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
744
- >
745
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
746
- Save
747
- </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>
748
910
  </>
749
911
  )}
750
912
  </div>
@@ -848,7 +1010,7 @@ function SectionPane({
848
1010
  children: ReactNode
849
1011
  }) {
850
1012
  return (
851
- <div className={clsx(active !== id && 'hidden')} role="tabpanel">
1013
+ <div className={clsx(active !== id && 'hidden')} role="tabpanel" inert={active !== id || undefined}>
852
1014
  <div className="mb-4">
853
1015
  <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
854
1016
  {!locked && caption && <SectionCaption live={live}>{caption}</SectionCaption>}
@@ -911,6 +1073,7 @@ interface OverviewRow {
911
1073
  function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s: SettingsSectionId) => void }) {
912
1074
  const { data: cluster } = useClusterInfo()
913
1075
  const { data: prom } = usePrometheusStatus()
1076
+ const { data: cost } = useOpenCostSummary()
914
1077
  const { data: argo } = useArgoStatus(active)
915
1078
  const { data: capabilitiesData } = useCapabilities()
916
1079
  const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
@@ -924,6 +1087,16 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
924
1087
  diag.agents.find((a) => a.name === diag.selectedAgent)?.label ?? diag.agents[0]?.label
925
1088
  const mcpOn = capabilities.mcpEnabled
926
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
927
1100
 
928
1101
  const rows: OverviewRow[] = [
929
1102
  {
@@ -933,11 +1106,23 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
933
1106
  detail: cluster ? `Kubernetes ${cluster.kubernetesVersion} · ${cluster.nodeCount} nodes` : undefined,
934
1107
  },
935
1108
  {
936
- id: 'prometheus', icon: Activity, label: 'Prometheus',
1109
+ id: 'prometheus', icon: Activity, label: 'Metrics',
937
1110
  tone: prom?.connected ? 'ok' : prom?.available ? 'warn' : 'off',
938
1111
  value: prom?.connected ? 'Connected' : prom?.available ? 'Not reachable' : 'Not configured',
939
1112
  detail: prom?.connected ? prom.address : undefined,
940
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
+ },
941
1126
  {
942
1127
  id: 'argocd', icon: GitBranch, label: 'Argo CD',
943
1128
  tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
@@ -1001,10 +1186,10 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1001
1186
  <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
1002
1187
  <span className="text-sm text-theme-text-primary w-24 shrink-0 truncate">{row.label}</span>
1003
1188
  <OverviewStatus tone={row.tone} />
1004
- <div className="flex-1 min-w-0 flex items-baseline gap-1.5">
1005
- <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>
1006
1191
  {row.detail && (
1007
- <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>
1008
1193
  )}
1009
1194
  </div>
1010
1195
  {row.copyable && (
@@ -1196,40 +1381,405 @@ function TimelineSection({
1196
1381
 
1197
1382
  function CostSection({
1198
1383
  currency,
1384
+ source,
1385
+ url,
1386
+ clusterId,
1387
+ apiKeySet,
1388
+ sourceEnvManaged,
1389
+ sourceEnvError,
1390
+ draftDirty,
1391
+ resetVersion,
1199
1392
  managed,
1200
1393
  effectiveCurrency,
1201
- onChange,
1394
+ deploymentMode,
1395
+ settingsSaving,
1396
+ onNavigateToResource,
1397
+ onApplyCurrency,
1398
+ onChangeSource,
1399
+ onChangeUrl,
1400
+ onChangeClusterId,
1401
+ onCredentialDirtyChange,
1402
+ onApplied,
1202
1403
  }: {
1203
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
1204
1413
  managed: boolean
1205
1414
  effectiveCurrency: string
1206
- 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
1207
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
+
1208
1539
  return (
1209
- <div>
1210
- <label className="mb-1 block text-sm font-medium text-theme-text-primary">
1211
- Currency override
1212
- </label>
1213
- <p className="mb-1 text-xs text-theme-text-tertiary">
1214
- Choose a currency, or use Auto to read <code>currencyCode</code> or{' '}
1215
- <code>DISPLAY_CURRENCY</code> from an active OpenCost/Kubecost installation, then fall back
1216
- to USD. A custom Prometheus URL disables detection. Radar labels values but does not convert
1217
- them.
1218
- </p>
1219
- <SelectMenu
1220
- value={currency}
1221
- options={currencyOptionsForValue(currency)}
1222
- onChange={onChange}
1223
- ariaLabel="Currency override"
1224
- searchPlaceholder="Search currencies by name or code"
1225
- className="w-full"
1226
- />
1227
- {managed && (
1228
- <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
1229
- Currently managed by CLI or Helm: {effectiveCurrency || 'Auto'}. Saved changes apply
1230
- after Radar starts without that override.
1231
- </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>
1232
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>
1233
1783
  </div>
1234
1784
  )
1235
1785
  }
@@ -1336,6 +1886,18 @@ type ApplyState =
1336
1886
  | { status: 'unreachable'; error: string } // persisted, but the probe failed
1337
1887
  | { status: 'failed'; error: string } // request itself failed — nothing saved
1338
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
+
1339
1901
  type HeaderRow = { key: string; value: string }
1340
1902
 
1341
1903
  function PrometheusConfigField({
@@ -1427,7 +1989,8 @@ function PrometheusConfigField({
1427
1989
  Server URL
1428
1990
  </label>
1429
1991
  <p className="text-xs text-theme-text-tertiary mb-1">
1430
- 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.
1431
1994
  </p>
1432
1995
  <div className="flex items-center gap-2">
1433
1996
  <Input