@skyhook-io/radar-app 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +247 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +223 -0
  17. package/src/api/radarFeatures.test.ts +142 -0
  18. package/src/api/radarFeatures.ts +148 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +44 -6
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -2,17 +2,17 @@ import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
4
  Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug,
5
- Plus, Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
6
- LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins, EyeOff,
5
+ Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
6
+ LayoutDashboard, ChevronRight, AlertTriangle, Coins, EyeOff,
7
7
  type LucideIcon,
8
8
  } from 'lucide-react'
9
9
  import { clsx } from 'clsx'
10
10
  import { useQueryClient } from '@tanstack/react-query'
11
11
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
12
12
  import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
13
- import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
13
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
14
14
  import {
15
- useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus, useCapabilities,
15
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
16
16
  useOpenCostSummary,
17
17
  } from '../../api/client'
18
18
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
@@ -25,16 +25,25 @@ import { PrivacySection } from './PrivacySection'
25
25
  import { ConfigToggle, SubHeading } from './controls'
26
26
  import { useDiagnose } from '../diagnose/DiagnoseContext'
27
27
  import { currencyOptionsForValue } from './currency-options'
28
- import { versionUpdateURL } from '../../utils/version'
28
+ import { UpdateNotification } from '../ui/UpdateNotification'
29
29
  import {
30
30
  costConfigurationAction,
31
31
  costFreshnessLabel,
32
32
  costIntegrationUnavailableMessage,
33
33
  costSourceLabel,
34
+ costSourcePreferenceLabel,
35
+ COST_SOURCE_OPTIONS,
34
36
  } from '../cost/source'
35
- import { costSourceApplyLabel, prometheusHeadersFromRows, shouldOfferCostReview, shouldShowSettingsFooter } from './settings-state'
37
+ import { costSourceApplyLabel, pendingSectionLabels, pendingSections, shouldShowSettingsFooter } from './settings-state'
38
+ import { PrometheusConfigField } from './PrometheusConfigField'
39
+ import { LocalConnectionSettings, type IntegrationProfiles, type IntegrationKind } from './LocalConnectionSettings'
40
+ import { LocalIntegrationStatus } from './LocalIntegrationStatus'
41
+ import { LocalConfigurationDetails, SavedClusterConnections, PreviousIntegrationSettingsNotice } from './LocalConfigurationDetails'
42
+ import { useContextSwitch } from '../../context/ContextSwitchContext'
43
+ import { previousIntegrationSettingsKey, type IntegrationSettingsResponse } from '../../hooks/usePreviousIntegrationSettings'
36
44
  import type { SettingsSectionId } from './settings-state'
37
45
  import { OperatorManagedNotice } from './OperatorManagedNotice'
46
+ import { FormSaveActions } from './FormSaveActions'
38
47
  export type { SettingsSectionId } from './settings-state'
39
48
 
40
49
  function mcpEndpointUrl(): string {
@@ -62,8 +71,7 @@ interface Config {
62
71
  restoreLastDesktopContext?: boolean | null
63
72
  }
64
73
 
65
- interface ConfigResponse {
66
- management: 'local' | 'operator' | 'cloud'
74
+ interface ConfigResponse extends IntegrationSettingsResponse {
67
75
  file: Config
68
76
  effective: Config
69
77
  isDesktop: boolean
@@ -130,6 +138,8 @@ function normalizeStartup(c: Config) {
130
138
  }
131
139
  }
132
140
 
141
+ const localIntegrationCaption = 'Saved for this cluster · applies when you save, no restart.'
142
+
133
143
  export function SettingsDialog({
134
144
  open,
135
145
  onClose,
@@ -142,6 +152,39 @@ export function SettingsDialog({
142
152
  const { data: versionInfo } = useVersionCheck()
143
153
  const { canAtLeast } = useCloudRole()
144
154
  const capabilities = useCapabilitiesContext()
155
+ const { data: settingsCluster } = useClusterInfo()
156
+ const { isSwitching } = useContextSwitch()
157
+ const settingsApiBase = getApiBase()
158
+ const [prometheusCredentialDirty, setPrometheusCredentialDirty] = useState(false)
159
+ const [localDirty, setLocalDirty] = useState<Record<IntegrationKind, boolean>>({ metrics: false, argocd: false, cost: false })
160
+ const [discardGeneration, setDiscardGeneration] = useState(0)
161
+ const [localBusy, setLocalBusy] = useState<Record<IntegrationKind, boolean>>({ metrics: false, argocd: false, cost: false })
162
+ const [storageBusy, setStorageBusy] = useState(false)
163
+ const integrationBusy = Object.values(localBusy).some(Boolean)
164
+ const metricsBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, metrics: busy })), [])
165
+ const argoBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, argocd: busy })), [])
166
+ const costBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, cost: busy })), [])
167
+ const metricsDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, metrics: dirty })), [])
168
+ const argoDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, argocd: dirty })), [])
169
+ const costDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, cost: dirty })), [])
170
+ const [connectionsChangedAt, setConnectionsChangedAt] = useState(0)
171
+ const overviewHeading = useRef<HTMLHeadingElement>(null)
172
+ const previousNoticeDismissed = useCallback(() => {
173
+ // The notice unmounts with its focused button; keep focus in the dialog.
174
+ requestAnimationFrame(() => overviewHeading.current?.focus())
175
+ setConfigData(value => value?.integrationProfiles ? {
176
+ ...value,
177
+ integrationProfiles: Object.fromEntries(Object.entries(value.integrationProfiles).map(([kind, profile]) =>
178
+ [kind, profile.legacy ? { ...profile, legacy: { ...profile.legacy, noticeDismissed: true } } : profile])) as IntegrationProfiles,
179
+ } : value)
180
+ void queryClient.resetQueries({ queryKey: [previousIntegrationSettingsKey] })
181
+ }, [queryClient])
182
+ const connectionsChanged = useCallback((profiles: IntegrationProfiles) => {
183
+ setConnectionsChangedAt(Date.now())
184
+ setConfigData(value => value ? { ...value, integrationProfiles: profiles } : value)
185
+ void queryClient.resetQueries({ queryKey: [previousIntegrationSettingsKey] })
186
+ void queryClient.invalidateQueries({ predicate: query => typeof query.queryKey[0] === 'string' && /^(prometheus|opencost|gitops|argo)/.test(query.queryKey[0]) })
187
+ }, [queryClient])
145
188
 
146
189
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
147
190
  const operatorManaged = configData?.management === 'operator'
