@skyhook-io/radar-app 1.14.6 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  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.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -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,
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'
@@ -21,18 +21,29 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
21
21
  import { Tooltip } from '../ui/Tooltip'
22
22
  import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
23
23
  import { MyPermissionsContent } from './MyPermissionsDialog'
24
+ import { PrivacySection } from './PrivacySection'
25
+ import { ConfigToggle, SubHeading } from './controls'
24
26
  import { useDiagnose } from '../diagnose/DiagnoseContext'
25
27
  import { currencyOptionsForValue } from './currency-options'
26
- import { versionUpdateURL } from '../../utils/version'
28
+ import { UpdateNotification } from '../ui/UpdateNotification'
27
29
  import {
28
30
  costConfigurationAction,
29
31
  costFreshnessLabel,
30
32
  costIntegrationUnavailableMessage,
31
33
  costSourceLabel,
34
+ costSourcePreferenceLabel,
35
+ COST_SOURCE_OPTIONS,
32
36
  } from '../cost/source'
33
- 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'
34
44
  import type { SettingsSectionId } from './settings-state'
35
45
  import { OperatorManagedNotice } from './OperatorManagedNotice'
46
+ import { FormSaveActions } from './FormSaveActions'
36
47
  export type { SettingsSectionId } from './settings-state'
37
48
 
