@skyhook-io/radar-app 1.12.3 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/api/client.ts +38 -6
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +666 -103
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/index.css +11 -1
|
@@ -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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
// •
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
310
|
-
}, [saveConfig,
|
|
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
|
-
|
|
318
|
-
|
|
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: '
|
|
365
|
-
{ id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty:
|
|
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
|
|
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
|
-
{/*
|
|
623
|
+
{/* Metrics backend — live */}
|
|
528
624
|
<SectionPane
|
|
529
625
|
id="prometheus"
|
|
530
626
|
active={section}
|
|
531
|
-
title="
|
|
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=
|
|
553
|
-
|
|
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
|
-
|
|
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">
|
|
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={() =>
|
|
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={
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
<
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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: '
|
|
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
|
|
1005
|
-
<span className="text-sm text-theme-text-secondary
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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'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'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'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
|
|
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
|