@@ -158,9 +201,10 @@ export function SettingsDialog({
158
201
  const configSavingRef = useRef(false)
159
202
  const costCurrencySavingRef = useRef(false)
160
203
  const pendingCloseActionRef = useRef<(() => void) | null>(null)
161
- const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
204
+ const argoStatusQuery = useArgoStatus(
162
205
  open && section === 'argocd'
163
206
  )
207
+ const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = argoStatusQuery
164
208
 
165
209
  // Local AI preferences save independently of the owner-gated server settings.
166
210
  const diag = useDiagnose()
@@ -172,11 +216,20 @@ export function SettingsDialog({
172
216
  effort: diag.effort,
173
217
  })
174
218
  const [aiSaved, setAiSaved] = useState(false)
175
- const aiDirty =
219
+ // Until the user edits, the draft follows the saved preferences: the agent
220
+ // list loads after Settings may already be open, and adopting its defaults
221
+ // is not an edit.
222
+ const [aiTouched, setAiTouched] = useState(false)
223
+ const aiEditable = aiAvailable && !diag.hosted && configData?.management !== 'operator'
224
+ const aiDirty = aiEditable && (
176
225
  aiDraft.agent !== diag.selectedAgent ||
177
226
  aiDraft.profile !== diag.profile ||
178
227
  aiDraft.model !== diag.model ||
179
- aiDraft.effort !== diag.effort
228
+ aiDraft.effort !== diag.effort)
229
+ useEffect(() => {
230
+ if (aiTouched) return
231
+ setAiDraft({ agent: diag.selectedAgent, profile: diag.profile, model: diag.model, effort: diag.effort })
232
+ }, [aiTouched, diag.selectedAgent, diag.profile, diag.model, diag.effort])
180
233
 
181
234
  // Per-bucket normalized dirty. Only startup fields participate; integration
182
235
  // fields apply live and never light up the footer.
@@ -198,17 +251,47 @@ export function SettingsDialog({
198
251
  (editedConfig.costSource ?? 'auto') !== (configData?.file.costSource ?? 'auto') ||
199
252
  (editedConfig.kubecostUrl ?? '').trim() !== (configData?.file.kubecostUrl ?? '').trim() ||
200
253
  (editedConfig.kubecostClusterId ?? '').trim() !== (configData?.file.kubecostClusterId ?? '').trim()
201
- const costIntegrationDirty = costSourceDirty || costCredentialDirty
254
+ const costIntegrationDirty = costSourceDirty || costCredentialDirty || localDirty.cost
202
255
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
203
256
  const connectionDirty = clusterDirty || serverDirty
204
257
  const advancedDirty = mcpDirty || timelineDirty
205
258
  const configDirty = configData != null && (connectionDirty || advancedDirty)
259
+ const prometheusDirty = prometheusCredentialDirty || localDirty.metrics || (editedConfig.prometheusUrl ?? '') !== (configData?.effective.prometheusUrl ?? '')
260
+ const pending = pendingSections({ prometheus: prometheusDirty, cost: costIntegrationDirty, argocd: localDirty.argocd, ai: aiDirty })
261
+ const integrationDirty = pending.some(item => item !== 'ai')
262
+ // Drafts that save through their own form, which "Save startup settings" leaves in place.
263
+ const formDirty = pending.length > 0
264
+ const reviewSection = pending.find(item => item !== section)
265
+ const settingsBusy = saving || costCurrencySaving || integrationBusy || storageBusy
266
+ const metricsDraft = useRef({ open, scope: `${settingsApiBase}:${settingsCluster?.context}`, dirty: false })
267
+ const [draftFrozen, setDraftFrozen] = useState(false)
268
+ const [reloadVersion, setReloadVersion] = useState(0)
269
+ const settingsScope = `${settingsApiBase}:${settingsCluster?.context}`
270
+ const targetChangedWithDraft = open && metricsDraft.current.open && metricsDraft.current.dirty && (isSwitching || metricsDraft.current.scope !== settingsScope)
271
+ const showLocalStatus = open && !draftFrozen && !targetChangedWithDraft && !isSwitching
272
+ useEffect(() => {
273
+ if (!open) {
274
+ setDraftFrozen(false)
275
+ metricsDraft.current = { open: false, scope: settingsScope, dirty: false }
276
+ return
277
+ }
278
+ if (targetChangedWithDraft) {
279
+ setDraftFrozen(true)
280
+ return
281
+ }
282
+ if (draftFrozen) return
283
+ metricsDraft.current = { open, scope: settingsScope, dirty: configData != null && (prometheusDirty || configDirty || costIntegrationDirty || localDirty.argocd || aiDirty) }
284
+ }, [open, settingsScope, targetChangedWithDraft, draftFrozen, configData, prometheusDirty, configDirty, costIntegrationDirty, localDirty.argocd, aiDirty])
206
285
 
207
286
  // Load config on open + snapshot AI prefs + pick a default section that's
208
287
  // actually accessible to the current identity.
209
288
  useEffect(() => {
210
289
  if (!open) return
290
+ if (targetChangedWithDraft || draftFrozen) return
291
+ const controller = new AbortController()
211
292
  setConfigData(null)
293
+ setPrometheusCredentialDirty(false)
294
+ setLocalDirty({ metrics: false, argocd: false, cost: false })
212
295
  setSaveMessage(null)
213
296
  setLoadError(null)
214
297
  setConfirmingClose(false)
@@ -216,28 +299,27 @@ export function SettingsDialog({
216
299
  setCostCredentialDirty(false)
217
300
  setCostDraftReset((current) => current + 1)
218
301
  setAiSaved(false)
219
- setAiDraft({
220
- agent: diag.selectedAgent,
221
- profile: diag.profile,
222
- model: diag.model,
223
- effort: diag.effort,
224
- })
225
- fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
302
+ setAiTouched(false)
303
+ if (isSwitching) return () => controller.abort()
304
+ fetch(apiUrl('/config'), { signal: controller.signal, credentials: getCredentialsMode(), headers: getAuthHeaders() })
226
305
  .then((res) => {
227
306
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
228
307
  return res.json()
229
308
  })
230
309
  .then((data: ConfigResponse) => {
310
+ if (controller.signal.aborted || getApiBase() !== settingsApiBase) return
231
311
  setConfigData(data)
232
312
  setEditedConfig({ ...data.file, prometheusUrl: data.effective.prometheusUrl })
233
313
  })
234
314
  .catch((err) => {
315
+ if (controller.signal.aborted) return
235
316
  console.warn('[settings] Failed to load config:', err)
236
317
  setLoadError('Failed to load configuration.')
237
318
  })
319
+ return () => controller.abort()
238
320
  // Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
239
321
  // eslint-disable-next-line react-hooks/exhaustive-deps
240
- }, [open])
322
+ }, [open, isSwitching, settingsCluster?.context, settingsApiBase, draftFrozen, reloadVersion])
241
323
 
242
324
  useEffect(() => {
243
325
  if (open) setSection(initialSection)
@@ -258,7 +340,7 @@ export function SettingsDialog({
258
340
  }, [])
259
341
 
260
342
  const saveConfig = useCallback(async (): Promise<boolean> => {
261
- if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
343
+ if (!configData || integrationBusy || draftFrozen || targetChangedWithDraft || configSavingRef.current || costCurrencySavingRef.current) return false
262
344
  configSavingRef.current = true
263
345
  setSaving(true)
264
346
  setSaveMessage(null)
@@ -302,7 +384,7 @@ export function SettingsDialog({
302
384
  configSavingRef.current = false
303
385
  setSaving(false)
304
386
  }
305
- }, [editedConfig, configData])
387
+ }, [editedConfig, configData, integrationBusy, draftFrozen, targetChangedWithDraft])
306
388
 
307
389
  const saveCostCurrency = useCallback(async (value: string): Promise<void> => {
308
390
  if (!configData) throw new Error('Radar configuration is not available')
@@ -352,20 +434,31 @@ export function SettingsDialog({
352
434
  diag.setProfile(aiDraft.profile)
353
435
  diag.setModel(aiDraft.model)
354
436
  diag.setEffort(aiDraft.effort)
437
+ setAiTouched(false)
355
438
  setAiSaved(true)
356
439
  }, [diag, aiDraft])
357
440
 
441
+ const discardAi = useCallback(() => {
442
+ setAiTouched(false)
443
+ setAiSaved(false)
444
+ }, [])
445
+
358
446
  const discardChanges = useCallback(() => {
359
447
  // Revert unsaved startup edits back to the last-saved values (non-
360
448
  // destructive). configData.file holds the committed config, including the
361
449
  // live integration fields, so restoring it drops drafts without touching
362
- // what's saved.
450
+ // what's saved. AI preferences don't depend on the config load.
451
+ if (settingsBusy || draftFrozen || targetChangedWithDraft) return
452
+ discardAi()
363
453
  if (!configData) return
364
454
  setEditedConfig({ ...configData.file, prometheusUrl: configData.effective.prometheusUrl })
365
455
  setCostCredentialDirty(false)
456
+ setPrometheusCredentialDirty(false)
457
+ setDiscardGeneration(value => value + 1)
458
+ setLocalDirty({ metrics: false, argocd: false, cost: false })
366
459
  setCostDraftReset((current) => current + 1)
367
460
  setSaveMessage(null)
368
- }, [configData])
461
+ }, [configData, settingsBusy, draftFrozen, targetChangedWithDraft, discardAi])
369
462
 
370
463
  const finishClose = useCallback(() => {
371
464
  const action = pendingCloseActionRef.current
@@ -379,23 +472,33 @@ export function SettingsDialog({
379
472
  if (ok) finishClose()
380
473
  }, [saveConfig, finishClose])
381
474
 
382
- const reviewCostDraft = useCallback(() => {
475
+ const reviewPendingDraft = () => {
476
+ if (!reviewSection) return
383
477
  setConfirmingClose(false)
384
- setSection('cost')
385
- requestAnimationFrame(() => {
386
- const applyButton = document.getElementById('cost-apply-source')
387
- applyButton?.scrollIntoView({ block: 'center' })
388
- applyButton?.focus()
389
- })
390
- }, [])
478
+ setSection(reviewSection)
479
+ focusIntegration.current = true
480
+ }
481
+ const focusIntegration = useRef(false)
482
+ useEffect(() => {
483
+ if (!focusIntegration.current) return
484
+ focusIntegration.current = false
485
+ const panel = dialogRef.current?.querySelector<HTMLElement>('[role="tabpanel"]:not([inert])')
486
+ panel?.querySelector<HTMLElement>('fieldset[tabindex], input, select, button')?.focus()
487
+ }, [section])
488
+
489
+ // Close guard: any pending draft prompts an inline confirm rather than
490
+ // silently discarding. AI drafts count for every user; the rest need owner access.
491
+ // Saving or discarding the last draft from its own tab answers the close
492
+ // prompt; a stale "Unsaved changes" would otherwise linger in the footer.
493
+ useEffect(() => {
494
+ if (confirmingClose && !configDirty && !formDirty) setConfirmingClose(false)
495
+ }, [confirmingClose, configDirty, formDirty])
391
496
 
392
- // Close guard: a pending startup edit prompts an inline confirm rather than
393
- // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
394
- // drop it on close.
395
497
  const requestCloseRef = useRef<(afterClose?: () => void) => void>(() => {})
396
498
  requestCloseRef.current = (afterClose) => {
499
+ if (settingsBusy) return
397
500
  pendingCloseActionRef.current = afterClose ?? null
398
- if (canEditConfig && (configDirty || costIntegrationDirty)) setConfirmingClose(true)
501
+ if ((canEditConfig && (configDirty || integrationDirty)) || aiDirty) setConfirmingClose(true)
399
502
  else finishClose()
400
503
  }
401
504
 
@@ -447,21 +550,20 @@ export function SettingsDialog({
447
550
  { id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
448
551
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
449
552
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
450
- { id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: false },
553
+ { id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: prometheusDirty },
451
554
  { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
452
- { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
555
+ { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: localDirty.argocd },
453
556
  { id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
454
557
  { id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
455
558
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
456
559
  ]
457
560
 
458
- const offerCostReview = shouldOfferCostReview(costIntegrationDirty, section)
459
561
  const showFooter = shouldShowSettingsFooter({
460
562
  canEditConfig,
461
563
  confirmingClose,
462
564
  configDirty,
463
- costIntegrationDirty,
464
- section,
565
+ integrationDirty: integrationDirty && (!configData?.integrationProfiles || (!!reviewSection && reviewSection !== 'ai')),
566
+ aiDirtyElsewhere: aiDirty && section !== 'ai',
465
567
  hasSaveMessage: Boolean(saveMessage),
466
568
  })
467
569
 
@@ -497,28 +599,30 @@ export function SettingsDialog({
497
599
  // Fixed height so the dialog doesn't jump when switching tabs — short
498
600
  // tabs leave breathing room, tall ones scroll inside the content pane.
499
601
  // max-h keeps it on-screen on short viewports.
500
- 'sm:rounded-xl sm:max-w-4xl sm:mx-4 sm:h-[660px] sm:max-h-[85vh]',
602
+ 'sm:rounded-xl sm:max-w-5xl sm:mx-6 sm:h-[800px] sm:max-h-[calc(100dvh-64px)]',
501
603
  TRANSITION_PANEL,
502
604
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
503
605
  )}
504
606
  style={overlayTransitionStyle(isOpen, 'dialog')}
505
607
  >
506
608
  {/* Header — spans both panes */}
507
- <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
508
- <div className="flex items-center gap-2">
509
- <Settings className="w-5 h-5 text-theme-text-secondary" />
510
- <div className="flex items-baseline gap-2">
609
+ <div className="relative flex items-center justify-between p-4 border-b border-theme-border shrink-0">
610
+ <div className="flex min-w-0 items-center gap-2">
611
+ <Settings className="w-5 h-5 shrink-0 text-theme-text-secondary" />
612
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
511
613
  <h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
512
- <span className="text-[11px] text-theme-text-tertiary">
513
- Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
614
+ <span className="break-all text-[11px] text-theme-text-tertiary">
615
+ Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion.replace(/^v/, '')}` : ''}
514
616
  <span className="text-theme-text-disabled"> · by Skyhook</span>
515
617
  </span>
516
-
618
+ <UpdateNotification placement="settings" />
517
619
  </div>
518
620
  </div>
519
621
  <button
520
622
  onClick={() => requestCloseRef.current()}
521
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
623
+ aria-label="Close settings"
624
+ disabled={settingsBusy}
625
+ className="shrink-0 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
522
626
  >
523
627
  <X className="w-5 h-5" />
524
628
  </button>
@@ -566,21 +670,35 @@ export function SettingsDialog({
566
670
  </div>
567
671
  )}
568
672
 
673
+ {draftFrozen && <div role="status" className="mb-4 space-y-2 rounded-md border border-theme-border p-3 text-sm text-theme-text-secondary">
674
+ <p>The cluster changed while you had unsaved edits. Your previous draft is still shown below, but cannot be applied to this cluster.</p>
675
+ <button type="button" disabled={isSwitching} className="text-xs text-accent-text hover:underline disabled:opacity-50" onClick={() => {
676
+ metricsDraft.current = { open, scope: settingsScope, dirty: false }
677
+ setDraftFrozen(false)
678
+ setReloadVersion((version) => version + 1)
679
+ }}>Discard draft and load current cluster</button>
680
+ </div>}
569
681
  {!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
570
682
  <p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
571
- ) : <>
683
+ ) : <div inert={draftFrozen || settingsBusy || undefined} className={draftFrozen ? 'opacity-60' : undefined}>
572
684
  {operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
573
685
  {/* Overview — status at a glance; the landing section */}
574
686
  <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
575
687
  <div className="mb-1">
576
- <h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
688
+ <h3 ref={overviewHeading} tabIndex={-1} className="text-base font-semibold text-theme-text-primary outline-none">Overview</h3>
577
689
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
578
690
  What this Radar is connected to right now — select a row for details.
579
691
  </p>
580
692
  </div>
693
+ {configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
694
+ <PreviousIntegrationSettingsNotice profiles={configData.integrationProfiles} onNavigate={setSection} onDismissed={previousNoticeDismissed} />
695
+ )}
581
696
  <div className="mt-3">
582
697
  <OverviewPanel active={section === 'overview'} onNavigate={setSection} />
583
698
  </div>
699
+ {configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
700
+ <LocalConfigurationDetails onNavigate={setSection} />
701
+ )}
584
702
  </div>
585
703
 
586
704
  {/* My permissions — usable by everyone, rendered inline (no launcher) */}
@@ -603,7 +721,7 @@ export function SettingsDialog({
603
721
  active={section}
604
722
  title="Connection"
605
723
  managed={operatorManaged ? <OperatorSettingsSummary section="connection" config={configData} /> : undefined}
606
- caption="Takes effect on next launch."
724
+ caption="Kubeconfig and server changes take effect on next launch."
607
725
  locked={!canEditConfig}
608
726
  >
609
727
  <div className="space-y-4">
@@ -625,6 +743,15 @@ export function SettingsDialog({
625
743
  onChange={updateConfigField}
626
744
  />
627
745
  </div>
746
+ {configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
747
+ <SavedClusterConnections
748
+ key={settingsScope}
749
+ active={open && section === 'connection' && !draftFrozen && !isSwitching}
750
+ profiles={configData.integrationProfiles}
751
+ onChange={connectionsChanged}
752
+ onBusyChange={setStorageBusy}
753
+ />
754
+ )}
628
755
  </SectionPane>
629
756
 
630
757
  {/* Metrics backend — live */}
@@ -633,11 +760,15 @@ export function SettingsDialog({
633
760
  active={section}
634
761
  title="Metrics"
635
762
  managed={operatorManaged ? <OperatorSettingsSummary section="prometheus" config={configData} /> : undefined}
636
- caption="Connect and manage your metrics backend."
763
+ caption={configData?.integrationProfiles ? localIntegrationCaption : 'Connect and manage your metrics backend.'}
637
764
  live
638
765
  locked={!canEditConfig}
639
766
  >
640
- <PrometheusConfigField
767
+ {configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.metrics.target)}`} kind="metrics" profiles={configData.integrationProfiles} onChange={connectionsChanged} onDirtyChange={metricsDirtyChange} onBusyChange={metricsBusyChange}
768
+ status={showLocalStatus && section === 'prometheus' && !['error', 'target_changed'].includes(configData.integrationProfiles.metrics.state) ? <LocalIntegrationStatus kind="metrics" profile={configData.integrationProfiles.metrics} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : <PrometheusConfigField
769
+ key={`${settingsApiBase}:${discardGeneration}`}
770
+ onBusyChange={metricsBusyChange}
771
+ onDirtyChange={setPrometheusCredentialDirty}
641
772
  local={deploymentMode === 'local'}
642
773
  value={editedConfig.prometheusUrl ?? ''}
643
774
  configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
@@ -645,7 +776,8 @@ export function SettingsDialog({
645
776
  headersManaged={configData?.prometheusHeadersManaged === true}
646
777
  urlFromFlag={configData?.prometheusUrlFromFlag === true}
647
778
  onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
648
- onApplied={(url) =>
779
+ onApplied={(url) => {
780
+ setEditedConfig((prev) => ({ ...prev, prometheusUrl: url || undefined }))
649
781
  setConfigData((prev) =>
650
782
  prev ? {
651
783
  ...prev,
@@ -653,8 +785,8 @@ export function SettingsDialog({
653
785
  effective: { ...prev.effective, prometheusUrl: url || undefined },
654
786
  } : prev
655
787
  )
656
- }
657
- />
788
+ }}
789
+ />}
658
790
  </SectionPane>
659
791
 
660
792
  <SectionPane
@@ -662,11 +794,13 @@ export function SettingsDialog({
662
794
  active={section}
663
795
  title="Cost"
664
796
  managed={operatorManaged ? <OperatorSettingsSummary section="cost" config={configData} /> : undefined}
665
- caption="Choose where Radar gets cost data and how amounts are labeled."
797
+ caption={configData?.integrationProfiles ? `${localIntegrationCaption} Display currency applies to all clusters.` : 'Choose where Radar gets cost data and how amounts are labeled.'}
666
798
  live
667
799
  locked={!canEditConfig}
668
800
  >
669
801
  <CostSection
802
+ integration={configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.cost.target)}`} kind="cost" profiles={configData.integrationProfiles} onChange={connectionsChanged} onDirtyChange={costDirtyChange} onBusyChange={costBusyChange}
803
+ status={showLocalStatus && section === 'cost' && !['error', 'target_changed'].includes(configData.integrationProfiles.cost.state) ? <LocalIntegrationStatus kind="cost" profile={configData.integrationProfiles.cost} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : undefined}
670
804
  currency={editedConfig.opencostCurrency ?? ''}
671
805
  source={editedConfig.costSource ?? 'auto'}
672
806
  url={editedConfig.kubecostUrl ?? ''}
@@ -679,7 +813,7 @@ export function SettingsDialog({
679
813
  managed={configData?.openCostCurrencyManaged ?? false}
680
814
  effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
681
815
  deploymentMode={deploymentMode}
682
- settingsSaving={saving}
816
+ settingsSaving={settingsBusy}
683
817
  onNavigateToResource={navigateFromSettings}
684
818
  onApplyCurrency={saveCostCurrency}
685
819
  onChangeSource={(value) => updateConfigField('costSource', value)}
@@ -707,11 +841,12 @@ export function SettingsDialog({
707
841
  active={section}
708
842
  title="Argo CD"
709
843
  managed={operatorManaged ? <OperatorSettingsSummary section="argocd" config={configData} /> : undefined}
710
- caption="Applies immediately — no restart."
844
+ caption={configData?.integrationProfiles ? localIntegrationCaption : 'Applies immediately — no restart.'}
711
845
  live
712
846
  locked={!canEditConfig}
713
847
  >
714
- <ArgoCDConfigField
848
+ {configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.argocd.target)}`} kind="argocd" profiles={configData.integrationProfiles} cliSession={configData.argoCdCliSession} onChange={connectionsChanged} onDirtyChange={argoDirtyChange} onBusyChange={argoBusyChange}
849
+ status={showLocalStatus && section === 'argocd' && !['error', 'target_changed'].includes(configData.integrationProfiles.argocd.state) ? <LocalIntegrationStatus kind="argocd" profile={configData.integrationProfiles.argocd} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : <ArgoCDConfigField
715
850
  url={editedConfig.argoCdUrl ?? ''}
716
851
  insecureTls={editedConfig.argoCdInsecureTls ?? false}
717
852
  tokenSet={configData?.argoCdTokenSet ?? false}
@@ -746,7 +881,7 @@ export function SettingsDialog({
746
881
  predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('gitops-'),
747
882
  })
748
883
  }}
749
- />
884
+ />}
750
885
  </SectionPane>
751
886
 
752
887
  {/* AI investigations — self-saving, usable by everyone. Same heading block
@@ -773,26 +908,19 @@ export function SettingsDialog({
773
908
  draft={aiDraft}
774
909
  onChange={(patch) => {
775
910
  setAiDraft((d) => ({ ...d, ...patch }))
911
+ setAiTouched(true)
776
912
  setAiSaved(false)
777
913
  }}
778
914
  onHistoryCleared={diag.refreshRuns}
779
915
  />
780
- {!diag.hosted && (
781
- <div className="flex items-center justify-end gap-3">
782
- {aiSaved && !aiDirty && (
783
- <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
784
- <Check className="w-3 h-3" />
785
- Saved
786
- </span>
787
- )}
788
- <button
789
- onClick={saveAi}
790
- disabled={!aiDirty}
791
- className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
792
- >
793
- Save
794
- </button>
795
- </div>
916
+ {aiEditable && (
917
+ <FormSaveActions
918
+ dirty={aiDirty}
919
+ busy={false}
920
+ onSave={saveAi}
921
+ onDiscard={discardAi}
922
+ feedback={aiSaved ? { tone: 'success', message: 'Saved' } : undefined}
923
+ />
796
924
  )}
797
925
  </div>
798
926
  ) : (
@@ -834,120 +962,118 @@ export function SettingsDialog({
834
962
  />
835
963
  </div>
836
964
  </SectionPane>
837
- </>}
965
+ </div>}
838
966
  </div>
839
967
  </div>
840
968
 
841
- {/* Footer — owner-gated persisted config. AI self-saves and integrations
842
- apply separately. Shown whenever an edit is pending (any section),
843
- while confirming a close, or briefly after a save. */}
844
- <div
845
- className={clsx(
846
- 'shrink-0 overflow-hidden transition-all duration-200 ease-out',
847
- showFooter ? 'max-h-24 opacity-100 border-t border-theme-border' : 'max-h-0 opacity-0 pointer-events-none'
848
- )}
849
- >
850
- <div className="flex items-center justify-between gap-3 px-4 py-2.5">
851
- {confirmingClose ? (
852
- <>
853
- <span className="text-xs text-theme-text-secondary">
854
- {costIntegrationDirty && configDirty
855
- ? 'Cost source changes are not applied, and other changes are unsaved.'
856
- : costIntegrationDirty
857
- ? 'Cost source changes have not been applied.'
969
+ {/* Startup settings only apply on the next launch, so they save here
970
+ instead of in their tabs; drafts left in other tabs surface here
971
+ too, so closing can't drop them unnoticed. */}
972
+ <Collapse open={showFooter} className="shrink-0">
973
+ <div className="border-t border-theme-border">
974
+ <div className="flex items-center justify-between gap-3 px-4 py-2.5">
975
+ {confirmingClose ? (
976
+ <>
977
+ <div className="space-y-1 text-xs text-theme-text-secondary">
978
+ <p>
979
+ {formDirty
980
+ ? `${pending.map(item => pendingSectionLabels[item]).join(', ')} changes are not saved.${configDirty ? ' Startup settings are unsaved.' : ''}`
858
981
  : 'Unsaved changes.'}
859
- </span>
860
- <div className="flex items-center gap-2">
861
- <button
862
- onClick={() => {
863
- pendingCloseActionRef.current = null
864
- setConfirmingClose(false)
865
- }}
866
- disabled={saving}
867
- 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"
868
- >
869
- Keep editing
870
- </button>
871
- <button
872
- onClick={finishClose}
873
- disabled={saving}
874
- 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"
875
- >
876
- Discard
877
- </button>
878
- {offerCostReview && (
879
- <button
880
- onClick={reviewCostDraft}
881
- className={clsx(
882
- 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
883
- configDirty
884
- ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
885
- : 'btn-brand',
886
- )}
887
- >
888
- Review Cost
889
- </button>
890
- )}
891
- {configDirty && (
892
- <button
893
- onClick={costIntegrationDirty ? saveConfig : handleSaveAndClose}
894
- disabled={saving || costCurrencySaving}
895
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
896
- >
897
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
898
- {costIntegrationDirty ? 'Save other changes' : 'Save'}
899
- </button>
900
- )}
901
- </div>
902
- </>
903
- ) : (
904
- <>
905
- <div className="flex items-center gap-2">
906
- <Tooltip content="Discard unsaved changes and revert to the last saved values">
907
- <button
908
- onClick={discardChanges}
909
- disabled={saving || (!configDirty && !costIntegrationDirty)}
910
- 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"
911
- >
912
- <RotateCcw className="w-3.5 h-3.5" />
913
- Discard changes
914
- </button>
915
- </Tooltip>
916
- {saveMessage && (
917
- <span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
918
- {saveMessage}
919
- </span>
920
- )}
921
- </div>
922
- <div className="flex items-center gap-2">
923
- {offerCostReview && (
982
+ </p>
983
+ {saveMessage && <p role="status" className={saveMessage.startsWith('Error') ? 'text-semantic-error' : undefined}>{saveMessage}</p>}
984
+ </div>
985
+ <div className="flex items-center gap-2">
924
986
  <button
925
- onClick={reviewCostDraft}
926
- className={clsx(
927
- 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
928
- configDirty
929
- ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
930
- : 'btn-brand',
931
- )}
987
+ onClick={() => {
988
+ pendingCloseActionRef.current = null
989
+ setConfirmingClose(false)
990
+ }}
991
+ disabled={settingsBusy}
992
+ 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"
932
993
  >
933
- Review Cost
994
+ Keep editing
934
995
  </button>
935
- )}
936
- {configDirty && (
937
996
  <button
938
- onClick={saveConfig}
939
- disabled={saving || costCurrencySaving}
940
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
997
+ onClick={finishClose}
998
+ disabled={settingsBusy}
999
+ 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"
941
1000
  >
942
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
943
- Save
1001
+ Discard
944
1002
  </button>
945
- )}
946
- </div>
947
- </>
948
- )}
1003
+ {reviewSection && (
1004
+ <button
1005
+ onClick={reviewPendingDraft}
1006
+ className={clsx(
1007
+ 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
1008
+ configDirty
1009
+ ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
1010
+ : 'btn-brand',
1011
+ )}
1012
+ >
1013
+ Review {pendingSectionLabels[reviewSection]}
1014
+ </button>
1015
+ )}
1016
+ {configDirty && canEditConfig && (
1017
+ <button
1018
+ onClick={formDirty ? saveConfig : handleSaveAndClose}
1019
+ disabled={settingsBusy || draftFrozen || targetChangedWithDraft}
1020
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
1021
+ >
1022
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
1023
+ Save startup settings
1024
+ </button>
1025
+ )}
1026
+ </div>
1027
+ </>
1028
+ ) : (
1029
+ <>
1030
+ <div className="flex items-center gap-2">
1031
+ {(!configData?.integrationProfiles || configDirty || !!reviewSection) && <Tooltip content="Discard unsaved changes and revert to the last saved values">
1032
+ <button
1033
+ onClick={discardChanges}
1034
+ disabled={settingsBusy || draftFrozen || targetChangedWithDraft || (!configDirty && !formDirty)}
1035
+ 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"
1036
+ >
1037
+ <RotateCcw className="w-3.5 h-3.5" />
1038
+ {formDirty && (configDirty || pending.length > 1) ? 'Discard all changes' : 'Discard changes'}
1039
+ </button>
1040
+ </Tooltip>}
1041
+ {saveMessage && (
1042
+ <span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
1043
+ {saveMessage}
1044
+ </span>
1045
+ )}
1046
+ </div>
1047
+ <div className="flex items-center gap-2">
1048
+ {reviewSection && (
1049
+ <button
1050
+ onClick={reviewPendingDraft}
1051
+ className={clsx(
1052
+ 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
1053
+ configDirty
1054
+ ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
1055
+ : 'btn-brand',
1056
+ )}
1057
+ >
1058
+ Review {pendingSectionLabels[reviewSection]}
1059
+ </button>
1060
+ )}
1061
+ {configDirty && canEditConfig && (
1062
+ <button
1063
+ onClick={saveConfig}
1064
+ disabled={settingsBusy || draftFrozen || targetChangedWithDraft}
1065
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
1066
+ >
1067
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
1068
+ Save startup settings
1069
+ </button>
1070
+ )}
1071
+ </div>
1072
+ </>
1073
+ )}
1074
+ </div>
949
1075
  </div>
950
- </div>
1076
+ </Collapse>
951
1077
  </div>
952
1078
  </div>,
953
1079
  document.body
@@ -1096,9 +1222,9 @@ function OperatorSettingsSummary({ section, config }: { section: SettingsSection
1096
1222
  case 'cost':
1097
1223
  rows = [
1098
1224
  ['Status', costError ? 'Status unavailable' : !cost ? 'Checking…' : cost.available ? 'Available' : 'Unavailable'],
1099
- ['Source preference', c.costSource || 'Auto'],
1225
+ ['Source preference', costSourcePreferenceLabel(c.costSource)],
1100
1226
  ['Kubecost endpoint', c.kubecostUrl || 'Auto-discovery'],
1101
- ['Cluster ID', c.kubecostClusterId || 'Auto-detected when available'],
1227
+ ['Kubecost cluster ID', c.kubecostClusterId || 'Detected automatically'],
1102
1228
  ['API key', config.kubecostApiKeySet ? 'Configured' : 'None configured'],
1103
1229
  ['Currency override', c.opencostCurrency || 'Automatic'],
1104
1230
  ]
@@ -1168,9 +1294,6 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1168
1294
  const { data: prom } = usePrometheusStatus()
1169
1295
  const { data: cost } = useOpenCostSummary()
1170
1296
  const { data: argo } = useArgoStatus(active)
1171
- const { data: capabilitiesData } = useCapabilities()
1172
- const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
1173
- const { data: version } = useVersionCheck()
1174
1297
  const capabilities = useCapabilitiesContext()
1175
1298
  const diag = useDiagnose()
1176
1299
  const [copied, setCopied] = useState(false)
@@ -1201,8 +1324,8 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1201
1324
  {
1202
1325
  id: 'prometheus', icon: Activity, label: 'Metrics',
1203
1326
  tone: prom?.connected ? 'ok' : prom?.discovering ? 'unknown' : prom?.error ? 'warn' : 'off',
1204
- value: prom?.connected ? 'Connected' : prom?.discovering ? 'Discovering…' : prom?.error ? 'Not connected' : 'Not configured',
1205
- detail: prom?.connected ? prom.address : prom?.discovering ? undefined : prom?.error,
1327
+ value: prom?.connected ? 'Connected' : prom?.discovering ? 'Discovering…' : 'Not connected',
1328
+ detail: prom?.connected ? prom.address : prom?.discovering ? undefined : prom?.error ? 'Open Metrics for connection details.' : undefined,
1206
1329
  },
1207
1330
  {
1208
1331
  id: costConfigurationAction(cost?.reason).section, icon: Coins, label: 'Cost',
@@ -1218,12 +1341,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1218
1341
  },
1219
1342
  {
1220
1343
  id: 'argocd', icon: GitBranch, label: 'Argo CD',
1221
- tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
1222
- // Configured-but-not-connected is often a permanently rejected/expired
1223
- // token, not a transient reconnect — "Not reachable" matches Prometheus and
1224
- // doesn't imply it will recover on its own.
1225
- value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : argo?.configured ? 'Not reachable' : 'Not connected',
1226
- detail: argo?.connected ? argo.address : argo?.reason,
1344
+ tone: argo?.connected ? 'ok' : argo?.configured || argo?.reason ? 'warn' : 'off',
1345
+ value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : 'Not connected',
1346
+ detail: argo?.connected ? argo.address : argo?.reason ? 'Open Argo CD for connection details.' : undefined,
1227
1347
  },
1228
1348
  {
1229
1349
  id: 'advanced', icon: Zap, label: 'MCP',
@@ -1248,22 +1368,6 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1248
1368
 
1249
1369
  return (
1250
1370
  <div className="space-y-4">
1251
- {version?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud' && (
1252
- <a
1253
- href={versionUpdateURL(deploymentMode, version.releaseUrl)}
1254
- target="_blank"
1255
- rel="noreferrer"
1256
- 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"
1257
- >
1258
- <Download className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
1259
- <span className="flex-1 text-theme-text-primary">
1260
- Radar {version.latestVersion} is available
1261
- <span className="text-theme-text-tertiary"> — you're on {version.currentVersion}</span>
1262
- </span>
1263
- <ExternalLink className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
1264
- </a>
1265
- )}
1266
-
1267
1371
  <div className="rounded-md border border-theme-border divide-y divide-theme-border-subtle overflow-hidden">
1268
1372
  {rows.map((row) => {
1269
1373
  const Icon = row.icon
@@ -1274,10 +1378,10 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1274
1378
  tabIndex={0}
1275
1379
  onClick={() => onNavigate(row.id)}
1276
1380
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(row.id) } }}
1277
- className="group flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-theme-hover transition-colors"
1381
+ className="group flex items-center gap-3 px-3 py-1.5 cursor-pointer hover:bg-theme-hover transition-colors"
1278
1382
  >
1279
1383
  <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
1280
- <span className="text-sm text-theme-text-primary w-24 shrink-0 truncate">{row.label}</span>
1384
+ <span className="text-sm text-theme-text-primary w-32 shrink-0">{row.label}</span>
1281
1385
  <OverviewStatus tone={row.tone} />
1282
1386
  <div className="flex min-w-0 flex-1 flex-col gap-0.5">
1283
1387
  <span className="truncate text-sm text-theme-text-secondary">{row.value}</span>
@@ -1474,6 +1578,7 @@ function TimelineSection({
1474
1578
  }
1475
1579
 
1476
1580
  function CostSection({
1581
+ integration,
1477
1582
  currency,
1478
1583
  source,
1479
1584
  url,
@@ -1495,6 +1600,7 @@ function CostSection({
1495
1600
  onCredentialDirtyChange,
1496
1601
  onApplied,
1497
1602
  }: {
1603
+ integration?: ReactNode
1498
1604
  currency: string
1499
1605
  source: 'auto' | 'prometheus' | 'kubecost'
1500
1606
  url: string
@@ -1632,7 +1738,12 @@ function CostSection({
1632
1738
 
1633
1739
  return (
1634
1740
  <div className="space-y-5">
1635
- {sourceEnvManaged && (
1741
+ {integration ? (
1742
+ <div className="space-y-4">
1743
+ <SubHeading>This cluster</SubHeading>
1744
+ {integration}
1745
+ </div>
1746
+ ) : <>{sourceEnvManaged && (
1636
1747
  <div id="cost-source-managed" className={clsx(
1637
1748
  'rounded-md border p-3',
1638
1749
  sourceEnvError
@@ -1665,11 +1776,7 @@ function CostSection({
1665
1776
  <SelectMenu
1666
1777
  id="cost-source"
1667
1778
  value={source}
1668
- options={[
1669
- { value: 'auto', label: 'Automatic (recommended)' },
1670
- { value: 'prometheus', label: 'OpenCost metrics only' },
1671
- { value: 'kubecost', label: 'Kubecost only' },
1672
- ]}
1779
+ options={COST_SOURCE_OPTIONS}
1673
1780
  onChange={(value) => { onChangeSource(value as typeof source); setApply({ status: 'idle' }) }}
1674
1781
  disabled={sourceEnvManaged}
1675
1782
  className="w-full"
@@ -1698,7 +1805,7 @@ function CostSection({
1698
1805
  <div id="cost-advanced-connection" className="pt-3">
1699
1806
  <div className="space-y-3 rounded-md border border-theme-border-subtle bg-theme-base/60 p-3">
1700
1807
  <div>
1701
- <label htmlFor="cost-kubecost-url" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost URL</label>
1808
+ <label htmlFor="cost-kubecost-url" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost Aggregator URL</label>
1702
1809
  <p id="cost-kubecost-url-help" className="mb-1 text-xs text-theme-text-tertiary">
1703
1810
  Leave blank when Kubecost runs in this cluster. Enter the central Aggregator URL
1704
1811
  for an agent-only or federated setup.
@@ -1714,7 +1821,7 @@ function CostSection({
1714
1821
  />
1715
1822
  </div>
1716
1823
  <div>
1717
- <label htmlFor="cost-kubecost-cluster-id" className="mb-1 block text-sm font-medium text-theme-text-primary">Cluster ID</label>
1824
+ <label htmlFor="cost-kubecost-cluster-id" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost cluster ID</label>
1718
1825
  <p id="cost-kubecost-cluster-id-help" className="mb-1 text-xs text-theme-text-tertiary">
1719
1826
  Usually detected automatically. Set it only if detection fails or the Kubecost
1720
1827
  server contains data for more than one cluster. Use the <code>CLUSTER_ID</code>{' '}
@@ -1726,7 +1833,7 @@ function CostSection({
1726
1833
  value={clusterId}
1727
1834
  onChange={(event) => { onChangeClusterId(event.target.value); setApply({ status: 'idle' }) }}
1728
1835
  disabled={sourceEnvManaged}
1729
- placeholder="Auto-detect CLUSTER_ID"
1836
+ placeholder="Detected automatically"
1730
1837
  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"
1731
1838
  />
1732
1839
  </div>
@@ -1758,17 +1865,17 @@ function CostSection({
1758
1865
  autoComplete="off"
1759
1866
  spellCheck={false}
1760
1867
  disabled={sourceEnvManaged}
1761
- placeholder={apiKeySet && !apiKeyCleared ? 'Configured — enter to replace' : 'Optional API key'}
1868
+ placeholder={apiKeySet && !apiKeyCleared ? 'Saved value' : 'Optional API key'}
1762
1869
  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"
1763
1870
  />
1764
1871
  {apiKeySet && !apiKeyCleared && !sourceEnvManaged && (
1765
1872
  <button
1766
1873
  type="button"
1767
- aria-label="Clear saved Kubecost API key"
1874
+ aria-label="Remove saved Kubecost API key"
1768
1875
  onClick={() => { setApiKey(''); setApiKeyTouched(false); setApiKeyCleared(true); setApply({ status: 'idle' }) }}
1769
1876
  className="px-2 py-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
1770
1877
  >
1771
- Clear
1878
+ Remove
1772
1879
  </button>
1773
1880
  )}
1774
1881
  </div>
@@ -1834,45 +1941,52 @@ function CostSection({
1834
1941
  </div>}
1835
1942
  </div>
1836
1943
 
1837
- <div className="rounded-lg border border-theme-border bg-theme-base/40 p-4">
1838
- <label htmlFor="cost-currency" className="block text-sm font-semibold text-theme-text-primary">
1839
- Display currency
1840
- </label>
1841
- <p id="cost-currency-help" className="mb-1 mt-0.5 text-xs text-theme-text-tertiary">
1842
- Auto uses the currency reported by the active cost source, or USD when unavailable.
1843
- Overrides relabel amounts; Radar does not convert them.
1844
- </p>
1845
- <SelectMenu
1846
- id="cost-currency"
1847
- value={currencyDraft}
1848
- options={currencyOptionsForValue(currencyDraft)}
1849
- onChange={(value) => { void saveCurrency(value) }}
1850
- ariaLabel="Display currency"
1851
- ariaDescribedBy="cost-currency-help"
1852
- searchPlaceholder="Search currencies by name or code"
1853
- disabled={settingsSaving || currencySave.status === 'saving'}
1854
- className="w-full"
1855
- />
1856
- {currencySave.status === 'saving' && (
1857
- <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-theme-text-tertiary">
1858
- <Loader2 className="h-3 w-3 animate-spin" /> Saving…
1859
- </p>
1860
- )}
1861
- {currencySave.status === 'saved' && (
1862
- <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1863
- <Check className="h-3 w-3" /> {managed ? 'Saved for when the CLI or Helm override is removed' : 'Saved'}
1864
- </p>
1865
- )}
1866
- {currencySave.status === 'failed' && (
1867
- <p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400/80">
1868
- Could not save: {currencySave.error}
1869
- </p>
1870
- )}
1871
- {managed && (
1872
- <p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
1873
- CLI or Helm currently sets {effectiveCurrency || 'Auto'}; that override stays active until it is removed.
1944
+ </>}
1945
+ <div className={integration ? 'space-y-4 border-t border-theme-border-subtle pt-4' : 'rounded-lg border border-theme-border bg-theme-base/40 p-4'}>
1946
+ {integration && <SubHeading>All clusters</SubHeading>}
1947
+ <div>
1948
+ <label htmlFor="cost-currency" className="block text-sm font-semibold text-theme-text-primary">
1949
+ Display currency
1950
+ </label>
1951
+ {integration
1952
+ ? <p className="mt-1 text-xs text-theme-text-secondary">Saved automatically.</p>
1953
+ : deploymentMode === 'local' && <p className="mt-1 text-xs text-theme-text-secondary">Display preference · all local clusters. Saved automatically.</p>}
1954
+ <p id="cost-currency-help" className="mb-1 mt-0.5 text-xs text-theme-text-tertiary">
1955
+ Automatic uses the currency reported by the active cost source, or USD when unavailable.
1956
+ Overrides relabel amounts; Radar does not convert them.
1874
1957
  </p>
1875
- )}
1958
+ <SelectMenu
1959
+ id="cost-currency"
1960
+ value={currencyDraft}
1961
+ options={currencyOptionsForValue(currencyDraft)}
1962
+ onChange={(value) => { void saveCurrency(value) }}
1963
+ ariaLabel="Display currency"
1964
+ ariaDescribedBy="cost-currency-help"
1965
+ searchPlaceholder="Search currencies by name or code"
1966
+ disabled={settingsSaving || currencySave.status === 'saving'}
1967
+ className="w-full"
1968
+ />
1969
+ {currencySave.status === 'saving' && (
1970
+ <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-theme-text-tertiary">
1971
+ <Loader2 className="h-3 w-3 animate-spin" /> Saving…
1972
+ </p>
1973
+ )}
1974
+ {currencySave.status === 'saved' && (
1975
+ <p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1976
+ <Check className="h-3 w-3" /> {managed ? 'Saved for when the CLI or Helm override is removed' : 'Saved'}
1977
+ </p>
1978
+ )}
1979
+ {currencySave.status === 'failed' && (
1980
+ <p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400/80">
1981
+ Could not save: {currencySave.error}
1982
+ </p>
1983
+ )}
1984
+ {managed && (
1985
+ <p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
1986
+ CLI or Helm currently sets {effectiveCurrency || 'Automatic'}; that override stays active until it is removed.
1987
+ </p>
1988
+ )}
1989
+ </div>
1876
1990
  </div>
1877
1991
  </div>
1878
1992
  )
@@ -1973,12 +2087,6 @@ function MCPSection({
1973
2087
  // confirm reachability inline. onApplied notifies the parent so the footer's
1974
2088
  // last-committed snapshot stays in sync (see saveConfig). No EffectiveHint here —
1975
2089
  // a restart-diff hint would contradict the whole point of applying live.
1976
- type ApplyState =
1977
- | { status: 'idle' }
1978
- | { status: 'applying' }
1979
- | { status: 'connected'; address: string }
1980
- | { status: 'unreachable'; error: string } // persisted, but the probe failed
1981
- | { status: 'failed'; error: string } // request itself failed — nothing saved
1982
2090
 
1983
2091
  type CostApplyState =
1984
2092
  | { status: 'idle' }
@@ -1992,253 +2100,6 @@ type CurrencySaveState =
1992
2100
  | { status: 'saved' }
1993
2101
  | { status: 'failed'; error: string }
1994
2102
 
1995
- type HeaderRow = { key: string; value: string }
1996
-
1997
- function PrometheusConfigField({
1998
- local,
1999
- value,
2000
- onChange,
2001
- configuredHeaderKeys,
2002
- serverManaged,
2003
- headersManaged,
2004
- urlFromFlag,
2005
- onApplied,
2006
- }: {
2007
- local: boolean
2008
- value: string
2009
- onChange: (value: string) => void
2010
- configuredHeaderKeys: string[]
2011
- serverManaged: boolean
2012
- headersManaged: boolean
2013
- urlFromFlag: boolean
2014
- onApplied?: (url: string) => void
2015
- }) {
2016
- const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
2017
- // null = not editing headers (preserve what's stored). A non-null array means
2018
- // the user opened the editor; on Apply we send it verbatim, replacing all
2019
- // stored headers (values are write-only, so the server never sends them back).
2020
- const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
2021
- // Show the server's configured header keys, but let a successful apply override
2022
- // optimistically (config isn't refetched). Derived from the prop — not a
2023
- // mount-time snapshot — so it stays correct as config loads asynchronously.
2024
- const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
2025
- const storedKeys = appliedKeys ?? configuredHeaderKeys
2026
-
2027
- const clearStatus = () => {
2028
- if (apply.status !== 'applying') setApply({ status: 'idle' })
2029
- }
2030
-
2031
- // Footer Reset (and any external edit) clears the URL field without a keystroke;
2032
- // drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
2033
- useEffect(() => {
2034
- setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
2035
- }, [value])
2036
-
2037
- const handleApply = async () => {
2038
- setApply({ status: 'applying' })
2039
- // Decide what to do with headers. undefined = leave them untouched. Only send
2040
- // a replacement when the editor has real content, or {} when the user emptied
2041
- // every row (explicit clear) — blank in-progress rows must NOT wipe stored
2042
- // secrets just because the editor happens to be open for a URL-only change.
2043
- try {
2044
- const editedHeaders = prometheusHeadersFromRows(headerRows)
2045
- const res = await fetch(apiUrl('/integrations/prometheus'), {
2046
- method: 'PUT',
2047
- credentials: getCredentialsMode(),
2048
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
2049
- body: JSON.stringify({
2050
- prometheusUrl: value.trim(),
2051
- ...(editedHeaders !== undefined ? { headers: editedHeaders } : {}),
2052
- }),
2053
- })
2054
- const data = await res.json().catch(() => null)
2055
- if (!res.ok) {
2056
- setApply({ status: 'failed', error: data?.error || res.statusText })
2057
- return
2058
- }
2059
- onApplied?.(value.trim())
2060
- if (editedHeaders !== undefined) {
2061
- setAppliedKeys(Object.keys(editedHeaders).sort())
2062
- }
2063
- if (headerRows !== null) {
2064
- setHeaderRows(null)
2065
- }
2066
- if (data?.connected) {
2067
- setApply({ status: 'connected', address: data.address || value.trim() })
2068
- } else {
2069
- setApply({ status: 'unreachable', error: data?.error || 'not reachable' })
2070
- }
2071
- } catch (err) {
2072
- setApply({ status: 'failed', error: String(err) })
2073
- }
2074
- }
2075
-
2076
- return (
2077
- <div>
2078
- <p className="text-xs text-theme-text-tertiary mb-3">
2079
- Connect existing Prometheus-compatible data for resource usage, workload HTTP metrics and rightsizing. Available charts depend on collected metrics.
2080
- </p>
2081
- <label className="block text-sm font-medium text-theme-text-primary mb-1">
2082
- Metrics backend URL
2083
- </label>
2084
- <p className="text-xs text-theme-text-tertiary mb-1">
2085
- Base URL reachable from Radar, not your browser — Prometheus, VictoriaMetrics, Thanos or
2086
- Mimir. Include any backend path prefix, but not /api/v1/query. Leave empty for cluster discovery; headers require a URL.
2087
- </p>
2088
- <div className="flex items-center gap-2">
2089
- <Input
2090
- value={value}
2091
- disabled={apply.status === 'applying'}
2092
- onChange={(e) => onChange(e.target.value)}
2093
- placeholder="http://prometheus-server.monitoring:9090"
2094
- className="flex-1 min-w-0 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"
2095
- />
2096
- <Tooltip content="Save and apply this connection, then check reachability. Applying clears any workload scope override and resumes automatic identity matching." wrapperClassName="shrink-0">
2097
- <button
2098
- onClick={handleApply}
2099
- disabled={apply.status === 'applying'}
2100
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
2101
- >
2102
- {apply.status === 'applying'
2103
- ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
2104
- : <Plug className="w-3.5 h-3.5" />}
2105
- Apply now
2106
- </button>
2107
- </Tooltip>
2108
- </div>
2109
- {apply.status === 'connected' ? (
2110
- <p className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
2111
- <Check className="w-3 h-3 shrink-0" />
2112
- Connected to {apply.address} — applied, no restart needed
2113
- </p>
2114
- ) : apply.status === 'unreachable' ? (
2115
- <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
2116
- Saved, but not reachable: {apply.error}
2117
- </p>
2118
- ) : apply.status === 'failed' ? (
2119
- <p className="mt-1 text-xs text-red-600 dark:text-red-400/80">
2120
- Couldn't apply: {apply.error}
2121
- </p>
2122
- ) : (
2123
- <p className="mt-1 text-xs text-theme-text-tertiary">
2124
- Saves and applies before checking the connection.
2125
- </p>
2126
- )}
2127
- <p className="mt-2 text-xs text-theme-text-tertiary">
2128
- {local
2129
- ? 'Saved URL and headers apply across all local cluster contexts.'
2130
- : 'Changes affect this Radar installation. Use deployment settings for configuration that survives Pod replacement.'}
2131
- {' Changing servers requires replacing or clearing the saved headers.'}
2132
- </p>
2133
- {serverManaged && (
2134
- <p className="mt-2 text-xs text-theme-text-secondary">
2135
- Server controlled by startup configuration. To set or change the server,
2136
- {local ? ' update the startup flags or environment references and restart Radar.' : ' update the deployment configuration (such as Helm values) and restart Radar.'}
2137
- </p>
2138
- )}
2139
- {urlFromFlag && (
2140
- <p className="mt-1 text-xs text-theme-text-secondary">
2141
- Path edits apply until restart; the URL supplied at launch will then be restored.
2142
- </p>
2143
- )}
2144
-
2145
- {/* Auth headers — for token / multi-tenant backends (Bearer, X-Scope-OrgID). */}
2146
- <div className="mt-3">
2147
- {headerRows === null ? (
2148
- <div className="flex items-center justify-between gap-2">
2149
- <span className="text-xs text-theme-text-tertiary">
2150
- {storedKeys.length > 0
2151
- ? <>Auth headers: <span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> <span className="text-theme-text-disabled">(values hidden)</span></>
2152
- : 'No auth headers'}
2153
- </span>
2154
- {!headersManaged && <button
2155
- onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
2156
- disabled={apply.status === 'applying'}
2157
- className="shrink-0 text-xs font-medium text-accent-text hover:underline"
2158
- >
2159
- {storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
2160
- </button>}
2161
- </div>
2162
- ) : (
2163
- <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
2164
- {headerRows.map((row, i) => (
2165
- <div key={i} className="flex items-center gap-2">
2166
- <Input
2167
- value={row.key}
2168
- disabled={apply.status === 'applying'}
2169
- onChange={(e) => {
2170
- setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
2171
- clearStatus()
2172
- }}
2173
- placeholder="Header (e.g. Authorization)"
2174
- className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
2175
- />
2176
- <input
2177
- type="password"
2178
- value={row.value}
2179
- disabled={apply.status === 'applying'}
2180
- onChange={(e) => {
2181
- setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
2182
- clearStatus()
2183
- }}
2184
- placeholder="Value (e.g. Bearer …)"
2185
- className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
2186
- />
2187
- <Tooltip content="Remove header" wrapperClassName="shrink-0">
2188
- <button
2189
- onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
2190
- disabled={apply.status === 'applying'}
2191
- className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
2192
- >
2193
- <X className="w-3.5 h-3.5" />
2194
- </button>
2195
- </Tooltip>
2196
- </div>
2197
- ))}
2198
- <div className="flex items-center justify-between gap-2">
2199
- <button
2200
- onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
2201
- disabled={apply.status === 'applying'}
2202
- className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
2203
- >
2204
- <Plus className="w-3 h-3" /> Add header
2205
- </button>
2206
- <button
2207
- onClick={() => { setHeaderRows(null); clearStatus() }}
2208
- disabled={apply.status === 'applying'}
2209
- className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
2210
- >
2211
- Cancel
2212
- </button>
2213
- </div>
2214
- <p className="text-xs text-theme-text-tertiary">
2215
- Saved when you click Apply now. Entered headers replace all stored
2216
- ones — values are hidden, so re-enter any you want to keep. Leave
2217
- all rows blank to keep existing headers unchanged. To clear all saved
2218
- headers, remove every row and click Apply now.
2219
- </p>
2220
- </div>
2221
- )}
2222
- </div>
2223
- {headersManaged ? (
2224
- <p className="mt-2 text-xs text-theme-text-secondary">
2225
- Headers are controlled by startup configuration. Change them at their source and restart Radar.
2226
- </p>
2227
- ) : storedKeys.length > 0 && (
2228
- <button
2229
- onClick={() => { setHeaderRows([]); clearStatus() }}
2230
- disabled={apply.status === 'applying'}
2231
- className="mt-2 text-xs text-theme-text-secondary hover:underline"
2232
- >
2233
- Clear saved headers
2234
- </button>
2235
- )}
2236
- {headerRows?.length === 0 && (
2237
- <p className="mt-1 text-xs text-warning-text">Headers will be cleared when you click Apply now.</p>
2238
- )}
2239
- </div>
2240
- )
2241
- }
2242
2103
 
2243
2104
  // -- Argo CD (live-appliable) -------------------------------------------------
2244
2105
 
@@ -2385,7 +2246,7 @@ function ArgoCDEnvManagedField({
2385
2246
  Connected at {connectedAddress}
2386
2247
  </span>
2387
2248
  ) : statusReason ? (
2388
- <span className="text-warning-text">Not reachable — {statusReason}</span>
2249
+ <span className="text-warning-text">Not connected — {statusReason}</span>
2389
2250
  ) : (
2390
2251
  'Not connected'
2391
2252
  )}
@@ -2606,7 +2467,7 @@ function ArgoCDEditableField({
2606
2467
  )}
2607
2468
 
2608
2469
  <label className="block text-sm font-medium text-theme-text-primary mt-3 mb-1">
2609
- {cliSession ? 'Or paste a token' : 'Auth token'}
2470
+ {cliSession ? 'Or paste a token' : 'API token'}
2610
2471
  </label>
2611
2472
  <p className="text-xs text-theme-text-tertiary mb-1">
2612
2473
  Lets Radar read your applications. Create one with{' '}
@@ -2619,7 +2480,7 @@ function ArgoCDEditableField({
2619
2480
  type="password"
2620
2481
  value={showConfiguredPlaceholder ? '' : token}
2621
2482
  onChange={(e) => { setToken(e.target.value); setTokenTouched(true); setTokenCleared(false); clearStatus() }}
2622
- placeholder={showConfiguredPlaceholder ? '•••• configured' : 'Argo CD auth token'}
2483
+ placeholder={showConfiguredPlaceholder ? 'Saved value' : 'Argo CD API token'}
2623
2484
  className="flex-1 min-w-0 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"
2624
2485
  />
2625
2486
  {effectiveTokenSet && !tokenCleared && (
@@ -2627,12 +2488,12 @@ function ArgoCDEditableField({
2627
2488
  onClick={() => { setToken(''); setTokenTouched(false); setTokenCleared(true); clearStatus() }}
2628
2489
  className="shrink-0 px-2.5 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors"
2629
2490
  >
2630
- Clear
2491
+ Remove
2631
2492
  </button>
2632
2493
  )}
2633
2494
  </div>
2634
2495
  {tokenCleared && (
2635
- <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be cleared on save.</p>
2496
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be removed when you save.</p>
2636
2497
  )}
2637
2498
 
2638
2499
  <label className="mt-2 flex items-center gap-2 cursor-pointer">