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