38
49
  function mcpEndpointUrl(): string {
@@ -60,8 +71,7 @@ interface Config {
60
71
  restoreLastDesktopContext?: boolean | null
61
72
  }
62
73
 
63
- interface ConfigResponse {
64
- management: 'local' | 'operator' | 'cloud'
74
+ interface ConfigResponse extends IntegrationSettingsResponse {
65
75
  file: Config
66
76
  effective: Config
67
77
  isDesktop: boolean
@@ -128,6 +138,8 @@ function normalizeStartup(c: Config) {
128
138
  }
129
139
  }
130
140
 
141
+ const localIntegrationCaption = 'Saved for this cluster · applies when you save, no restart.'
142
+
131
143
  export function SettingsDialog({
132
144
  open,
133
145
  onClose,
@@ -140,6 +152,39 @@ export function SettingsDialog({
140
152
  const { data: versionInfo } = useVersionCheck()
141
153
  const { canAtLeast } = useCloudRole()
142
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])
143
188
 
144
189
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
145
190
  const operatorManaged = configData?.management === 'operator'
@@ -156,9 +201,10 @@ export function SettingsDialog({
156
201
  const configSavingRef = useRef(false)
157
202
  const costCurrencySavingRef = useRef(false)
158
203
  const pendingCloseActionRef = useRef<(() => void) | null>(null)
159
- const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
204
+ const argoStatusQuery = useArgoStatus(
160
205
  open && section === 'argocd'
161
206
  )
207
+ const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = argoStatusQuery
162
208
 
163
209
  // Local AI preferences save independently of the owner-gated server settings.
164
210
  const diag = useDiagnose()
@@ -170,11 +216,20 @@ export function SettingsDialog({
170
216
  effort: diag.effort,
171
217
  })
172
218
  const [aiSaved, setAiSaved] = useState(false)
173
- 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 && (
174
225
  aiDraft.agent !== diag.selectedAgent ||
175
226
  aiDraft.profile !== diag.profile ||
176
227
  aiDraft.model !== diag.model ||
177
- 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])
178
233
 
179
234
  // Per-bucket normalized dirty. Only startup fields participate; integration
180
235
  // fields apply live and never light up the footer.
@@ -196,17 +251,47 @@ export function SettingsDialog({
196
251
  (editedConfig.costSource ?? 'auto') !== (configData?.file.costSource ?? 'auto') ||
197
252
  (editedConfig.kubecostUrl ?? '').trim() !== (configData?.file.kubecostUrl ?? '').trim() ||
198
253
  (editedConfig.kubecostClusterId ?? '').trim() !== (configData?.file.kubecostClusterId ?? '').trim()
199
- const costIntegrationDirty = costSourceDirty || costCredentialDirty
254
+ const costIntegrationDirty = costSourceDirty || costCredentialDirty || localDirty.cost
200
255
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
201
256
  const connectionDirty = clusterDirty || serverDirty
202
257
  const advancedDirty = mcpDirty || timelineDirty
203
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])
204
285
 
205
286
  // Load config on open + snapshot AI prefs + pick a default section that's
206
287
  // actually accessible to the current identity.
207
288
  useEffect(() => {
208
289
  if (!open) return
290
+ if (targetChangedWithDraft || draftFrozen) return
291
+ const controller = new AbortController()
209
292
  setConfigData(null)
293
+ setPrometheusCredentialDirty(false)
294
+ setLocalDirty({ metrics: false, argocd: false, cost: false })
210
295
  setSaveMessage(null)
211
296
  setLoadError(null)
212
297
  setConfirmingClose(false)
@@ -214,28 +299,27 @@ export function SettingsDialog({
214
299
  setCostCredentialDirty(false)
215
300
  setCostDraftReset((current) => current + 1)
216
301
  setAiSaved(false)
217
- setAiDraft({
218
- agent: diag.selectedAgent,
219
- profile: diag.profile,
220
- model: diag.model,
221
- effort: diag.effort,
222
- })
223
- 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() })
224
305
  .then((res) => {
225
306
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
226
307
  return res.json()
227
308
  })
228
309
  .then((data: ConfigResponse) => {
310
+ if (controller.signal.aborted || getApiBase() !== settingsApiBase) return
229
311
  setConfigData(data)
230
312
  setEditedConfig({ ...data.file, prometheusUrl: data.effective.prometheusUrl })
231
313
  })
232
314
  .catch((err) => {
315
+ if (controller.signal.aborted) return
233
316
  console.warn('[settings] Failed to load config:', err)
234
317
  setLoadError('Failed to load configuration.')
235
318
  })
319
+ return () => controller.abort()
236
320
  // Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
237
321
  // eslint-disable-next-line react-hooks/exhaustive-deps
238
- }, [open])
322
+ }, [open, isSwitching, settingsCluster?.context, settingsApiBase, draftFrozen, reloadVersion])
239
323
 
240
324
  useEffect(() => {
241
325
  if (open) setSection(initialSection)
@@ -256,7 +340,7 @@ export function SettingsDialog({
256
340
  }, [])
257
341
 
258
342
  const saveConfig = useCallback(async (): Promise<boolean> => {
259
- if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
343
+ if (!configData || integrationBusy || draftFrozen || targetChangedWithDraft || configSavingRef.current || costCurrencySavingRef.current) return false
260
344
  configSavingRef.current = true
261
345
  setSaving(true)
262
346
  setSaveMessage(null)
@@ -300,7 +384,7 @@ export function SettingsDialog({
300
384
  configSavingRef.current = false
301
385
  setSaving(false)
302
386
  }
303
- }, [editedConfig, configData])
387
+ }, [editedConfig, configData, integrationBusy, draftFrozen, targetChangedWithDraft])
304
388
 
305
389
  const saveCostCurrency = useCallback(async (value: string): Promise<void> => {
306
390
  if (!configData) throw new Error('Radar configuration is not available')
@@ -350,20 +434,31 @@ export function SettingsDialog({
350
434
  diag.setProfile(aiDraft.profile)
351
435
  diag.setModel(aiDraft.model)
352
436
  diag.setEffort(aiDraft.effort)
437
+ setAiTouched(false)
353
438
  setAiSaved(true)
354
439
  }, [diag, aiDraft])
355
440
 
441
+ const discardAi = useCallback(() => {
442
+ setAiTouched(false)
443
+ setAiSaved(false)
444
+ }, [])
445
+
356
446
  const discardChanges = useCallback(() => {
357
447
  // Revert unsaved startup edits back to the last-saved values (non-
358
448
  // destructive). configData.file holds the committed config, including the
359
449
  // live integration fields, so restoring it drops drafts without touching
360
- // what's saved.
450
+ // what's saved. AI preferences don't depend on the config load.
451
+ if (settingsBusy || draftFrozen || targetChangedWithDraft) return
452
+ discardAi()
361
453
  if (!configData) return
362
454
  setEditedConfig({ ...configData.file, prometheusUrl: configData.effective.prometheusUrl })
363
455
  setCostCredentialDirty(false)
456
+ setPrometheusCredentialDirty(false)
457
+ setDiscardGeneration(value => value + 1)
458
+ setLocalDirty({ metrics: false, argocd: false, cost: false })
364
459
  setCostDraftReset((current) => current + 1)
365
460
  setSaveMessage(null)
366
- }, [configData])
461
+ }, [configData, settingsBusy, draftFrozen, targetChangedWithDraft, discardAi])
367
462
 
368
463
  const finishClose = useCallback(() => {
369
464
  const action = pendingCloseActionRef.current
@@ -377,23 +472,33 @@ export function SettingsDialog({
377
472
  if (ok) finishClose()
378
473
  }, [saveConfig, finishClose])
379
474
 
380
- const reviewCostDraft = useCallback(() => {
475
+ const reviewPendingDraft = () => {
476
+ if (!reviewSection) return
381
477
  setConfirmingClose(false)
382
- setSection('cost')
383
- requestAnimationFrame(() => {
384
- const applyButton = document.getElementById('cost-apply-source')
385
- applyButton?.scrollIntoView({ block: 'center' })
386
- applyButton?.focus()
387
- })
388
- }, [])
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])
389
496
 
390
- // Close guard: a pending startup edit prompts an inline confirm rather than
391
- // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
392
- // drop it on close.
393
497
  const requestCloseRef = useRef<(afterClose?: () => void) => void>(() => {})
394
498
  requestCloseRef.current = (afterClose) => {
499
+ if (settingsBusy) return
395
500
  pendingCloseActionRef.current = afterClose ?? null
396
- if (canEditConfig && (configDirty || costIntegrationDirty)) setConfirmingClose(true)
501
+ if ((canEditConfig && (configDirty || integrationDirty)) || aiDirty) setConfirmingClose(true)
397
502
  else finishClose()
398
503
  }
399
504
 
@@ -445,20 +550,20 @@ export function SettingsDialog({
445
550
  { id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
446
551
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
447
552
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
448
- { id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: false },
553
+ { id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: prometheusDirty },
449
554
  { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
450
- { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
555
+ { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: localDirty.argocd },
451
556
  { id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
557
+ { id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
452
558
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
453
559
  ]
454
560
 
455
- const offerCostReview = shouldOfferCostReview(costIntegrationDirty, section)
456
561
  const showFooter = shouldShowSettingsFooter({
457
562
  canEditConfig,
458
563
  confirmingClose,
459
564
  configDirty,
460
- costIntegrationDirty,
461
- section,
565
+ integrationDirty: integrationDirty && (!configData?.integrationProfiles || (!!reviewSection && reviewSection !== 'ai')),
566
+ aiDirtyElsewhere: aiDirty && section !== 'ai',
462
567
  hasSaveMessage: Boolean(saveMessage),
463
568
  })
464
569
 
@@ -494,27 +599,30 @@ export function SettingsDialog({
494
599
  // Fixed height so the dialog doesn't jump when switching tabs — short
495
600
  // tabs leave breathing room, tall ones scroll inside the content pane.
496
601
  // max-h keeps it on-screen on short viewports.
497
- '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)]',
498
603
  TRANSITION_PANEL,
499
604
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
500
605
  )}
501
606
  style={overlayTransitionStyle(isOpen, 'dialog')}
502
607
  >
503
608
  {/* Header — spans both panes */}
504
- <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
505
- <div className="flex items-center gap-2">
506
- <Settings className="w-5 h-5 text-theme-text-secondary" />
507
- <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">
508
613
  <h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
509
- <span className="text-[11px] text-theme-text-tertiary">
510
- 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/, '')}` : ''}
511
616
  <span className="text-theme-text-disabled"> · by Skyhook</span>
512
617
  </span>
618
+ <UpdateNotification placement="settings" />
513
619
  </div>
514
620
  </div>
515
621
  <button
516
622
  onClick={() => requestCloseRef.current()}
517
- 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"
518
626
  >
519
627
  <X className="w-5 h-5" />
520
628
  </button>
@@ -562,21 +670,35 @@ export function SettingsDialog({
562
670
  </div>
563
671
  )}
564
672
 
565
- {!configData && !['overview', 'perms', 'ai'].includes(section) ? (
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>}
681
+ {!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
566
682
  <p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
567
- ) : <>
568
- {operatorManaged && section !== 'perms' && section !== 'ai' && <div className="mb-4"><OperatorManagedNotice /></div>}
683
+ ) : <div inert={draftFrozen || settingsBusy || undefined} className={draftFrozen ? 'opacity-60' : undefined}>
684
+ {operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
569
685
  {/* Overview — status at a glance; the landing section */}
570
686
  <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
571
687
  <div className="mb-1">
572
- <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>
573
689
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
574
690
  What this Radar is connected to right now — select a row for details.
575
691
  </p>
576
692
  </div>
693
+ {configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
694
+ <PreviousIntegrationSettingsNotice profiles={configData.integrationProfiles} onNavigate={setSection} onDismissed={previousNoticeDismissed} />
695
+ )}
577
696
  <div className="mt-3">
578
697
  <OverviewPanel active={section === 'overview'} onNavigate={setSection} />
579
698
  </div>
699
+ {configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
700
+ <LocalConfigurationDetails onNavigate={setSection} />
701
+ )}
580
702
  </div>
581
703
 
582
704
  {/* My permissions — usable by everyone, rendered inline (no launcher) */}
@@ -599,7 +721,7 @@ export function SettingsDialog({
599
721
  active={section}
600
722
  title="Connection"
601
723
  managed={operatorManaged ? <OperatorSettingsSummary section="connection" config={configData} /> : undefined}
602
- caption="Takes effect on next launch."
724
+ caption="Kubeconfig and server changes take effect on next launch."
603
725
  locked={!canEditConfig}
604
726
  >
605
727
  <div className="space-y-4">
@@ -621,6 +743,15 @@ export function SettingsDialog({
621
743
  onChange={updateConfigField}
622
744
  />
623
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
+ )}
624
755
  </SectionPane>
625
756
 
626
757
  {/* Metrics backend — live */}
@@ -629,11 +760,15 @@ export function SettingsDialog({
629
760
  active={section}
630
761
  title="Metrics"
631
762
  managed={operatorManaged ? <OperatorSettingsSummary section="prometheus" config={configData} /> : undefined}
632
- caption="Connect and manage your metrics backend."
763
+ caption={configData?.integrationProfiles ? localIntegrationCaption : 'Connect and manage your metrics backend.'}
633
764
  live
634
765
  locked={!canEditConfig}
635
766
  >
636
- <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}
637
772
  local={deploymentMode === 'local'}
638
773
  value={editedConfig.prometheusUrl ?? ''}
639
774
  configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
@@ -641,7 +776,8 @@ export function SettingsDialog({
641
776
  headersManaged={configData?.prometheusHeadersManaged === true}
642
777
  urlFromFlag={configData?.prometheusUrlFromFlag === true}
643
778
  onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
644
- onApplied={(url) =>
779
+ onApplied={(url) => {
780
+ setEditedConfig((prev) => ({ ...prev, prometheusUrl: url || undefined }))
645
781
  setConfigData((prev) =>
646
782
  prev ? {
647
783
  ...prev,
@@ -649,8 +785,8 @@ export function SettingsDialog({
649
785
  effective: { ...prev.effective, prometheusUrl: url || undefined },
650
786
  } : prev
651
787
  )
652
- }
653
- />
788
+ }}
789
+ />}
654
790
  </SectionPane>
655
791
 
656
792
  <SectionPane
@@ -658,11 +794,13 @@ export function SettingsDialog({
658
794
  active={section}
659
795
  title="Cost"
660
796
  managed={operatorManaged ? <OperatorSettingsSummary section="cost" config={configData} /> : undefined}
661
- 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.'}
662
798
  live
663
799
  locked={!canEditConfig}
664
800
  >
665
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}
666
804
  currency={editedConfig.opencostCurrency ?? ''}
667
805
  source={editedConfig.costSource ?? 'auto'}
668
806
  url={editedConfig.kubecostUrl ?? ''}
@@ -675,7 +813,7 @@ export function SettingsDialog({
675
813
  managed={configData?.openCostCurrencyManaged ?? false}
676
814
  effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
677
815
  deploymentMode={deploymentMode}
678
- settingsSaving={saving}
816
+ settingsSaving={settingsBusy}
679
817
  onNavigateToResource={navigateFromSettings}
680
818
  onApplyCurrency={saveCostCurrency}
681
819
  onChangeSource={(value) => updateConfigField('costSource', value)}
@@ -703,11 +841,12 @@ export function SettingsDialog({
703
841
  active={section}
704
842
  title="Argo CD"
705
843
  managed={operatorManaged ? <OperatorSettingsSummary section="argocd" config={configData} /> : undefined}
706
- caption="Applies immediately — no restart."
844
+ caption={configData?.integrationProfiles ? localIntegrationCaption : 'Applies immediately — no restart.'}
707
845
  live
708
846
  locked={!canEditConfig}
709
847
  >
710
- <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
711
850
  url={editedConfig.argoCdUrl ?? ''}
712
851
  insecureTls={editedConfig.argoCdInsecureTls ?? false}
713
852
  tokenSet={configData?.argoCdTokenSet ?? false}
@@ -742,7 +881,7 @@ export function SettingsDialog({
742
881
  predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('gitops-'),
743
882
  })
744
883
  }}
745
- />
884
+ />}
746
885
  </SectionPane>
747
886
 
748
887
  {/* AI investigations — self-saving, usable by everyone. Same heading block
@@ -769,26 +908,19 @@ export function SettingsDialog({
769
908
  draft={aiDraft}
770
909
  onChange={(patch) => {
771
910
  setAiDraft((d) => ({ ...d, ...patch }))
911
+ setAiTouched(true)
772
912
  setAiSaved(false)
773
913
  }}
774
914
  onHistoryCleared={diag.refreshRuns}
775
915
  />
776
- {!diag.hosted && (
777
- <div className="flex items-center justify-end gap-3">
778
- {aiSaved && !aiDirty && (
779
- <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
780
- <Check className="w-3 h-3" />
781
- Saved
782
- </span>
783
- )}
784
- <button
785
- onClick={saveAi}
786
- disabled={!aiDirty}
787
- className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
788
- >
789
- Save
790
- </button>
791
- </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
+ />
792
924
  )}
793
925
  </div>
794
926
  ) : (
@@ -796,6 +928,12 @@ export function SettingsDialog({
796
928
  )}
797
929
  </div>
798
930
 
931
+ {/* Privacy: the usage-stats choice. Readable by everyone; only a local
932
+ install can change it here. */}
933
+ <SectionPane id="privacy" active={section} title="Privacy" caption="Applies immediately." live>
934
+ <PrivacySection active={section === 'privacy'} />
935
+ </SectionPane>
936
+
799
937
  {/* Advanced — MCP + Timeline merged */}
800
938
  <SectionPane
801
939
  id="advanced"
@@ -824,120 +962,118 @@ export function SettingsDialog({
824
962
  />
825
963
  </div>
826
964
  </SectionPane>
827
- </>}
965
+ </div>}
828
966
  </div>
829
967
  </div>
830
968
 
831
- {/* Footer — owner-gated persisted config. AI self-saves and integrations
832
- apply separately. Shown whenever an edit is pending (any section),
833
- while confirming a close, or briefly after a save. */}
834
- <div
835
- className={clsx(
836
- 'shrink-0 overflow-hidden transition-all duration-200 ease-out',
837
- showFooter ? 'max-h-24 opacity-100 border-t border-theme-border' : 'max-h-0 opacity-0 pointer-events-none'
838
- )}
839
- >
840
- <div className="flex items-center justify-between gap-3 px-4 py-2.5">
841
- {confirmingClose ? (
842
- <>
843
- <span className="text-xs text-theme-text-secondary">
844
- {costIntegrationDirty && configDirty
845
- ? 'Cost source changes are not applied, and other changes are unsaved.'
846
- : costIntegrationDirty
847
- ? '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.' : ''}`
848
981
  : 'Unsaved changes.'}
849
- </span>
850
- <div className="flex items-center gap-2">
851
- <button
852
- onClick={() => {
853
- pendingCloseActionRef.current = null
854
- setConfirmingClose(false)
855
- }}
856
- disabled={saving}
857
- 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"
858
- >
859
- Keep editing
860
- </button>
861
- <button
862
- onClick={finishClose}
863
- disabled={saving}
864
- 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"
865
- >
866
- Discard
867
- </button>
868
- {offerCostReview && (
869
- <button
870
- onClick={reviewCostDraft}
871
- className={clsx(
872
- 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
873
- configDirty
874
- ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
875
- : 'btn-brand',
876
- )}
877
- >
878
- Review Cost
879
- </button>
880
- )}
881
- {configDirty && (
882
- <button
883
- onClick={costIntegrationDirty ? saveConfig : handleSaveAndClose}
884
- disabled={saving || costCurrencySaving}
885
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
886
- >
887
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
888
- {costIntegrationDirty ? 'Save other changes' : 'Save'}
889
- </button>
890
- )}
891
- </div>
892
- </>
893
- ) : (
894
- <>
895
- <div className="flex items-center gap-2">
896
- <Tooltip content="Discard unsaved changes and revert to the last saved values">
897
- <button
898
- onClick={discardChanges}
899
- disabled={saving || (!configDirty && !costIntegrationDirty)}
900
- 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"
901
- >
902
- <RotateCcw className="w-3.5 h-3.5" />
903
- Discard changes
904
- </button>
905
- </Tooltip>
906
- {saveMessage && (
907
- <span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
908
- {saveMessage}
909
- </span>
910
- )}
911
- </div>
912
- <div className="flex items-center gap-2">
913
- {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">
914
986
  <button
915
- onClick={reviewCostDraft}
916
- className={clsx(
917
- 'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
918
- configDirty
919
- ? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
920
- : 'btn-brand',
921
- )}
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"
922
993
  >
923
- Review Cost
994
+ Keep editing
924
995
  </button>
925
- )}
926
- {configDirty && (
927
996
  <button
928
- onClick={saveConfig}
929
- disabled={saving || costCurrencySaving}
930
- 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"
931
1000
  >
932
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
933
- Save
1001
+ Discard
934
1002
  </button>
935
- )}
936
- </div>
937
- </>
938
- )}
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>
939
1075
  </div>
940
- </div>
1076
+ </Collapse>
941
1077
  </div>
942
1078
  </div>,
943
1079
  document.body
@@ -954,16 +1090,6 @@ interface NavItemDef {
954
1090
  dirty: boolean
955
1091
  }
956
1092
 
957
- // Light subheading separating the two field groups inside a merged pane
958
- // (Cluster/Server, MCP/Timeline).
959
- function SubHeading({ children }: { children: ReactNode }) {
960
- return (
961
- <h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
962
- {children}
963
- </h4>
964
- )
965
- }
966
-
967
1093
  function NavItem({
968
1094
  item,
969
1095
  active,
@@ -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">
@@ -2798,45 +2659,6 @@ function ConfigNumberField({
2798
2659
  )
2799
2660
  }
2800
2661
 
2801
- function ConfigToggle({
2802
- label,
2803
- description,
2804
- value,
2805
- onChange,
2806
- }: {
2807
- label: string
2808
- description?: string
2809
- value: boolean
2810
- onChange: (value: boolean) => void
2811
- }) {
2812
- return (
2813
- <label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
2814
- <span className="min-w-0">
2815
- <span className="block text-sm text-theme-text-primary">{label}</span>
2816
- {description && (
2817
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
2818
- )}
2819
- </span>
2820
- <button
2821
- role="switch"
2822
- aria-checked={value}
2823
- onClick={() => onChange(!value)}
2824
- className={clsx(
2825
- 'relative w-9 h-5 shrink-0 rounded-full transition-colors',
2826
- value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
2827
- )}
2828
- >
2829
- <span
2830
- className={clsx(
2831
- 'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
2832
- value && 'translate-x-4'
2833
- )}
2834
- />
2835
- </button>
2836
- </label>
2837
- )
2838
- }
2839
-
2840
2662
  function EffectiveHint({
2841
2663
  current,
2842
2664
  effective,