@skyhook-io/radar-app 0.2.2 → 0.3.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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,10 +1,24 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react'
1
+ import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin } from 'lucide-react'
3
+ import {
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,
7
+ type LucideIcon,
8
+ } from 'lucide-react'
4
9
  import { clsx } from 'clsx'
5
10
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
11
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
7
12
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
13
+ import {
14
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
15
+ } from '../../api/client'
16
+ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
17
+ import { Input } from '@skyhook-io/k8s-ui'
18
+ import { Tooltip } from '../ui/Tooltip'
19
+ import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
20
+ import { MyPermissionsContent } from './MyPermissionsDialog'
21
+ import { useDiagnose } from '../diagnose/DiagnoseContext'
8
22
 
9
23
  interface Config {
10
24
  kubeconfig?: string
@@ -12,10 +26,13 @@ interface Config {
12
26
  namespace?: string
13
27
  port?: number
14
28
  noBrowser?: boolean
29
+ browser?: string
15
30
  timelineStorage?: 'memory' | 'sqlite'
16
31
  timelineDbPath?: string
17
32
  historyLimit?: number
18
33
  prometheusUrl?: string
34
+ argoCdUrl?: string
35
+ argoCdInsecureTls?: boolean
19
36
  mcp?: boolean | null
20
37
  }
21
38
 
@@ -23,6 +40,21 @@ interface ConfigResponse {
23
40
  file: Config
24
41
  effective: Config
25
42
  isDesktop: boolean
43
+ prometheusHeaderKeys?: string[]
44
+ // True when an Argo CD auth token is stored. The token itself is never
45
+ // returned — the card shows a "configured" placeholder and omits the token
46
+ // from the PUT unless the user changes or clears it.
47
+ argoCdTokenSet?: boolean
48
+ // True when the integration is provisioned from the environment
49
+ // (RADAR_ARGOCD_TOKEN / _TOKEN_FILE) — the card renders read-only because the
50
+ // server refuses UI edits to a declaratively-configured integration.
51
+ argoCdEnvManaged?: boolean
52
+ // Set when environment provisioning was attempted but failed (bad token file,
53
+ // invalid URL, …) — the read-only card surfaces the reason.
54
+ argoCdEnvError?: string
55
+ // A detected Argo CD CLI login (server + user + TLS mode, no token), so the UI
56
+ // can offer "use your CLI session" as a one-click when it will actually work.
57
+ argoCdCliSession?: { server: string; user: string; insecure?: boolean }
26
58
  }
27
59
 
28
60
  interface SettingsDialogProps {
@@ -30,22 +62,111 @@ interface SettingsDialogProps {
30
62
  onClose: () => void
31
63
  }
32
64
 
65
+ // The settings surface splits into three honest apply buckets:
66
+ // • Startup config (kubeconfig, server, timeline, MCP) — persisted by the
67
+ // owner-gated footer to the config file; effect on next launch.
68
+ // • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints
69
+ // re-point the running server; effect immediately, NOT part of footer dirty.
70
+ // • AI diagnose — client-side prefs, self-saving, editable by everyone.
71
+ type SectionId =
72
+ | 'overview' | 'perms' | 'connection' | 'prometheus' | 'argocd' | 'ai' | 'advanced'
73
+
74
+ // Only STARTUP fields count toward footer dirty. Integration fields (prometheusUrl,
75
+ // argoCdUrl, argoCdInsecureTls) apply live and are excluded here. Every field is
76
+ // normalized so unset≡default doesn't read as a change.
77
+ function normalizeStartup(c: Config) {
78
+ return {
79
+ kubeconfig: c.kubeconfig ?? '',
80
+ kubeconfigDirs: c.kubeconfigDirs && c.kubeconfigDirs.length > 0 ? c.kubeconfigDirs.join('\x00') : '',
81
+ namespace: c.namespace ?? '',
82
+ port: c.port ?? null,
83
+ noBrowser: c.noBrowser ?? false,
84
+ browser: c.browser ?? '',
85
+ timelineStorage: c.timelineStorage ?? 'memory',
86
+ timelineDbPath: c.timelineDbPath ?? '',
87
+ historyLimit: c.historyLimit ?? null,
88
+ mcp: c.mcp ?? true,
89
+ }
90
+ }
91
+
33
92
  export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
34
93
  const dialogRef = useRef<HTMLDivElement>(null)
35
94
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
95
+ const { data: versionInfo } = useVersionCheck()
96
+ // Radar configuration (kubeconfig, port, integrations…) is host-level and
97
+ // affects every user of this instance, so it's gated to owners. Personal
98
+ // sections (My permissions, AI diagnose) stay usable by everyone. Non-Cloud
99
+ // callers (OSS, OIDC, kubectl plugin) have no role and pass — single-user
100
+ // laptops are never locked out of their own config. Backend enforces this too.
101
+ const { canAtLeast } = useCloudRole()
102
+ const capabilities = useCapabilitiesContext()
103
+ const canEditConfig = canAtLeast('owner')
104
+
36
105
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
37
106
  const [editedConfig, setEditedConfig] = useState<Config>({})
38
107
  const [saving, setSaving] = useState(false)
39
108
  const [saveMessage, setSaveMessage] = useState<string | null>(null)
40
- const [configDirty, setConfigDirty] = useState(false)
41
109
  const [loadError, setLoadError] = useState<string | null>(null)
110
+ const [section, setSection] = useState<SectionId>('overview')
111
+ const [confirmingClose, setConfirmingClose] = useState(false)
112
+
113
+ // AI Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
114
+ // section has its own Save that commits the draft to DiagnoseContext, so it's
115
+ // independent of the owner-gated footer. The draft is snapshotted on open.
116
+ const diag = useDiagnose()
117
+ const aiAvailable = diag.available && diag.agents.length > 0
118
+ const [aiDraft, setAiDraft] = useState<AIDraft>({
119
+ agent: diag.selectedAgent,
120
+ isolated: diag.isolated,
121
+ model: diag.model,
122
+ effort: diag.effort,
123
+ })
124
+ const [aiSaved, setAiSaved] = useState(false)
125
+ const aiDirty =
126
+ aiDraft.agent !== diag.selectedAgent ||
127
+ aiDraft.isolated !== diag.isolated ||
128
+ aiDraft.model !== diag.model ||
129
+ aiDraft.effort !== diag.effort
42
130
 
43
- // Load config on open
131
+ // Per-bucket normalized dirty. Only startup fields participate; integration
132
+ // fields apply live and never light up the footer.
133
+ const edN = normalizeStartup(editedConfig)
134
+ const svN = normalizeStartup(configData?.file ?? {})
135
+ const clusterDirty =
136
+ edN.kubeconfig !== svN.kubeconfig ||
137
+ edN.kubeconfigDirs !== svN.kubeconfigDirs ||
138
+ edN.namespace !== svN.namespace
139
+ const serverDirty =
140
+ edN.port !== svN.port || edN.noBrowser !== svN.noBrowser || edN.browser !== svN.browser
141
+ const mcpDirty = edN.mcp !== svN.mcp
142
+ const timelineDirty =
143
+ edN.timelineStorage !== svN.timelineStorage ||
144
+ edN.timelineDbPath !== svN.timelineDbPath ||
145
+ edN.historyLimit !== svN.historyLimit
146
+ // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
147
+ const connectionDirty = clusterDirty || serverDirty
148
+ const advancedDirty = mcpDirty || timelineDirty
149
+ const startupDirty = configData != null && (connectionDirty || advancedDirty)
150
+
151
+ // Load config on open + snapshot AI prefs + pick a default section that's
152
+ // actually accessible to the current identity.
44
153
  useEffect(() => {
45
154
  if (!open) return
46
155
  setSaveMessage(null)
47
- setConfigDirty(false)
48
156
  setLoadError(null)
157
+ setConfirmingClose(false)
158
+ setAiSaved(false)
159
+ setAiDraft({
160
+ agent: diag.selectedAgent,
161
+ isolated: diag.isolated,
162
+ model: diag.model,
163
+ effort: diag.effort,
164
+ })
165
+ // Overview is the landing section — a status-at-a-glance of what Radar is
166
+ // connected to (cluster, integrations, MCP, AI), useful to owners and
167
+ // viewers alike, rather than dropping owners on a config form or everyone
168
+ // on a permissions dump.
169
+ setSection('overview')
49
170
 
50
171
  fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
51
172
  .then((res) => {
@@ -60,67 +181,137 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
60
181
  console.warn('[settings] Failed to load config:', err)
61
182
  setLoadError('Failed to load configuration.')
62
183
  })
63
- }, [open])
64
-
65
- // ESC key
66
- useEffect(() => {
67
- if (!open) return
68
- const handleKeyDown = (e: KeyboardEvent) => {
69
- if (e.key === 'Escape') {
70
- e.stopPropagation()
71
- onClose()
72
- }
73
- }
74
- document.addEventListener('keydown', handleKeyDown, true)
75
- return () => document.removeEventListener('keydown', handleKeyDown, true)
76
- }, [open, onClose])
77
-
78
- // Focus trap
79
- useEffect(() => {
80
- if (open && dialogRef.current) {
81
- dialogRef.current.focus()
82
- }
184
+ // Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
186
  }, [open])
84
187
 
85
188
  const updateConfigField = useCallback(<K extends keyof Config>(field: K, value: Config[K]) => {
86
189
  setEditedConfig((prev) => ({ ...prev, [field]: value }))
87
- setConfigDirty(true)
88
190
  setSaveMessage(null)
89
191
  }, [])
90
192
 
91
- const saveConfig = useCallback(async () => {
193
+ const saveConfig = useCallback(async (): Promise<boolean> => {
194
+ if (!configData) return false
92
195
  setSaving(true)
93
196
  setSaveMessage(null)
94
197
  try {
198
+ // /config is FULL-REPLACEMENT. Integration fields must carry the
199
+ // LAST-COMMITTED values (from configData.file), never an un-applied draft:
200
+ // sending a typed-but-not-applied argoCdUrl trips the server-side origin
201
+ // guard that clears the stored Argo token, and a stale value would revert a
202
+ // live-applied integration. configData.file is kept in sync on Apply/Connect.
203
+ const body: Config = {
204
+ ...editedConfig,
205
+ prometheusUrl: configData.file.prometheusUrl,
206
+ argoCdUrl: configData.file.argoCdUrl,
207
+ argoCdInsecureTls: configData.file.argoCdInsecureTls,
208
+ }
95
209
  const res = await fetch(apiUrl('/config'), {
96
210
  method: 'PUT',
97
211
  credentials: getCredentialsMode(),
98
212
  headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
99
- body: JSON.stringify(editedConfig),
213
+ body: JSON.stringify(body),
100
214
  })
101
215
  if (!res.ok) {
102
216
  const data = await res.json().catch(() => null)
103
217
  setSaveMessage(`Error: ${data?.error || res.statusText}`)
104
- } else {
105
- setConfigDirty(false)
106
- setSaveMessage('Saved. Changes take effect on next launch.')
218
+ return false
107
219
  }
220
+ // Advance the committed snapshot so startupDirty settles to false.
221
+ setConfigData((prev) => (prev ? { ...prev, file: body } : prev))
222
+ setSaveMessage('Saved. Restart Radar to apply.')
223
+ return true
108
224
  } catch (err) {
109
225
  setSaveMessage(`Error: ${err}`)
226
+ return false
110
227
  } finally {
111
228
  setSaving(false)
112
229
  }
113
- }, [editedConfig])
230
+ }, [editedConfig, configData])
114
231
 
115
- const resetConfig = useCallback(() => {
116
- setEditedConfig({})
117
- setConfigDirty(true)
118
- setSaveMessage('All fields cleared. Press Save to apply.')
119
- }, [])
232
+ // AI prefs are client-side (localStorage) commit the staged draft now.
233
+ // setSelectedAgent clears model/effort (they're agent-specific), so set the
234
+ // agent first, then restore the draft's model/effort.
235
+ const saveAi = useCallback(() => {
236
+ diag.setSelectedAgent(aiDraft.agent)
237
+ diag.setIsolated(aiDraft.isolated)
238
+ diag.setModel(aiDraft.model)
239
+ diag.setEffort(aiDraft.effort)
240
+ setAiSaved(true)
241
+ }, [diag, aiDraft])
242
+
243
+ const discardChanges = useCallback(() => {
244
+ // Revert unsaved startup edits back to the last-saved values (non-
245
+ // destructive). configData.file holds the committed config, including the
246
+ // live integration fields, so restoring it drops drafts without touching
247
+ // what's saved.
248
+ if (!configData) return
249
+ setEditedConfig(configData.file)
250
+ setSaveMessage(null)
251
+ }, [configData])
252
+
253
+ const handleSaveAndClose = useCallback(async () => {
254
+ const ok = await saveConfig()
255
+ if (ok) onClose()
256
+ }, [saveConfig, onClose])
257
+
258
+ // Close guard: a pending startup edit prompts an inline confirm rather than
259
+ // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
260
+ // drop it on close. Held in a ref so the ESC listener reads current dirtiness.
261
+ const requestCloseRef = useRef<() => void>(() => {})
262
+ requestCloseRef.current = () => {
263
+ if (canEditConfig && startupDirty) setConfirmingClose(true)
264
+ else onClose()
265
+ }
266
+
267
+ // ESC key
268
+ useEffect(() => {
269
+ if (!open) return
270
+ const handleKeyDown = (e: KeyboardEvent) => {
271
+ if (e.key === 'Escape') {
272
+ e.stopPropagation()
273
+ requestCloseRef.current()
274
+ }
275
+ }
276
+ document.addEventListener('keydown', handleKeyDown, true)
277
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
278
+ }, [open])
279
+
280
+ // Post-save feedback ("Saved. Restart Radar to apply.") is scoped to the
281
+ // section it was saved from. Clear it on navigation so it doesn't linger — with
282
+ // its disabled footer — on a live-apply section where "Restart to apply" is
283
+ // wrong. It still stays put while the user remains on the saved section.
284
+ useEffect(() => { setSaveMessage(null) }, [section])
285
+
286
+ // Focus trap
287
+ useEffect(() => {
288
+ if (open && dialogRef.current) {
289
+ dialogRef.current.focus()
290
+ }
291
+ }, [open])
120
292
 
121
293
  if (!shouldRender) return null
122
294
 
123
295
  const isDesktop = configData?.isDesktop ?? false
296
+ const deploymentMode = capabilities.deployment?.mode ?? 'local'
297
+ const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
298
+
299
+ // Flat, un-grouped nav ordered as a narrative — at-a-glance, then you, then how
300
+ // Radar connects, then data integrations, then AI, then advanced. The
301
+ // per-section captions carry the restart-vs-live semantics, so group labels
302
+ // would only add visual weight. AI diagnose is always shown (the section
303
+ // explains how to enable it when no agent CLI is installed).
304
+ const navItems: NavItemDef[] = [
305
+ { id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
306
+ { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
307
+ { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
308
+ { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false },
309
+ { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
310
+ { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
311
+ { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
312
+ ]
313
+
314
+ const showFooter = canEditConfig && (confirmingClose || startupDirty || !!saveMessage)
124
315
 
125
316
  return createPortal(
126
317
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -131,7 +322,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
131
322
  TRANSITION_BACKDROP,
132
323
  isOpen ? 'opacity-100' : 'opacity-0'
133
324
  )}
134
- onClick={onClose}
325
+ onClick={() => requestCloseRef.current()}
135
326
  />
136
327
 
137
328
  {/* Dialog */}
@@ -141,98 +332,647 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
141
332
  className={clsx(
142
333
  'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
143
334
  'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
144
- 'sm:rounded-xl sm:max-w-xl sm:mx-4 sm:max-h-[85vh]',
335
+ // Fixed height so the dialog doesn't jump when switching tabs — short
336
+ // tabs leave breathing room, tall ones scroll inside the content pane.
337
+ // max-h keeps it on-screen on short viewports.
338
+ 'sm:rounded-xl sm:max-w-4xl sm:mx-4 sm:h-[620px] sm:max-h-[85vh]',
145
339
  TRANSITION_PANEL,
146
340
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
147
341
  )}
148
342
  >
149
- {/* Header */}
343
+ {/* Header — spans both panes */}
150
344
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
151
345
  <div className="flex items-center gap-2">
152
346
  <Settings className="w-5 h-5 text-theme-text-secondary" />
153
- <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
347
+ <div className="flex items-baseline gap-2">
348
+ <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
349
+ <span className="text-[11px] text-theme-text-tertiary">
350
+ Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
351
+ <span className="text-theme-text-disabled"> · by Skyhook</span>
352
+ </span>
353
+ </div>
154
354
  </div>
155
355
  <button
156
- onClick={onClose}
356
+ onClick={() => requestCloseRef.current()}
157
357
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
158
358
  >
159
359
  <X className="w-5 h-5" />
160
360
  </button>
161
361
  </div>
162
362
 
163
- {/* Content */}
164
- <div className="overflow-y-auto p-4 flex-1">
165
- {loadError && (
166
- <div className="mb-3 px-3 py-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-md">
167
- {loadError}
363
+ {/* Body: sidebar + content */}
364
+ <div className="flex flex-col sm:flex-row flex-1 min-h-0">
365
+ {/* Sidebar (sm+) */}
366
+ <nav
367
+ role="tablist"
368
+ aria-orientation="vertical"
369
+ className="hidden sm:flex sm:flex-col gap-0.5 w-[200px] shrink-0 overflow-y-auto border-r border-theme-border p-3"
370
+ >
371
+ {navItems.map((i) => (
372
+ <NavItem
373
+ key={i.id}
374
+ item={i}
375
+ active={section === i.id}
376
+ disabled={i.ownerOnly && !canEditConfig}
377
+ onSelect={() => setSection(i.id)}
378
+ />
379
+ ))}
380
+ </nav>
381
+
382
+ {/* Tab strip (below sm) */}
383
+ <div role="tablist" className="sm:hidden flex gap-1 overflow-x-auto border-b border-theme-border p-2 shrink-0">
384
+ {navItems.map((i) => (
385
+ <NavItem
386
+ key={i.id}
387
+ item={i}
388
+ horizontal
389
+ active={section === i.id}
390
+ disabled={i.ownerOnly && !canEditConfig}
391
+ onSelect={() => setSection(i.id)}
392
+ />
393
+ ))}
394
+ </div>
395
+
396
+ {/* Content pane — stable scrollbar gutter so switching between a
397
+ scrolling tab and a short one doesn't shift content sideways. */}
398
+ <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-5 [scrollbar-gutter:stable]">
399
+ {loadError && (
400
+ <div className="mb-3 px-3 py-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-md">
401
+ {loadError}
402
+ </div>
403
+ )}
404
+
405
+ {/* Overview — status at a glance; the landing section */}
406
+ <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel">
407
+ <div className="mb-1">
408
+ <h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
409
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
410
+ What this Radar is connected to right now — select a row to manage it.
411
+ </p>
412
+ </div>
413
+ <div className="mt-3">
414
+ <OverviewPanel active={section === 'overview'} onNavigate={setSection} />
415
+ </div>
168
416
  </div>
169
- )}
170
- <StartupConfigTab
171
- config={editedConfig}
172
- effectiveConfig={configData?.effective}
173
- isDesktop={isDesktop}
174
- onChange={updateConfigField}
175
- />
176
- </div>
177
417
 
178
- {/* Footer */}
179
- <div className="flex items-center justify-between gap-3 p-4 border-t border-theme-border shrink-0">
180
- <div className="flex items-center gap-2">
181
- <button
182
- onClick={resetConfig}
183
- disabled={saving}
184
- 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"
185
- title="Reset all configuration to defaults"
186
- >
187
- <RotateCcw className="w-3.5 h-3.5" />
188
- Reset
189
- </button>
190
- {saveMessage && (
191
- <span className={clsx(
192
- 'text-xs',
193
- saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400'
194
- )}>
195
- {saveMessage}
196
- </span>
418
+ {/* My permissions — usable by everyone, rendered inline (no launcher) */}
419
+ <div className={clsx(section !== 'perms' && 'hidden')} role="tabpanel">
420
+ <div className="mb-1">
421
+ <h3 className="text-base font-semibold text-theme-text-primary">My permissions</h3>
422
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
423
+ What your current identity can do in this cluster — roles bound to you and your
424
+ effective, flattened permissions.
425
+ </p>
426
+ </div>
427
+ <div className="mt-3">
428
+ <MyPermissionsContent active={section === 'perms'} />
429
+ </div>
430
+ </div>
431
+
432
+ {/* Connection — Cluster + Server merged */}
433
+ <SectionPane
434
+ id="connection"
435
+ active={section}
436
+ title="Connection"
437
+ caption="Takes effect on next launch."
438
+ locked={!canEditConfig}
439
+ >
440
+ <div className="space-y-4">
441
+ <SubHeading>Cluster</SubHeading>
442
+ <ClusterSection
443
+ config={editedConfig}
444
+ effectiveConfig={configData?.effective}
445
+ onChange={updateConfigField}
446
+ />
447
+ </div>
448
+ <div className="space-y-4 border-t border-theme-border-subtle pt-4">
449
+ <SubHeading>Server</SubHeading>
450
+ <ServerSection
451
+ config={editedConfig}
452
+ effectiveConfig={configData?.effective}
453
+ isDesktop={isDesktop}
454
+ showBrowserLaunchControls={showBrowserLaunchControls}
455
+ onChange={updateConfigField}
456
+ />
457
+ </div>
458
+ </SectionPane>
459
+
460
+ {/* Prometheus — live */}
461
+ <SectionPane
462
+ id="prometheus"
463
+ active={section}
464
+ title="Prometheus"
465
+ caption="Applies immediately — no restart."
466
+ live
467
+ locked={!canEditConfig}
468
+ >
469
+ <PrometheusConfigField
470
+ value={editedConfig.prometheusUrl ?? ''}
471
+ configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
472
+ onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
473
+ onApplied={(url) =>
474
+ setConfigData((prev) =>
475
+ prev ? { ...prev, file: { ...prev.file, prometheusUrl: url || undefined } } : prev
476
+ )
477
+ }
478
+ />
479
+ </SectionPane>
480
+
481
+ {/* Argo CD — live */}
482
+ <SectionPane
483
+ id="argocd"
484
+ active={section}
485
+ title="Argo CD"
486
+ caption="Applies immediately — no restart."
487
+ live
488
+ locked={!canEditConfig}
489
+ >
490
+ <ArgoCDConfigField
491
+ url={editedConfig.argoCdUrl ?? ''}
492
+ insecureTls={editedConfig.argoCdInsecureTls ?? false}
493
+ tokenSet={configData?.argoCdTokenSet ?? false}
494
+ envManaged={configData?.argoCdEnvManaged ?? false}
495
+ envError={configData?.argoCdEnvError}
496
+ cliSession={configData?.argoCdCliSession}
497
+ onChangeUrl={(v) => updateConfigField('argoCdUrl', v || undefined)}
498
+ onChangeInsecureTls={(v) => updateConfigField('argoCdInsecureTls', v || undefined)}
499
+ onApplied={({ url, insecureTls, tokenSet }) =>
500
+ setConfigData((prev) =>
501
+ prev
502
+ ? {
503
+ ...prev,
504
+ file: { ...prev.file, argoCdUrl: url || undefined, argoCdInsecureTls: insecureTls || undefined },
505
+ argoCdTokenSet: tokenSet,
506
+ }
507
+ : prev
508
+ )
509
+ }
510
+ />
511
+ </SectionPane>
512
+
513
+ {/* AI diagnose — self-saving, usable by everyone. Same heading block
514
+ as every other tab; the body is the agent controls (when a CLI is
515
+ installed) or an enable explainer (when not). */}
516
+ <div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel">
517
+ <div className="mb-4">
518
+ <h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
519
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
520
+ {diag.hosted
521
+ ? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to explain what's wrong.`
522
+ : "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to explain what's wrong. No Radar cloud, no API key."}
523
+ </p>
524
+ </div>
525
+ {aiAvailable ? (
526
+ <div className="space-y-4">
527
+ <AISettingsSection
528
+ available={diag.available}
529
+ agents={diag.agents}
530
+ hosted={diag.hosted}
531
+ agentLabel={diag.agentLabel}
532
+ draft={aiDraft}
533
+ onChange={(patch) => {
534
+ setAiDraft((d) => ({ ...d, ...patch }))
535
+ setAiSaved(false)
536
+ }}
537
+ onHistoryCleared={diag.refreshRuns}
538
+ />
539
+ {!diag.hosted && (
540
+ <div className="flex items-center justify-end gap-3">
541
+ {aiSaved && !aiDirty && (
542
+ <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
543
+ <Check className="w-3 h-3" />
544
+ Saved
545
+ </span>
546
+ )}
547
+ <button
548
+ onClick={saveAi}
549
+ disabled={!aiDirty}
550
+ className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
551
+ >
552
+ Save
553
+ </button>
554
+ </div>
555
+ )}
556
+ </div>
557
+ ) : (
558
+ <AIUnavailableNotice />
197
559
  )}
198
560
  </div>
199
- <button
200
- onClick={saveConfig}
201
- disabled={saving || !configDirty}
202
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
561
+
562
+ {/* Advanced — MCP + Timeline merged */}
563
+ <SectionPane
564
+ id="advanced"
565
+ active={section}
566
+ title="Advanced"
567
+ caption="Takes effect on next launch."
568
+ locked={!canEditConfig}
203
569
  >
204
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
205
- Save
206
- </button>
570
+ <div className="space-y-4">
571
+ <SubHeading>MCP</SubHeading>
572
+ <MCPSection
573
+ mcpEnabled={editedConfig.mcp ?? true}
574
+ onToggle={(v) => updateConfigField('mcp', v)}
575
+ isDesktop={isDesktop}
576
+ portPinned={editedConfig.port != null && editedConfig.port > 0}
577
+ onPinPort={(port) => updateConfigField('port', port)}
578
+ />
579
+ </div>
580
+ <div className="space-y-4 border-t border-theme-border-subtle pt-4">
581
+ <SubHeading>Timeline</SubHeading>
582
+ <TimelineSection
583
+ config={editedConfig}
584
+ effectiveConfig={configData?.effective}
585
+ onChange={updateConfigField}
586
+ />
587
+ </div>
588
+ </SectionPane>
207
589
  </div>
590
+ </div>
591
+
592
+ {/* Footer — owner-gated. Startup config only: AI self-saves, integrations
593
+ apply live. Shown whenever a startup edit is pending (any section),
594
+ while confirming a close, or briefly after a save. */}
595
+ <div
596
+ className={clsx(
597
+ 'shrink-0 overflow-hidden transition-all duration-200 ease-out',
598
+ showFooter ? 'max-h-24 opacity-100 border-t border-theme-border' : 'max-h-0 opacity-0 pointer-events-none'
599
+ )}
600
+ >
601
+ <div className="flex items-center justify-between gap-3 px-4 py-2.5">
602
+ {confirmingClose ? (
603
+ <>
604
+ <span className="text-xs text-theme-text-secondary">Unsaved changes.</span>
605
+ <div className="flex items-center gap-2">
606
+ <button
607
+ onClick={() => setConfirmingClose(false)}
608
+ disabled={saving}
609
+ 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"
610
+ >
611
+ Keep editing
612
+ </button>
613
+ <button
614
+ onClick={onClose}
615
+ disabled={saving}
616
+ 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"
617
+ >
618
+ Discard
619
+ </button>
620
+ <button
621
+ onClick={handleSaveAndClose}
622
+ disabled={saving}
623
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
624
+ >
625
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
626
+ Save
627
+ </button>
628
+ </div>
629
+ </>
630
+ ) : (
631
+ <>
632
+ <div className="flex items-center gap-2">
633
+ <Tooltip content="Discard unsaved changes and revert to the last saved values">
634
+ <button
635
+ onClick={discardChanges}
636
+ disabled={saving || !startupDirty}
637
+ 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"
638
+ >
639
+ <RotateCcw className="w-3.5 h-3.5" />
640
+ Discard changes
641
+ </button>
642
+ </Tooltip>
643
+ {saveMessage && (
644
+ <span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
645
+ {saveMessage}
646
+ </span>
647
+ )}
648
+ </div>
649
+ <button
650
+ onClick={saveConfig}
651
+ disabled={saving || !startupDirty}
652
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
653
+ >
654
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
655
+ Save
656
+ </button>
657
+ </>
658
+ )}
659
+ </div>
660
+ </div>
208
661
  </div>
209
662
  </div>,
210
663
  document.body
211
664
  )
212
665
  }
213
666
 
214
- // -- Startup Configuration Tab ------------------------------------------------
667
+ // -- Sidebar primitives -------------------------------------------------------
215
668
 
216
- function StartupConfigTab({
669
+ interface NavItemDef {
670
+ id: SectionId
671
+ label: string
672
+ icon: LucideIcon
673
+ ownerOnly: boolean
674
+ dirty: boolean
675
+ }
676
+
677
+ // Light subheading separating the two field groups inside a merged pane
678
+ // (Cluster/Server, MCP/Timeline).
679
+ function SubHeading({ children }: { children: ReactNode }) {
680
+ return (
681
+ <h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
682
+ {children}
683
+ </h4>
684
+ )
685
+ }
686
+
687
+ function NavItem({
688
+ item,
689
+ active,
690
+ disabled,
691
+ horizontal,
692
+ onSelect,
693
+ }: {
694
+ item: NavItemDef
695
+ active: boolean
696
+ disabled: boolean
697
+ horizontal?: boolean
698
+ onSelect: () => void
699
+ }) {
700
+ const Icon = item.icon
701
+ const button = (
702
+ <button
703
+ type="button"
704
+ role="tab"
705
+ aria-selected={active}
706
+ onClick={onSelect}
707
+ disabled={disabled}
708
+ className={clsx(
709
+ 'group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors',
710
+ horizontal ? 'shrink-0' : 'w-full text-left',
711
+ active
712
+ ? 'bg-theme-active text-theme-text-primary font-medium'
713
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
714
+ disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent hover:text-theme-text-secondary'
715
+ )}
716
+ >
717
+ <Icon className="w-4 h-4 shrink-0" />
718
+ <span className={clsx('truncate', !horizontal && 'flex-1')}>{item.label}</span>
719
+ {disabled ? (
720
+ <Lock className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
721
+ ) : item.dirty ? (
722
+ <Tooltip content="Unsaved changes" delay={200} wrapperClassName="flex shrink-0">
723
+ <span className="w-1.5 h-1.5 rounded-full bg-accent" />
724
+ </Tooltip>
725
+ ) : null}
726
+ </button>
727
+ )
728
+ return disabled ? (
729
+ <Tooltip content="Owner access required" delay={200} wrapperClassName={horizontal ? 'shrink-0' : 'w-full'}>
730
+ {button}
731
+ </Tooltip>
732
+ ) : (
733
+ button
734
+ )
735
+ }
736
+
737
+ // -- Section shell ------------------------------------------------------------
738
+
739
+ // A section stays MOUNTED (visibility toggled) so local draft state in the live
740
+ // integration cards survives switching sidebar items. The caption states the
741
+ // section's honest apply semantics.
742
+ function SectionPane({
743
+ id,
744
+ active,
745
+ title,
746
+ caption,
747
+ live,
748
+ locked,
749
+ children,
750
+ }: {
751
+ id: SectionId
752
+ active: SectionId
753
+ title: string
754
+ caption?: string
755
+ live?: boolean
756
+ locked?: boolean
757
+ children: ReactNode
758
+ }) {
759
+ return (
760
+ <div className={clsx(active !== id && 'hidden')} role="tabpanel">
761
+ <div className="mb-4">
762
+ <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
763
+ {!locked && caption && <SectionCaption live={live}>{caption}</SectionCaption>}
764
+ </div>
765
+ {locked ? <LockWall /> : <div className="space-y-4">{children}</div>}
766
+ </div>
767
+ )
768
+ }
769
+
770
+ function SectionCaption({ children, live }: { children: ReactNode; live?: boolean }) {
771
+ return (
772
+ <p
773
+ className={clsx(
774
+ 'mt-1 flex items-center gap-1.5 text-xs',
775
+ live ? 'text-theme-text-secondary' : 'text-theme-text-tertiary'
776
+ )}
777
+ >
778
+ {live ? <Zap className="w-3 h-3 shrink-0" /> : <RotateCw className="w-3 h-3 shrink-0" />}
779
+ {children}
780
+ </p>
781
+ )
782
+ }
783
+
784
+ function LockWall() {
785
+ return (
786
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
787
+ <Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
788
+ <div className="min-w-0">
789
+ <p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
790
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
791
+ These settings (kubeconfig, server port, timeline, integrations) affect
792
+ every user of this Radar instance, so they're limited to owners. Ask an
793
+ owner if you need a change here.
794
+ </p>
795
+ </div>
796
+ </div>
797
+ )
798
+ }
799
+
800
+ // -- Overview (landing) -------------------------------------------------------
801
+
802
+ type OverviewTone = 'ok' | 'warn' | 'off' | 'unknown'
803
+
804
+ interface OverviewRow {
805
+ id: SectionId
806
+ icon: LucideIcon
807
+ label: string
808
+ tone: OverviewTone
809
+ value: string
810
+ detail?: string
811
+ copyable?: boolean
812
+ }
813
+
814
+ // OverviewPanel is the Settings landing: a status-at-a-glance of what Radar is
815
+ // connected to right now. Each row navigates to the section that manages it.
816
+ // The Argo status query is gated on `active` (it triggers a background reconnect
817
+ // probe, so we don't want it firing when Settings opens on another section);
818
+ // cluster and Prometheus status are shared app-wide caches, so they're read
819
+ // unconditionally.
820
+ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s: SectionId) => void }) {
821
+ const { data: cluster } = useClusterInfo()
822
+ const { data: prom } = usePrometheusStatus()
823
+ const { data: argo } = useArgoStatus(active)
824
+ const { data: version } = useVersionCheck()
825
+ const capabilities = useCapabilitiesContext()
826
+ const diag = useDiagnose()
827
+ const [copied, setCopied] = useState(false)
828
+
829
+ const aiAvailable = diag.available && diag.agents.length > 0
830
+ const agentLabel =
831
+ diag.agents.find((a) => a.name === diag.selectedAgent)?.label ?? diag.agents[0]?.label
832
+ const mcpOn = capabilities.mcpEnabled
833
+ const port = Number(window.location.port) || 80
834
+ const mcpUrl = `http://localhost:${port}/mcp`
835
+
836
+ const rows: OverviewRow[] = [
837
+ {
838
+ id: 'connection', icon: Boxes, label: 'Cluster',
839
+ tone: cluster ? 'ok' : 'unknown',
840
+ value: cluster?.context ?? 'Connecting…',
841
+ detail: cluster ? `Kubernetes ${cluster.kubernetesVersion} · ${cluster.nodeCount} nodes` : undefined,
842
+ },
843
+ {
844
+ id: 'prometheus', icon: Activity, label: 'Prometheus',
845
+ tone: prom?.connected ? 'ok' : prom?.available ? 'warn' : 'off',
846
+ value: prom?.connected ? 'Connected' : prom?.available ? 'Not reachable' : 'Not configured',
847
+ detail: prom?.connected ? prom.address : undefined,
848
+ },
849
+ {
850
+ id: 'argocd', icon: GitBranch, label: 'Argo CD',
851
+ tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
852
+ // Configured-but-not-connected is often a permanently rejected/expired
853
+ // token, not a transient reconnect — "Not reachable" matches Prometheus and
854
+ // doesn't imply it will recover on its own.
855
+ value: argo?.connected ? 'Connected' : argo?.configured ? 'Not reachable' : 'Not connected',
856
+ detail: argo?.connected ? argo.address : undefined,
857
+ },
858
+ {
859
+ id: 'advanced', icon: Zap, label: 'MCP',
860
+ tone: mcpOn ? 'ok' : 'off',
861
+ value: mcpOn ? 'On' : 'Off',
862
+ detail: mcpOn ? mcpUrl : undefined,
863
+ copyable: mcpOn,
864
+ },
865
+ {
866
+ id: 'ai', icon: Sparkles, label: 'AI diagnose',
867
+ tone: aiAvailable ? 'ok' : 'off',
868
+ value: aiAvailable ? 'Ready' : 'No agent CLI',
869
+ detail: aiAvailable ? agentLabel : undefined,
870
+ },
871
+ ]
872
+
873
+ const copyMcp = () => {
874
+ navigator.clipboard.writeText(mcpUrl)
875
+ setCopied(true)
876
+ setTimeout(() => setCopied(false), 2000)
877
+ }
878
+
879
+ return (
880
+ <div className="space-y-4">
881
+ {version?.updateAvailable && (
882
+ <a
883
+ href={version.releaseUrl}
884
+ target="_blank"
885
+ rel="noreferrer"
886
+ 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"
887
+ >
888
+ <Download className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
889
+ <span className="flex-1 text-theme-text-primary">
890
+ Radar {version.latestVersion} is available
891
+ <span className="text-theme-text-tertiary"> — you're on {version.currentVersion}</span>
892
+ </span>
893
+ <ExternalLink className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
894
+ </a>
895
+ )}
896
+
897
+ <div className="rounded-md border border-theme-border divide-y divide-theme-border-subtle overflow-hidden">
898
+ {rows.map((row) => {
899
+ const Icon = row.icon
900
+ return (
901
+ <div
902
+ key={row.label}
903
+ role="button"
904
+ tabIndex={0}
905
+ onClick={() => onNavigate(row.id)}
906
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(row.id) } }}
907
+ className="group flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-theme-hover transition-colors"
908
+ >
909
+ <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
910
+ <span className="text-sm text-theme-text-primary w-24 shrink-0 truncate">{row.label}</span>
911
+ <OverviewStatus tone={row.tone} />
912
+ <div className="flex-1 min-w-0 flex items-baseline gap-1.5">
913
+ <span className="text-sm text-theme-text-secondary shrink-0">{row.value}</span>
914
+ {row.detail && (
915
+ <span className="text-xs text-theme-text-tertiary truncate">{row.detail}</span>
916
+ )}
917
+ </div>
918
+ {row.copyable && (
919
+ <Tooltip content="Copy MCP URL" wrapperClassName="shrink-0">
920
+ <button
921
+ onClick={(e) => { e.stopPropagation(); copyMcp() }}
922
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded transition-colors"
923
+ >
924
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
925
+ </button>
926
+ </Tooltip>
927
+ )}
928
+ <ChevronRight className="w-4 h-4 shrink-0 text-theme-text-disabled group-hover:text-theme-text-tertiary" />
929
+ </div>
930
+ )
931
+ })}
932
+ </div>
933
+ </div>
934
+ )
935
+ }
936
+
937
+ function OverviewStatus({ tone }: { tone: OverviewTone }) {
938
+ const cls =
939
+ tone === 'ok' ? 'bg-green-500'
940
+ : tone === 'warn' ? 'bg-amber-500'
941
+ : tone === 'unknown' ? 'bg-theme-text-tertiary animate-pulse'
942
+ : 'bg-theme-text-disabled'
943
+ return <span className={clsx('w-2 h-2 rounded-full shrink-0', cls)} />
944
+ }
945
+
946
+ // AIUnavailableNotice is the body of the AI diagnose tab when no supported agent
947
+ // CLI is installed — the heading/description are provided by the tab itself, so
948
+ // this is just the enable explainer (keeping the feature discoverable to whoever
949
+ // would set it up).
950
+ function AIUnavailableNotice() {
951
+ return (
952
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
953
+ <p className="text-sm font-medium text-theme-text-primary">No supported agent CLI found</p>
954
+ <p className="mt-1 text-xs text-theme-text-tertiary">
955
+ Install <span className="text-theme-text-secondary">Claude Code</span> or{' '}
956
+ <span className="text-theme-text-secondary">Codex</span>, then restart Radar — this tab
957
+ will show the agent, model, and effort controls.
958
+ </p>
959
+ </div>
960
+ )
961
+ }
962
+
963
+ // -- Startup section bodies ---------------------------------------------------
964
+
965
+ function ClusterSection({
217
966
  config,
218
967
  effectiveConfig,
219
- isDesktop,
220
968
  onChange,
221
969
  }: {
222
970
  config: Config
223
971
  effectiveConfig?: Config
224
- isDesktop: boolean
225
972
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
226
973
  }) {
227
974
  return (
228
- <div className="space-y-4">
229
- <p className="text-xs text-theme-text-tertiary">
230
- Changes require a restart to take effect.
231
- {isDesktop
232
- ? ' Quit and relaunch Radar to apply.'
233
- : ' Stop and restart the radar command to apply.'}
234
- </p>
235
-
975
+ <>
236
976
  <ConfigField
237
977
  label="Kubeconfig"
238
978
  help="Path to kubeconfig file"
@@ -241,7 +981,6 @@ function StartupConfigTab({
241
981
  placeholder="~/.kube/config"
242
982
  onChange={(v) => onChange('kubeconfig', v || undefined)}
243
983
  />
244
-
245
984
  <ConfigArrayField
246
985
  label="Kubeconfig Directories"
247
986
  help="Comma-separated directories containing kubeconfig files"
@@ -250,16 +989,33 @@ function StartupConfigTab({
250
989
  placeholder="/path/to/dir1, /path/to/dir2"
251
990
  onChange={(v) => onChange('kubeconfigDirs', v)}
252
991
  />
253
-
254
992
  <ConfigField
255
993
  label="Default Namespace"
256
- help="Initial namespace filter on startup"
994
+ help="Startup default only — change the active namespace live anytime from the header switcher"
257
995
  value={config.namespace ?? ''}
258
996
  effectiveValue={effectiveConfig?.namespace}
259
997
  placeholder="All namespaces"
260
998
  onChange={(v) => onChange('namespace', v || undefined)}
261
999
  />
1000
+ </>
1001
+ )
1002
+ }
262
1003
 
1004
+ function ServerSection({
1005
+ config,
1006
+ effectiveConfig,
1007
+ isDesktop,
1008
+ showBrowserLaunchControls,
1009
+ onChange,
1010
+ }: {
1011
+ config: Config
1012
+ effectiveConfig?: Config
1013
+ isDesktop: boolean
1014
+ showBrowserLaunchControls: boolean
1015
+ onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
1016
+ }) {
1017
+ return (
1018
+ <>
263
1019
  <ConfigNumberField
264
1020
  label="Port"
265
1021
  help={isDesktop
@@ -271,67 +1027,63 @@ function StartupConfigTab({
271
1027
  onChange={(v) => onChange('port', v)}
272
1028
  />
273
1029
 
274
- {!isDesktop && (
275
- <ConfigToggle
276
- label="Open browser on start"
277
- value={!(config.noBrowser ?? false)}
278
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
279
- />
280
- )}
281
-
282
- <div className="border-t border-theme-border pt-4 mt-4">
283
- <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">Timeline</h4>
284
-
285
- <div className="space-y-4">
286
- <div>
287
- <label className="block text-sm font-medium text-theme-text-primary mb-1">
288
- Storage Backend
289
- </label>
290
- <select
291
- value={config.timelineStorage ?? 'memory'}
292
- onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
293
- className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary focus:outline-none focus:border-blue-500"
294
- >
295
- <option value="memory">Memory (default)</option>
296
- <option value="sqlite">SQLite (persistent)</option>
297
- </select>
298
- <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
299
- </div>
300
-
301
- <ConfigNumberField
302
- label="History Limit"
303
- help="Maximum events to retain"
304
- value={config.historyLimit}
305
- effectiveValue={effectiveConfig?.historyLimit}
306
- placeholder="10000"
307
- onChange={(v) => onChange('historyLimit', v)}
1030
+ {showBrowserLaunchControls && (
1031
+ <>
1032
+ <ConfigToggle
1033
+ label="Open browser on start"
1034
+ description="Automatically open the Radar UI in your browser when Radar starts — turn off to run headless and open the URL yourself."
1035
+ value={!(config.noBrowser ?? false)}
1036
+ onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
308
1037
  />
309
- </div>
310
- </div>
311
-
312
- <div className="border-t border-theme-border pt-4 mt-4">
313
- <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">Integrations</h4>
314
-
315
- <div className="space-y-4">
316
1038
  <ConfigField
317
- label="Prometheus URL"
318
- help="Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)"
319
- value={config.prometheusUrl ?? ''}
320
- effectiveValue={effectiveConfig?.prometheusUrl}
321
- placeholder="http://prometheus-server.monitoring:9090"
322
- onChange={(v) => onChange('prometheusUrl', v || undefined)}
1039
+ label="Browser"
1040
+ help="Browser to open the UI in — a macOS app name (Google Chrome, Safari) or a Linux/Windows command (google-chrome). Leave blank for your system default."
1041
+ value={config.browser ?? ''}
1042
+ effectiveValue={effectiveConfig?.browser}
1043
+ placeholder="System default"
1044
+ onChange={(v) => onChange('browser', v || undefined)}
323
1045
  />
1046
+ </>
1047
+ )}
1048
+ </>
1049
+ )
1050
+ }
324
1051
 
325
- <MCPSection
326
- mcpEnabled={config.mcp ?? true}
327
- onToggle={(v) => onChange('mcp', v)}
328
- isDesktop={isDesktop}
329
- portPinned={config.port != null && config.port > 0}
330
- onPinPort={(port) => onChange('port', port)}
331
- />
332
- </div>
1052
+ function TimelineSection({
1053
+ config,
1054
+ effectiveConfig,
1055
+ onChange,
1056
+ }: {
1057
+ config: Config
1058
+ effectiveConfig?: Config
1059
+ onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
1060
+ }) {
1061
+ return (
1062
+ <>
1063
+ <div>
1064
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">
1065
+ Storage Backend
1066
+ </label>
1067
+ <select
1068
+ value={config.timelineStorage ?? 'memory'}
1069
+ onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
1070
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary focus:outline-none focus:border-skyhook-500"
1071
+ >
1072
+ <option value="memory">Memory (default)</option>
1073
+ <option value="sqlite">SQLite (persistent)</option>
1074
+ </select>
1075
+ <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
333
1076
  </div>
334
- </div>
1077
+
1078
+ <ConfigNumberField
1079
+ label="History Limit"
1080
+ help="Maximum events to retain"
1081
+ value={config.historyLimit}
1082
+ effectiveValue={effectiveConfig?.historyLimit}
1083
+ placeholder="10000"
1084
+ onChange={(v) => onChange('historyLimit', v)}
1085
+ />
1086
+ </>
335
1087
  )
336
1088
  }
337
1089
 
@@ -367,8 +1119,12 @@ function MCPSection({
367
1119
 
368
1120
  return (
369
1121
  <div className="space-y-3">
1122
+ <p className="text-xs text-theme-text-tertiary">
1123
+ Lets AI tools (Claude Code, Cursor, …) query and act on this cluster through Radar over the
1124
+ Model Context Protocol. Point your agent at the endpoint below.
1125
+ </p>
370
1126
  <ConfigToggle
371
- label="MCP Server (AI tools)"
1127
+ label="MCP Server"
372
1128
  value={mcpEnabled}
373
1129
  onChange={onToggle}
374
1130
  />
@@ -381,13 +1137,14 @@ function MCPSection({
381
1137
  <code className="flex-1 px-2.5 py-1.5 text-xs font-mono bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary truncate">
382
1138
  {mcpUrl}
383
1139
  </code>
1140
+ <Tooltip content="Copy MCP URL" wrapperClassName="shrink-0">
384
1141
  <button
385
1142
  onClick={handleCopy}
386
- className="shrink-0 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors"
387
- title="Copy MCP URL"
1143
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors"
388
1144
  >
389
1145
  {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
390
1146
  </button>
1147
+ </Tooltip>
391
1148
  </div>
392
1149
  </div>
393
1150
 
@@ -417,6 +1174,584 @@ function MCPSection({
417
1174
  )
418
1175
  }
419
1176
 
1177
+ // -- Prometheus (live-appliable) ----------------------------------------------
1178
+
1179
+ // The Prometheus URL can be re-pointed without a restart — the metrics path reads
1180
+ // it from a mutable global. "Apply now" hits PUT /integrations/prometheus, which
1181
+ // persists the URL AND re-points the running client, then probes it so we can
1182
+ // confirm reachability inline. onApplied notifies the parent so the footer's
1183
+ // last-committed snapshot stays in sync (see saveConfig). No EffectiveHint here —
1184
+ // a restart-diff hint would contradict the whole point of applying live.
1185
+ type ApplyState =
1186
+ | { status: 'idle' }
1187
+ | { status: 'applying' }
1188
+ | { status: 'connected'; address: string }
1189
+ | { status: 'unreachable'; error: string } // persisted, but the probe failed
1190
+ | { status: 'failed'; error: string } // request itself failed — nothing saved
1191
+
1192
+ type HeaderRow = { key: string; value: string }
1193
+
1194
+ function PrometheusConfigField({
1195
+ value,
1196
+ onChange,
1197
+ configuredHeaderKeys,
1198
+ onApplied,
1199
+ }: {
1200
+ value: string
1201
+ onChange: (value: string) => void
1202
+ configuredHeaderKeys: string[]
1203
+ onApplied?: (url: string) => void
1204
+ }) {
1205
+ const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
1206
+ // null = not editing headers (preserve what's stored). A non-null array means
1207
+ // the user opened the editor; on Apply we send it verbatim, replacing all
1208
+ // stored headers (values are write-only, so the server never sends them back).
1209
+ const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
1210
+ // Show the server's configured header keys, but let a successful apply override
1211
+ // optimistically (config isn't refetched). Derived from the prop — not a
1212
+ // mount-time snapshot — so it stays correct as config loads asynchronously.
1213
+ const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
1214
+ const storedKeys = appliedKeys ?? configuredHeaderKeys
1215
+
1216
+ const clearStatus = () => {
1217
+ if (apply.status !== 'applying') setApply({ status: 'idle' })
1218
+ }
1219
+
1220
+ // Footer Reset (and any external edit) clears the URL field without a keystroke;
1221
+ // drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
1222
+ useEffect(() => {
1223
+ setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
1224
+ }, [value])
1225
+
1226
+ const handleApply = async () => {
1227
+ setApply({ status: 'applying' })
1228
+ // Decide what to do with headers. undefined = leave them untouched. Only send
1229
+ // a replacement when the editor has real content, or {} when the user emptied
1230
+ // every row (explicit clear) — blank in-progress rows must NOT wipe stored
1231
+ // secrets just because the editor happens to be open for a URL-only change.
1232
+ let editedHeaders: Record<string, string> | undefined
1233
+ if (headerRows !== null) {
1234
+ const entered = Object.fromEntries(
1235
+ headerRows
1236
+ .map((r) => [r.key.trim(), r.value] as const)
1237
+ .filter(([k, v]) => k !== '' && v !== '')
1238
+ )
1239
+ if (Object.keys(entered).length > 0) editedHeaders = entered
1240
+ else if (headerRows.length === 0) editedHeaders = {}
1241
+ }
1242
+ try {
1243
+ const res = await fetch(apiUrl('/integrations/prometheus'), {
1244
+ method: 'PUT',
1245
+ credentials: getCredentialsMode(),
1246
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
1247
+ body: JSON.stringify({
1248
+ prometheusUrl: value.trim(),
1249
+ ...(editedHeaders !== undefined ? { headers: editedHeaders } : {}),
1250
+ }),
1251
+ })
1252
+ const data = await res.json().catch(() => null)
1253
+ if (!res.ok) {
1254
+ setApply({ status: 'failed', error: data?.error || res.statusText })
1255
+ return
1256
+ }
1257
+ onApplied?.(value.trim())
1258
+ if (editedHeaders !== undefined) {
1259
+ setAppliedKeys(Object.keys(editedHeaders).sort())
1260
+ }
1261
+ if (headerRows !== null) {
1262
+ setHeaderRows(null)
1263
+ }
1264
+ if (data?.connected) {
1265
+ setApply({ status: 'connected', address: data.address || value.trim() })
1266
+ } else {
1267
+ setApply({ status: 'unreachable', error: data?.error || 'not reachable' })
1268
+ }
1269
+ } catch (err) {
1270
+ setApply({ status: 'failed', error: String(err) })
1271
+ }
1272
+ }
1273
+
1274
+ return (
1275
+ <div>
1276
+ <p className="text-xs text-theme-text-tertiary mb-3">
1277
+ Powers the CPU / memory graphs, usage history, and rightsizing hints on workload and node pages.
1278
+ </p>
1279
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">
1280
+ Server URL
1281
+ </label>
1282
+ <p className="text-xs text-theme-text-tertiary mb-1">
1283
+ Manual Prometheus / VictoriaMetrics URL — set this to skip auto-discovery.
1284
+ </p>
1285
+ <div className="flex items-center gap-2">
1286
+ <Input
1287
+ value={value}
1288
+ onChange={(e) => onChange(e.target.value)}
1289
+ placeholder="http://prometheus-server.monitoring:9090"
1290
+ 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"
1291
+ />
1292
+ <Tooltip content="Apply this URL to the running server now — no restart" wrapperClassName="shrink-0">
1293
+ <button
1294
+ onClick={handleApply}
1295
+ disabled={apply.status === 'applying'}
1296
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
1297
+ >
1298
+ {apply.status === 'applying'
1299
+ ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
1300
+ : <Plug className="w-3.5 h-3.5" />}
1301
+ Apply now
1302
+ </button>
1303
+ </Tooltip>
1304
+ </div>
1305
+ {apply.status === 'connected' ? (
1306
+ <p className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1307
+ <Check className="w-3 h-3 shrink-0" />
1308
+ Connected to {apply.address} — applied, no restart needed
1309
+ </p>
1310
+ ) : apply.status === 'unreachable' ? (
1311
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
1312
+ Saved, but not reachable: {apply.error}
1313
+ </p>
1314
+ ) : apply.status === 'failed' ? (
1315
+ <p className="mt-1 text-xs text-red-600 dark:text-red-400/80">
1316
+ Couldn't apply: {apply.error}
1317
+ </p>
1318
+ ) : (
1319
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1320
+ Applies immediately — no restart needed.
1321
+ </p>
1322
+ )}
1323
+
1324
+ {/* Auth headers — for token / multi-tenant backends (Bearer, X-Scope-OrgID). */}
1325
+ <div className="mt-3">
1326
+ {headerRows === null ? (
1327
+ <div className="flex items-center justify-between gap-2">
1328
+ <span className="text-xs text-theme-text-tertiary">
1329
+ {storedKeys.length > 0
1330
+ ? <>Auth headers: <span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> <span className="text-theme-text-disabled">(values hidden)</span></>
1331
+ : 'No auth headers'}
1332
+ </span>
1333
+ <button
1334
+ onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
1335
+ className="shrink-0 text-xs font-medium text-accent-text hover:underline"
1336
+ >
1337
+ {storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
1338
+ </button>
1339
+ </div>
1340
+ ) : (
1341
+ <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
1342
+ {headerRows.map((row, i) => (
1343
+ <div key={i} className="flex items-center gap-2">
1344
+ <Input
1345
+ value={row.key}
1346
+ onChange={(e) => {
1347
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
1348
+ clearStatus()
1349
+ }}
1350
+ placeholder="Header (e.g. Authorization)"
1351
+ 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"
1352
+ />
1353
+ <input
1354
+ type="password"
1355
+ value={row.value}
1356
+ onChange={(e) => {
1357
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
1358
+ clearStatus()
1359
+ }}
1360
+ placeholder="Value (e.g. Bearer …)"
1361
+ 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"
1362
+ />
1363
+ <Tooltip content="Remove header" wrapperClassName="shrink-0">
1364
+ <button
1365
+ onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
1366
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
1367
+ >
1368
+ <X className="w-3.5 h-3.5" />
1369
+ </button>
1370
+ </Tooltip>
1371
+ </div>
1372
+ ))}
1373
+ <div className="flex items-center justify-between gap-2">
1374
+ <button
1375
+ onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
1376
+ className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
1377
+ >
1378
+ <Plus className="w-3 h-3" /> Add header
1379
+ </button>
1380
+ <button
1381
+ onClick={() => { setHeaderRows(null); clearStatus() }}
1382
+ className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
1383
+ >
1384
+ Cancel
1385
+ </button>
1386
+ </div>
1387
+ <p className="text-xs text-theme-text-tertiary">
1388
+ Saved when you click Apply now. Entered headers replace all stored
1389
+ ones — values are hidden, so re-enter any you want to keep. Leave
1390
+ blank to keep existing headers unchanged.
1391
+ </p>
1392
+ </div>
1393
+ )}
1394
+ </div>
1395
+ </div>
1396
+ )
1397
+ }
1398
+
1399
+ // -- Argo CD (live-appliable) -------------------------------------------------
1400
+
1401
+ // The Argo CD integration powers the full Git-rendered desired-vs-live diff on
1402
+ // GitOps Application pages. Like Prometheus, it applies to the running server
1403
+ // without a restart: both actions PUT /integrations/argocd, which persists the
1404
+ // settings AND re-points the running client, then verifies reachability (the
1405
+ // server returns 400 distinguishing unreachable from an invalid token). onApplied
1406
+ // notifies the parent so the footer's last-committed snapshot stays in sync. The
1407
+ // token is write-only — never returned, and omitted from the PUT unless the user
1408
+ // types a new value or clicks Clear.
1409
+ type ArgoState =
1410
+ | { status: 'idle' }
1411
+ | { status: 'connecting' }
1412
+ | { status: 'connected' }
1413
+ | { status: 'error'; error: string }
1414
+
1415
+ function ArgoCDConfigField({
1416
+ url,
1417
+ insecureTls,
1418
+ tokenSet,
1419
+ envManaged,
1420
+ envError,
1421
+ cliSession,
1422
+ onChangeUrl,
1423
+ onChangeInsecureTls,
1424
+ onApplied,
1425
+ }: {
1426
+ url: string
1427
+ insecureTls: boolean
1428
+ tokenSet: boolean
1429
+ envManaged?: boolean
1430
+ envError?: string
1431
+ cliSession?: { server: string; user: string; insecure?: boolean }
1432
+ onChangeUrl: (value: string) => void
1433
+ onChangeInsecureTls: (value: boolean) => void
1434
+ onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
1435
+ }) {
1436
+ if (envManaged) {
1437
+ return <ArgoCDEnvManagedField url={url} insecureTls={insecureTls} envError={envError} />
1438
+ }
1439
+ return (
1440
+ <ArgoCDEditableField
1441
+ url={url}
1442
+ insecureTls={insecureTls}
1443
+ tokenSet={tokenSet}
1444
+ cliSession={cliSession}
1445
+ onChangeUrl={onChangeUrl}
1446
+ onChangeInsecureTls={onChangeInsecureTls}
1447
+ onApplied={onApplied}
1448
+ />
1449
+ )
1450
+ }
1451
+
1452
+ // Read-only card for the environment-managed integration: the token/URL/TLS come
1453
+ // from the deployment (RADAR_ARGOCD_TOKEN / _TOKEN_FILE / _URL), so editing here
1454
+ // is disabled — the server refuses the PUT. Shows the effective endpoint and points
1455
+ // the operator at the deployment for changes.
1456
+ function ArgoCDEnvManagedField({
1457
+ url,
1458
+ insecureTls,
1459
+ envError,
1460
+ }: {
1461
+ url: string
1462
+ insecureTls: boolean
1463
+ envError?: string
1464
+ }) {
1465
+ if (envError) {
1466
+ return (
1467
+ <div>
1468
+ <p className="text-xs text-theme-text-tertiary mb-3">
1469
+ Powers the full Git-rendered desired-vs-live diff on GitOps Application pages — what Git
1470
+ declares vs what&apos;s actually running.
1471
+ </p>
1472
+ <div className="rounded-md border border-red-500/30 bg-red-500/[0.07] p-3">
1473
+ <p className="flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400/90">
1474
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
1475
+ Environment Argo CD configuration is invalid
1476
+ </p>
1477
+ <p className="mt-1 text-xs text-theme-text-secondary break-words">{envError}</p>
1478
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1479
+ The deployment sets <code className="inline-code">RADAR_ARGOCD_TOKEN</code> (or{' '}
1480
+ <code className="inline-code">RADAR_ARGOCD_TOKEN_FILE</code>), but it couldn&apos;t be
1481
+ used, so the deep diff is disabled. Fix the deployment&apos;s environment or Secret and
1482
+ restart; check the Radar pod logs for details.
1483
+ </p>
1484
+ </div>
1485
+ </div>
1486
+ )
1487
+ }
1488
+ return (
1489
+ <div>
1490
+ <p className="text-xs text-theme-text-tertiary mb-3">
1491
+ Powers the full Git-rendered desired-vs-live diff on GitOps Application pages — what Git
1492
+ declares vs what&apos;s actually running.
1493
+ </p>
1494
+ <div className="rounded-md border border-skyhook-500/30 bg-skyhook-500/[0.07] p-3">
1495
+ <p className="flex items-center gap-1.5 text-sm font-medium text-theme-text-primary">
1496
+ <Terminal className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
1497
+ Configured from the environment
1498
+ </p>
1499
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1500
+ The token is provided by the deployment via{' '}
1501
+ <code className="inline-code">RADAR_ARGOCD_TOKEN</code> (or{' '}
1502
+ <code className="inline-code">RADAR_ARGOCD_TOKEN_FILE</code>), so this integration is
1503
+ read-only here. Edit the deployment&apos;s environment or Secret and restart to change it.
1504
+ </p>
1505
+ <dl className="mt-3 space-y-1 text-xs">
1506
+ <div className="flex gap-2">
1507
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">Server</dt>
1508
+ <dd className="min-w-0 truncate text-theme-text-secondary">
1509
+ {url || 'auto-discovered in this cluster'}
1510
+ </dd>
1511
+ </div>
1512
+ <div className="flex gap-2">
1513
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">Token</dt>
1514
+ <dd className="text-theme-text-secondary">provided via environment</dd>
1515
+ </div>
1516
+ {insecureTls && (
1517
+ <div className="flex gap-2">
1518
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">TLS</dt>
1519
+ <dd className="text-amber-600 dark:text-amber-400/80">verification skipped</dd>
1520
+ </div>
1521
+ )}
1522
+ </dl>
1523
+ </div>
1524
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1525
+ If the diff isn&apos;t loading, verify the token is valid for this cluster&apos;s Argo CD and
1526
+ check the Radar pod logs.
1527
+ </p>
1528
+ </div>
1529
+ )
1530
+ }
1531
+
1532
+ function ArgoCDEditableField({
1533
+ url,
1534
+ insecureTls,
1535
+ tokenSet,
1536
+ cliSession,
1537
+ onChangeUrl,
1538
+ onChangeInsecureTls,
1539
+ onApplied,
1540
+ }: {
1541
+ url: string
1542
+ insecureTls: boolean
1543
+ tokenSet: boolean
1544
+ cliSession?: { server: string; user: string; insecure?: boolean }
1545
+ onChangeUrl: (value: string) => void
1546
+ onChangeInsecureTls: (value: boolean) => void
1547
+ onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
1548
+ }) {
1549
+ const [state, setState] = useState<ArgoState>({ status: 'idle' })
1550
+ // Token editor three-way state: `touched` = user typed a value (replaces the
1551
+ // stored token); `cleared` = user hit Clear (send "" to wipe it); neither =
1552
+ // leave the stored token untouched (omit from the PUT).
1553
+ const [token, setToken] = useState('')
1554
+ const [tokenTouched, setTokenTouched] = useState(false)
1555
+ const [tokenCleared, setTokenCleared] = useState(false)
1556
+ // Optimistic "is a token stored" after a successful apply (config isn't
1557
+ // refetched), so the placeholder reflects the new reality without a reopen.
1558
+ const [tokenSetLocal, setTokenSetLocal] = useState<boolean | null>(null)
1559
+ const effectiveTokenSet = tokenSetLocal ?? tokenSet
1560
+
1561
+ const clearStatus = () => setState((s) => (s.status === 'connecting' ? s : { status: 'idle' }))
1562
+
1563
+ // Drop a stale "Connected"/error line when the URL changes out from under us
1564
+ // (footer Reset, external edit) so it doesn't describe an emptied field.
1565
+ useEffect(() => {
1566
+ setState((s) => (s.status === 'idle' || s.status === 'connecting' ? s : { status: 'idle' }))
1567
+ }, [url])
1568
+
1569
+ const put = async (body: Record<string, unknown>, resultingTokenSet?: boolean) => {
1570
+ setState({ status: 'connecting' })
1571
+ try {
1572
+ const res = await fetch(apiUrl('/integrations/argocd'), {
1573
+ method: 'PUT',
1574
+ credentials: getCredentialsMode(),
1575
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
1576
+ body: JSON.stringify(body),
1577
+ })
1578
+ const data = await res.json().catch(() => null)
1579
+ if (!res.ok) {
1580
+ setState({ status: 'error', error: data?.error || res.statusText })
1581
+ return
1582
+ }
1583
+ // Reset the token editor so the field reflects the now-stored token.
1584
+ setToken('')
1585
+ setTokenTouched(false)
1586
+ setTokenCleared(false)
1587
+ if (resultingTokenSet !== undefined) setTokenSetLocal(resultingTokenSet)
1588
+ onApplied?.({
1589
+ url: (body.argoCdUrl as string) ?? '',
1590
+ insecureTls: (body.argoCdInsecureTls as boolean) ?? false,
1591
+ tokenSet: resultingTokenSet ?? effectiveTokenSet,
1592
+ })
1593
+ setState({ status: 'connected' })
1594
+ } catch (err) {
1595
+ setState({ status: 'error', error: String(err) })
1596
+ }
1597
+ }
1598
+
1599
+ const handleConnect = () => {
1600
+ let argoCdToken: string | undefined
1601
+ let resultingTokenSet = effectiveTokenSet
1602
+ if (tokenCleared) {
1603
+ argoCdToken = ''
1604
+ resultingTokenSet = false
1605
+ } else if (tokenTouched && token !== '') {
1606
+ argoCdToken = token
1607
+ resultingTokenSet = true
1608
+ }
1609
+ put(
1610
+ {
1611
+ argoCdUrl: url.trim(),
1612
+ argoCdInsecureTls: insecureTls,
1613
+ ...(argoCdToken !== undefined ? { argoCdToken } : {}),
1614
+ },
1615
+ resultingTokenSet,
1616
+ )
1617
+ }
1618
+
1619
+ const handleUseCliToken = () => {
1620
+ // Only reachable from the "Use this session" button, which renders solely
1621
+ // when a session was detected. The detected session is for its own server
1622
+ // (which may differ from the URL field) — connect to that server with its TLS
1623
+ // mode (Argo CD is https; the insecure flag covers self-signed) and reflect
1624
+ // it in the form. An explicit URL is required: the server rejects useCliToken
1625
+ // with an empty URL rather than routing the CLI token to a discovered server.
1626
+ if (!cliSession) return
1627
+ const sessionUrl = /^https?:\/\//i.test(cliSession.server)
1628
+ ? cliSession.server
1629
+ : `https://${cliSession.server}`
1630
+ const insecure = cliSession.insecure ?? false
1631
+ onChangeUrl(sessionUrl)
1632
+ onChangeInsecureTls(insecure)
1633
+ put({ argoCdUrl: sessionUrl, argoCdInsecureTls: insecure, useCliToken: true }, true)
1634
+ }
1635
+
1636
+ const showConfiguredPlaceholder = effectiveTokenSet && !tokenTouched && !tokenCleared
1637
+ const connecting = state.status === 'connecting'
1638
+
1639
+ return (
1640
+ <div>
1641
+ <p className="text-xs text-theme-text-tertiary mb-3">
1642
+ Connect your Argo CD server for the full Git-rendered desired-vs-live diff on GitOps
1643
+ Application pages — what Git declares vs what's actually running. Without it, Radar falls
1644
+ back to a lighter annotation-based drift that can miss fields.
1645
+ </p>
1646
+
1647
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">Server URL</label>
1648
+ <p className="text-xs text-theme-text-tertiary mb-1">
1649
+ Leave blank to auto-discover the argocd-server in this cluster, or enter its API URL.
1650
+ </p>
1651
+ <Input
1652
+ value={url}
1653
+ onChange={(e) => { onChangeUrl(e.target.value); clearStatus() }}
1654
+ placeholder="auto-discover argocd-server"
1655
+ 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"
1656
+ />
1657
+
1658
+ {cliSession && (
1659
+ <div className="mt-3 rounded-md border border-skyhook-500/30 bg-skyhook-500/[0.07] p-3">
1660
+ <div className="flex items-start justify-between gap-3">
1661
+ <div className="min-w-0">
1662
+ <p className="flex items-center gap-1.5 text-sm font-medium text-theme-text-primary">
1663
+ <Terminal className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
1664
+ Argo CD CLI session found
1665
+ </p>
1666
+ <p className="mt-0.5 truncate text-xs text-theme-text-tertiary">
1667
+ {cliSession.user && cliSession.user !== cliSession.server ? (
1668
+ <><span className="text-theme-text-secondary">{cliSession.user}</span> @ {cliSession.server}</>
1669
+ ) : (
1670
+ <span className="text-theme-text-secondary">{cliSession.server}</span>
1671
+ )}
1672
+ </p>
1673
+ </div>
1674
+ <button
1675
+ onClick={handleUseCliToken}
1676
+ disabled={connecting}
1677
+ className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
1678
+ >
1679
+ {connecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plug className="w-3.5 h-3.5" />}
1680
+ Use this session
1681
+ </button>
1682
+ </div>
1683
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
1684
+ Reuses your <code className="inline-code">argocd login</code> token — nothing to generate or paste.
1685
+ </p>
1686
+ </div>
1687
+ )}
1688
+
1689
+ <label className="block text-sm font-medium text-theme-text-primary mt-3 mb-1">
1690
+ {cliSession ? 'Or paste a token' : 'Auth token'}
1691
+ </label>
1692
+ <p className="text-xs text-theme-text-tertiary mb-1">
1693
+ Lets Radar read your applications. Create one with{' '}
1694
+ <code className="inline-code">argocd account generate-token</code>, or in the Argo CD UI under
1695
+ Settings → Accounts.
1696
+ </p>
1697
+ <div className="flex items-center gap-2">
1698
+ <input
1699
+ type="password"
1700
+ value={showConfiguredPlaceholder ? '' : token}
1701
+ onChange={(e) => { setToken(e.target.value); setTokenTouched(true); setTokenCleared(false); clearStatus() }}
1702
+ placeholder={showConfiguredPlaceholder ? '•••• configured' : 'Argo CD auth token'}
1703
+ 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"
1704
+ />
1705
+ {effectiveTokenSet && !tokenCleared && (
1706
+ <button
1707
+ onClick={() => { setToken(''); setTokenTouched(false); setTokenCleared(true); clearStatus() }}
1708
+ 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"
1709
+ >
1710
+ Clear
1711
+ </button>
1712
+ )}
1713
+ </div>
1714
+ {tokenCleared && (
1715
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be cleared on save.</p>
1716
+ )}
1717
+
1718
+ <label className="mt-2 flex items-center gap-2 cursor-pointer">
1719
+ <input
1720
+ type="checkbox"
1721
+ checked={insecureTls}
1722
+ onChange={(e) => { onChangeInsecureTls(e.target.checked); clearStatus() }}
1723
+ className="h-3.5 w-3.5 accent-skyhook-600"
1724
+ />
1725
+ <span className="text-xs text-theme-text-secondary">Skip TLS verification (self-signed Argo CD server)</span>
1726
+ </label>
1727
+
1728
+ <div className="mt-3">
1729
+ <button
1730
+ onClick={handleConnect}
1731
+ disabled={connecting}
1732
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
1733
+ >
1734
+ {connecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plug className="w-3.5 h-3.5" />}
1735
+ Connect &amp; save
1736
+ </button>
1737
+ </div>
1738
+
1739
+ {state.status === 'connected' ? (
1740
+ <p className="mt-2 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1741
+ <Check className="w-3 h-3 shrink-0" />
1742
+ Connected to Argo CD — applied, no restart needed
1743
+ </p>
1744
+ ) : state.status === 'error' ? (
1745
+ <p className="mt-2 text-xs text-red-600 dark:text-red-400/80">{state.error}</p>
1746
+ ) : (
1747
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1748
+ Connecting verifies the URL and token before anything is saved.
1749
+ </p>
1750
+ )}
1751
+ </div>
1752
+ )
1753
+ }
1754
+
420
1755
  // -- Shared Field Components --------------------------------------------------
421
1756
 
422
1757
  function ConfigField({
@@ -440,12 +1775,11 @@ function ConfigField({
440
1775
  {label}
441
1776
  </label>
442
1777
  {help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
443
- <input
444
- type="text"
1778
+ <Input
445
1779
  value={value}
446
1780
  onChange={(e) => onChange(e.target.value)}
447
1781
  placeholder={placeholder}
448
- 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-blue-500"
1782
+ 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"
449
1783
  />
450
1784
  <EffectiveHint current={value || undefined} effective={effectiveValue} />
451
1785
  </div>
@@ -492,8 +1826,7 @@ function ConfigArrayField({
492
1826
  {label}
493
1827
  </label>
494
1828
  {help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
495
- <input
496
- type="text"
1829
+ <Input
497
1830
  value={text}
498
1831
  onFocus={() => { focusedRef.current = true }}
499
1832
  onBlur={() => {
@@ -505,7 +1838,7 @@ function ConfigArrayField({
505
1838
  commit(e.target.value)
506
1839
  }}
507
1840
  placeholder={placeholder}
508
- 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-blue-500"
1841
+ 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"
509
1842
  />
510
1843
  <EffectiveHint current={canonical(value) || undefined} effective={canonical(effectiveValue) || undefined} />
511
1844
  </div>
@@ -538,7 +1871,7 @@ function ConfigNumberField({
538
1871
  value={value ?? ''}
539
1872
  onChange={(e) => onChange(e.target.value ? parseInt(e.target.value, 10) || undefined : undefined)}
540
1873
  placeholder={placeholder}
541
- 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-blue-500"
1874
+ 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"
542
1875
  />
543
1876
  <EffectiveHint current={value} effective={effectiveValue} />
544
1877
  </div>
@@ -547,22 +1880,29 @@ function ConfigNumberField({
547
1880
 
548
1881
  function ConfigToggle({
549
1882
  label,
1883
+ description,
550
1884
  value,
551
1885
  onChange,
552
1886
  }: {
553
1887
  label: string
1888
+ description?: string
554
1889
  value: boolean
555
1890
  onChange: (value: boolean) => void
556
1891
  }) {
557
1892
  return (
558
- <label className="flex items-center justify-between py-1 cursor-pointer group">
559
- <span className="text-sm text-theme-text-primary group-hover:text-theme-text-primary">{label}</span>
1893
+ <label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
1894
+ <span className="min-w-0">
1895
+ <span className="block text-sm text-theme-text-primary">{label}</span>
1896
+ {description && (
1897
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
1898
+ )}
1899
+ </span>
560
1900
  <button
561
1901
  role="switch"
562
1902
  aria-checked={value}
563
1903
  onClick={() => onChange(!value)}
564
1904
  className={clsx(
565
- 'relative w-9 h-5 rounded-full transition-colors',
1905
+ 'relative w-9 h-5 shrink-0 rounded-full transition-colors',
566
1906
  value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
567
1907
  )}
568
1908
  >