@skyhook-io/radar-app 1.8.7 → 1.8.8

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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. package/src/main.tsx +1 -1
@@ -1,16 +1,23 @@
1
1
  import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug, Plus } 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'
8
- import { useCloudRole, useVersionCheck } from '../../api/client'
13
+ import {
14
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
15
+ } from '../../api/client'
9
16
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
10
- import { Tooltip } from '../ui/Tooltip'
11
- import type { DeploymentMode } from '../../types'
12
17
  import { Input } from '@skyhook-io/k8s-ui'
18
+ import { Tooltip } from '../ui/Tooltip'
13
19
  import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
20
+ import { MyPermissionsContent } from './MyPermissionsDialog'
14
21
  import { useDiagnose } from '../diagnose/DiagnoseContext'
15
22
 
16
23
  interface Config {
@@ -24,6 +31,8 @@ interface Config {
24
31
  timelineDbPath?: string
25
32
  historyLimit?: number
26
33
  prometheusUrl?: string
34
+ argoCdUrl?: string
35
+ argoCdInsecureTls?: boolean
27
36
  mcp?: boolean | null
28
37
  }
29
38
 
@@ -32,62 +41,132 @@ interface ConfigResponse {
32
41
  effective: Config
33
42
  isDesktop: boolean
34
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 }
35
58
  }
36
59
 
37
60
  interface SettingsDialogProps {
38
61
  open: boolean
39
62
  onClose: () => void
40
- onShowMyPermissions?: () => void
41
63
  }
42
64
 
43
- export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsDialogProps) {
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
+
92
+ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
44
93
  const dialogRef = useRef<HTMLDivElement>(null)
45
94
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
46
95
  const { data: versionInfo } = useVersionCheck()
47
96
  // Radar configuration (kubeconfig, port, integrations…) is host-level and
48
97
  // affects every user of this instance, so it's gated to owners. Personal
49
- // sections (My permissions) stay visible to everyone. Non-Cloud callers
50
- // (OSS, OIDC, kubectl plugin) have no role and pass — single-user laptops
51
- // are never locked out of their own config. Backend enforces this too.
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.
52
101
  const { canAtLeast } = useCloudRole()
53
102
  const capabilities = useCapabilitiesContext()
54
103
  const canEditConfig = canAtLeast('owner')
104
+
55
105
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
56
106
  const [editedConfig, setEditedConfig] = useState<Config>({})
57
107
  const [saving, setSaving] = useState(false)
58
108
  const [saveMessage, setSaveMessage] = useState<string | null>(null)
59
- const [configDirty, setConfigDirty] = useState(false)
60
109
  const [loadError, setLoadError] = useState<string | null>(null)
110
+ const [section, setSection] = useState<SectionId>('overview')
111
+ const [confirmingClose, setConfirmingClose] = useState(false)
61
112
 
62
- // AI Diagnosis prefs are client-side (localStorage) but, like the rest of this
63
- // dialog, are STAGED and committed on Save not on every change. The draft
64
- // mirrors the committed values (from DiagnoseContext) and is snapshotted on open.
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.
65
116
  const diag = useDiagnose()
117
+ const aiAvailable = diag.available && diag.agents.length > 0
66
118
  const [aiDraft, setAiDraft] = useState<AIDraft>({
67
119
  agent: diag.selectedAgent,
68
120
  isolated: diag.isolated,
69
121
  model: diag.model,
70
122
  effort: diag.effort,
71
123
  })
124
+ const [aiSaved, setAiSaved] = useState(false)
72
125
  const aiDirty =
73
126
  aiDraft.agent !== diag.selectedAgent ||
74
127
  aiDraft.isolated !== diag.isolated ||
75
128
  aiDraft.model !== diag.model ||
76
129
  aiDraft.effort !== diag.effort
77
130
 
78
- // 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.
79
153
  useEffect(() => {
80
154
  if (!open) return
81
155
  setSaveMessage(null)
82
- setConfigDirty(false)
83
156
  setLoadError(null)
84
- // Snapshot the committed AI prefs into the draft so edits stage cleanly.
157
+ setConfirmingClose(false)
158
+ setAiSaved(false)
85
159
  setAiDraft({
86
160
  agent: diag.selectedAgent,
87
161
  isolated: diag.isolated,
88
162
  model: diag.model,
89
163
  effort: diag.effort,
90
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')
91
170
 
92
171
  fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
93
172
  .then((res) => {
@@ -106,18 +185,103 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
106
185
  // eslint-disable-next-line react-hooks/exhaustive-deps
107
186
  }, [open])
108
187
 
188
+ const updateConfigField = useCallback(<K extends keyof Config>(field: K, value: Config[K]) => {
189
+ setEditedConfig((prev) => ({ ...prev, [field]: value }))
190
+ setSaveMessage(null)
191
+ }, [])
192
+
193
+ const saveConfig = useCallback(async (): Promise<boolean> => {
194
+ if (!configData) return false
195
+ setSaving(true)
196
+ setSaveMessage(null)
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
+ }
209
+ const res = await fetch(apiUrl('/config'), {
210
+ method: 'PUT',
211
+ credentials: getCredentialsMode(),
212
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
213
+ body: JSON.stringify(body),
214
+ })
215
+ if (!res.ok) {
216
+ const data = await res.json().catch(() => null)
217
+ setSaveMessage(`Error: ${data?.error || res.statusText}`)
218
+ return false
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
224
+ } catch (err) {
225
+ setSaveMessage(`Error: ${err}`)
226
+ return false
227
+ } finally {
228
+ setSaving(false)
229
+ }
230
+ }, [editedConfig, configData])
231
+
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
+
109
267
  // ESC key
110
268
  useEffect(() => {
111
269
  if (!open) return
112
270
  const handleKeyDown = (e: KeyboardEvent) => {
113
271
  if (e.key === 'Escape') {
114
272
  e.stopPropagation()
115
- onClose()
273
+ requestCloseRef.current()
116
274
  }
117
275
  }
118
276
  document.addEventListener('keydown', handleKeyDown, true)
119
277
  return () => document.removeEventListener('keydown', handleKeyDown, true)
120
- }, [open, onClose])
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])
121
285
 
122
286
  // Focus trap
123
287
  useEffect(() => {
@@ -126,66 +290,28 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
126
290
  }
127
291
  }, [open])
128
292
 
129
- const updateConfigField = useCallback(<K extends keyof Config>(field: K, value: Config[K]) => {
130
- setEditedConfig((prev) => ({ ...prev, [field]: value }))
131
- setConfigDirty(true)
132
- setSaveMessage(null)
133
- }, [])
134
-
135
- const saveConfig = useCallback(async () => {
136
- setSaving(true)
137
- setSaveMessage(null)
138
- // AI prefs are client-side (localStorage) — commit the staged draft now.
139
- // setSelectedAgent clears model/effort (they're agent-specific), so set the
140
- // agent first, then restore the draft's model/effort.
141
- if (aiDirty) {
142
- diag.setSelectedAgent(aiDraft.agent)
143
- diag.setIsolated(aiDraft.isolated)
144
- diag.setModel(aiDraft.model)
145
- diag.setEffort(aiDraft.effort)
146
- }
147
- try {
148
- if (configDirty) {
149
- const res = await fetch(apiUrl('/config'), {
150
- method: 'PUT',
151
- credentials: getCredentialsMode(),
152
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
153
- body: JSON.stringify(editedConfig),
154
- })
155
- if (!res.ok) {
156
- const data = await res.json().catch(() => null)
157
- setSaveMessage(`Error: ${data?.error || res.statusText}`)
158
- return
159
- }
160
- setConfigDirty(false)
161
- setSaveMessage('Saved. Configuration changes take effect on next launch.')
162
- } else {
163
- setSaveMessage('Saved.')
164
- }
165
- } catch (err) {
166
- setSaveMessage(`Error: ${err}`)
167
- } finally {
168
- setSaving(false)
169
- }
170
- }, [editedConfig, configDirty, aiDirty, aiDraft, diag])
171
-
172
- const resetConfig = useCallback(() => {
173
- setEditedConfig({})
174
- setConfigDirty(true)
175
- // Revert staged AI edits back to the committed prefs.
176
- setAiDraft({
177
- agent: diag.selectedAgent,
178
- isolated: diag.isolated,
179
- model: diag.model,
180
- effort: diag.effort,
181
- })
182
- setSaveMessage('All fields cleared. Press Save to apply.')
183
- }, [diag])
184
-
185
293
  if (!shouldRender) return null
186
294
 
187
295
  const isDesktop = configData?.isDesktop ?? false
188
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)
189
315
 
190
316
  return createPortal(
191
317
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -196,7 +322,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
196
322
  TRANSITION_BACKDROP,
197
323
  isOpen ? 'opacity-100' : 'opacity-0'
198
324
  )}
199
- onClick={onClose}
325
+ onClick={() => requestCloseRef.current()}
200
326
  />
201
327
 
202
328
  {/* Dialog */}
@@ -206,12 +332,15 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
206
332
  className={clsx(
207
333
  'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
208
334
  'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
209
- 'sm:rounded-xl sm:max-w-2xl 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]',
210
339
  TRANSITION_PANEL,
211
340
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
212
341
  )}
213
342
  >
214
- {/* Header */}
343
+ {/* Header — spans both panes */}
215
344
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
216
345
  <div className="flex items-center gap-2">
217
346
  <Settings className="w-5 h-5 text-theme-text-secondary" />
@@ -224,283 +353,732 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
224
353
  </div>
225
354
  </div>
226
355
  <button
227
- onClick={onClose}
356
+ onClick={() => requestCloseRef.current()}
228
357
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
229
358
  >
230
359
  <X className="w-5 h-5" />
231
360
  </button>
232
361
  </div>
233
362
 
234
- {/* Content */}
235
- <div className="overflow-y-auto p-4 flex-1">
236
- {loadError && (
237
- <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">
238
- {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>
239
416
  </div>
240
- )}
241
- {onShowMyPermissions && (
242
- <div className="mb-5">
243
- <SectionLabel>Personal</SectionLabel>
244
- <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
245
- <div className="flex items-center justify-between gap-3">
246
- <div className="min-w-0">
247
- <h3 className="text-sm font-medium text-theme-text-primary">My permissions</h3>
248
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
249
- View what your current identity can do in this cluster.
250
- </p>
251
- </div>
252
- <button
253
- onClick={onShowMyPermissions}
254
- className="shrink-0 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-hover rounded-md transition-colors"
255
- >
256
- <Shield className="w-3.5 h-3.5" />
257
- Open
258
- </button>
259
- </div>
417
+
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'} />
260
429
  </div>
261
430
  </div>
262
- )}
263
431
 
264
- <AISettingsSection
265
- available={diag.available}
266
- agents={diag.agents}
267
- draft={aiDraft}
268
- onChange={(patch) => {
269
- setAiDraft((d) => ({ ...d, ...patch }))
270
- setSaveMessage(null)
271
- }}
272
- onHistoryCleared={diag.refreshRuns}
273
- />
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>
274
512
 
275
- <SectionLabel>Radar configuration</SectionLabel>
276
- {canEditConfig ? (
277
- <StartupConfigTab
278
- config={editedConfig}
279
- effectiveConfig={configData?.effective}
280
- isDesktop={isDesktop}
281
- deploymentMode={deploymentMode}
282
- prometheusHeaderKeys={configData?.prometheusHeaderKeys ?? []}
283
- onChange={updateConfigField}
284
- />
285
- ) : (
286
- <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
287
- <Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
288
- <div className="min-w-0">
289
- <p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
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>
290
519
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
291
- These settings (kubeconfig, server port, timeline, integrations) affect
292
- every user of this Radar instance, so they're limited to owners. Ask an
293
- owner if you need a change here.
520
+ Investigate incidents with an AI agent that runs on your own machine — reading
521
+ logs, events, and topology to explain what's wrong. No Radar cloud, no API key.
294
522
  </p>
295
523
  </div>
524
+ {aiAvailable ? (
525
+ <div className="space-y-4">
526
+ <AISettingsSection
527
+ available={diag.available}
528
+ agents={diag.agents}
529
+ draft={aiDraft}
530
+ onChange={(patch) => {
531
+ setAiDraft((d) => ({ ...d, ...patch }))
532
+ setAiSaved(false)
533
+ }}
534
+ onHistoryCleared={diag.refreshRuns}
535
+ />
536
+ <div className="flex items-center justify-end gap-3">
537
+ {aiSaved && !aiDirty && (
538
+ <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
539
+ <Check className="w-3 h-3" />
540
+ Saved
541
+ </span>
542
+ )}
543
+ <button
544
+ onClick={saveAi}
545
+ disabled={!aiDirty}
546
+ className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
547
+ >
548
+ Save
549
+ </button>
550
+ </div>
551
+ </div>
552
+ ) : (
553
+ <AIUnavailableNotice />
554
+ )}
296
555
  </div>
297
- )}
556
+
557
+ {/* Advanced — MCP + Timeline merged */}
558
+ <SectionPane
559
+ id="advanced"
560
+ active={section}
561
+ title="Advanced"
562
+ caption="Takes effect on next launch."
563
+ locked={!canEditConfig}
564
+ >
565
+ <div className="space-y-4">
566
+ <SubHeading>MCP</SubHeading>
567
+ <MCPSection
568
+ mcpEnabled={editedConfig.mcp ?? true}
569
+ onToggle={(v) => updateConfigField('mcp', v)}
570
+ isDesktop={isDesktop}
571
+ portPinned={editedConfig.port != null && editedConfig.port > 0}
572
+ onPinPort={(port) => updateConfigField('port', port)}
573
+ />
574
+ </div>
575
+ <div className="space-y-4 border-t border-theme-border-subtle pt-4">
576
+ <SubHeading>Timeline</SubHeading>
577
+ <TimelineSection
578
+ config={editedConfig}
579
+ effectiveConfig={configData?.effective}
580
+ onChange={updateConfigField}
581
+ />
582
+ </div>
583
+ </SectionPane>
584
+ </div>
298
585
  </div>
299
586
 
300
- {/* Footer — only the owner-gated config section is editable, so hide
301
- the save controls entirely for non-owners (personal sections save
302
- themselves). */}
303
- {canEditConfig && (
304
- <div className="flex items-center justify-between gap-3 px-4 py-2.5 border-t border-theme-border shrink-0">
305
- <div className="flex items-center gap-2">
306
- <Tooltip content="Clear all fields reverts to defaults when saved">
307
- <button
308
- onClick={resetConfig}
309
- disabled={saving}
310
- 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"
311
- >
312
- <RotateCcw className="w-3.5 h-3.5" />
313
- Reset
314
- </button>
315
- </Tooltip>
316
- {saveMessage && (
317
- <span className={clsx(
318
- 'text-xs',
319
- saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400'
320
- )}>
321
- {saveMessage}
322
- </span>
323
- )}
324
- </div>
325
- <div className="flex items-center gap-3">
326
- <span className="hidden sm:flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
327
- <RotateCw className="w-3 h-3" />
328
- {aiDirty && !configDirty
329
- ? 'Applies to new investigations'
330
- : 'Applies on next launch'}
331
- </span>
332
- <button
333
- onClick={saveConfig}
334
- disabled={saving || (!configDirty && !aiDirty)}
335
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
336
- >
337
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
338
- Save
339
- </button>
340
- </div>
587
+ {/* Footer — owner-gated. Startup config only: AI self-saves, integrations
588
+ apply live. Shown whenever a startup edit is pending (any section),
589
+ while confirming a close, or briefly after a save. */}
590
+ <div
591
+ className={clsx(
592
+ 'shrink-0 overflow-hidden transition-all duration-200 ease-out',
593
+ showFooter ? 'max-h-24 opacity-100 border-t border-theme-border' : 'max-h-0 opacity-0 pointer-events-none'
594
+ )}
595
+ >
596
+ <div className="flex items-center justify-between gap-3 px-4 py-2.5">
597
+ {confirmingClose ? (
598
+ <>
599
+ <span className="text-xs text-theme-text-secondary">Unsaved changes.</span>
600
+ <div className="flex items-center gap-2">
601
+ <button
602
+ onClick={() => setConfirmingClose(false)}
603
+ disabled={saving}
604
+ 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"
605
+ >
606
+ Keep editing
607
+ </button>
608
+ <button
609
+ onClick={onClose}
610
+ disabled={saving}
611
+ 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"
612
+ >
613
+ Discard
614
+ </button>
615
+ <button
616
+ onClick={handleSaveAndClose}
617
+ disabled={saving}
618
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
619
+ >
620
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
621
+ Save
622
+ </button>
623
+ </div>
624
+ </>
625
+ ) : (
626
+ <>
627
+ <div className="flex items-center gap-2">
628
+ <Tooltip content="Discard unsaved changes and revert to the last saved values">
629
+ <button
630
+ onClick={discardChanges}
631
+ disabled={saving || !startupDirty}
632
+ 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"
633
+ >
634
+ <RotateCcw className="w-3.5 h-3.5" />
635
+ Discard changes
636
+ </button>
637
+ </Tooltip>
638
+ {saveMessage && (
639
+ <span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
640
+ {saveMessage}
641
+ </span>
642
+ )}
643
+ </div>
644
+ <button
645
+ onClick={saveConfig}
646
+ disabled={saving || !startupDirty}
647
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
648
+ >
649
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
650
+ Save
651
+ </button>
652
+ </>
653
+ )}
341
654
  </div>
342
- )}
655
+ </div>
343
656
  </div>
344
657
  </div>,
345
658
  document.body
346
659
  )
347
660
  }
348
661
 
349
- // -- Section label ------------------------------------------------------------
662
+ // -- Sidebar primitives -------------------------------------------------------
350
663
 
351
- function SectionLabel({ children }: { children: ReactNode }) {
664
+ interface NavItemDef {
665
+ id: SectionId
666
+ label: string
667
+ icon: LucideIcon
668
+ ownerOnly: boolean
669
+ dirty: boolean
670
+ }
671
+
672
+ // Light subheading separating the two field groups inside a merged pane
673
+ // (Cluster/Server, MCP/Timeline).
674
+ function SubHeading({ children }: { children: ReactNode }) {
352
675
  return (
353
- <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2">
676
+ <h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
354
677
  {children}
355
- </h3>
678
+ </h4>
679
+ )
680
+ }
681
+
682
+ function NavItem({
683
+ item,
684
+ active,
685
+ disabled,
686
+ horizontal,
687
+ onSelect,
688
+ }: {
689
+ item: NavItemDef
690
+ active: boolean
691
+ disabled: boolean
692
+ horizontal?: boolean
693
+ onSelect: () => void
694
+ }) {
695
+ const Icon = item.icon
696
+ const button = (
697
+ <button
698
+ type="button"
699
+ role="tab"
700
+ aria-selected={active}
701
+ onClick={onSelect}
702
+ disabled={disabled}
703
+ className={clsx(
704
+ 'group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors',
705
+ horizontal ? 'shrink-0' : 'w-full text-left',
706
+ active
707
+ ? 'bg-theme-active text-theme-text-primary font-medium'
708
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
709
+ disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent hover:text-theme-text-secondary'
710
+ )}
711
+ >
712
+ <Icon className="w-4 h-4 shrink-0" />
713
+ <span className={clsx('truncate', !horizontal && 'flex-1')}>{item.label}</span>
714
+ {disabled ? (
715
+ <Lock className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
716
+ ) : item.dirty ? (
717
+ <Tooltip content="Unsaved changes" delay={200} wrapperClassName="flex shrink-0">
718
+ <span className="w-1.5 h-1.5 rounded-full bg-accent" />
719
+ </Tooltip>
720
+ ) : null}
721
+ </button>
722
+ )
723
+ return disabled ? (
724
+ <Tooltip content="Owner access required" delay={200} wrapperClassName={horizontal ? 'shrink-0' : 'w-full'}>
725
+ {button}
726
+ </Tooltip>
727
+ ) : (
728
+ button
729
+ )
730
+ }
731
+
732
+ // -- Section shell ------------------------------------------------------------
733
+
734
+ // A section stays MOUNTED (visibility toggled) so local draft state in the live
735
+ // integration cards survives switching sidebar items. The caption states the
736
+ // section's honest apply semantics.
737
+ function SectionPane({
738
+ id,
739
+ active,
740
+ title,
741
+ caption,
742
+ live,
743
+ locked,
744
+ children,
745
+ }: {
746
+ id: SectionId
747
+ active: SectionId
748
+ title: string
749
+ caption?: string
750
+ live?: boolean
751
+ locked?: boolean
752
+ children: ReactNode
753
+ }) {
754
+ return (
755
+ <div className={clsx(active !== id && 'hidden')} role="tabpanel">
756
+ <div className="mb-4">
757
+ <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
758
+ {!locked && caption && <SectionCaption live={live}>{caption}</SectionCaption>}
759
+ </div>
760
+ {locked ? <LockWall /> : <div className="space-y-4">{children}</div>}
761
+ </div>
762
+ )
763
+ }
764
+
765
+ function SectionCaption({ children, live }: { children: ReactNode; live?: boolean }) {
766
+ return (
767
+ <p
768
+ className={clsx(
769
+ 'mt-1 flex items-center gap-1.5 text-xs',
770
+ live ? 'text-theme-text-secondary' : 'text-theme-text-tertiary'
771
+ )}
772
+ >
773
+ {live ? <Zap className="w-3 h-3 shrink-0" /> : <RotateCw className="w-3 h-3 shrink-0" />}
774
+ {children}
775
+ </p>
776
+ )
777
+ }
778
+
779
+ function LockWall() {
780
+ return (
781
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
782
+ <Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
783
+ <div className="min-w-0">
784
+ <p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
785
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
786
+ These settings (kubeconfig, server port, timeline, integrations) affect
787
+ every user of this Radar instance, so they're limited to owners. Ask an
788
+ owner if you need a change here.
789
+ </p>
790
+ </div>
791
+ </div>
792
+ )
793
+ }
794
+
795
+ // -- Overview (landing) -------------------------------------------------------
796
+
797
+ type OverviewTone = 'ok' | 'warn' | 'off' | 'unknown'
798
+
799
+ interface OverviewRow {
800
+ id: SectionId
801
+ icon: LucideIcon
802
+ label: string
803
+ tone: OverviewTone
804
+ value: string
805
+ detail?: string
806
+ copyable?: boolean
807
+ }
808
+
809
+ // OverviewPanel is the Settings landing: a status-at-a-glance of what Radar is
810
+ // connected to right now. Each row navigates to the section that manages it.
811
+ // The Argo status query is gated on `active` (it triggers a background reconnect
812
+ // probe, so we don't want it firing when Settings opens on another section);
813
+ // cluster and Prometheus status are shared app-wide caches, so they're read
814
+ // unconditionally.
815
+ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s: SectionId) => void }) {
816
+ const { data: cluster } = useClusterInfo()
817
+ const { data: prom } = usePrometheusStatus()
818
+ const { data: argo } = useArgoStatus(active)
819
+ const { data: version } = useVersionCheck()
820
+ const capabilities = useCapabilitiesContext()
821
+ const diag = useDiagnose()
822
+ const [copied, setCopied] = useState(false)
823
+
824
+ const aiAvailable = diag.available && diag.agents.length > 0
825
+ const agentLabel =
826
+ diag.agents.find((a) => a.name === diag.selectedAgent)?.label ?? diag.agents[0]?.label
827
+ const mcpOn = capabilities.mcpEnabled
828
+ const port = Number(window.location.port) || 80
829
+ const mcpUrl = `http://localhost:${port}/mcp`
830
+
831
+ const rows: OverviewRow[] = [
832
+ {
833
+ id: 'connection', icon: Boxes, label: 'Cluster',
834
+ tone: cluster ? 'ok' : 'unknown',
835
+ value: cluster?.context ?? 'Connecting…',
836
+ detail: cluster ? `Kubernetes ${cluster.kubernetesVersion} · ${cluster.nodeCount} nodes` : undefined,
837
+ },
838
+ {
839
+ id: 'prometheus', icon: Activity, label: 'Prometheus',
840
+ tone: prom?.connected ? 'ok' : prom?.available ? 'warn' : 'off',
841
+ value: prom?.connected ? 'Connected' : prom?.available ? 'Not reachable' : 'Not configured',
842
+ detail: prom?.connected ? prom.address : undefined,
843
+ },
844
+ {
845
+ id: 'argocd', icon: GitBranch, label: 'Argo CD',
846
+ tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
847
+ // Configured-but-not-connected is often a permanently rejected/expired
848
+ // token, not a transient reconnect — "Not reachable" matches Prometheus and
849
+ // doesn't imply it will recover on its own.
850
+ value: argo?.connected ? 'Connected' : argo?.configured ? 'Not reachable' : 'Not connected',
851
+ detail: argo?.connected ? argo.address : undefined,
852
+ },
853
+ {
854
+ id: 'advanced', icon: Zap, label: 'MCP',
855
+ tone: mcpOn ? 'ok' : 'off',
856
+ value: mcpOn ? 'On' : 'Off',
857
+ detail: mcpOn ? mcpUrl : undefined,
858
+ copyable: mcpOn,
859
+ },
860
+ {
861
+ id: 'ai', icon: Sparkles, label: 'AI diagnose',
862
+ tone: aiAvailable ? 'ok' : 'off',
863
+ value: aiAvailable ? 'Ready' : 'No agent CLI',
864
+ detail: aiAvailable ? agentLabel : undefined,
865
+ },
866
+ ]
867
+
868
+ const copyMcp = () => {
869
+ navigator.clipboard.writeText(mcpUrl)
870
+ setCopied(true)
871
+ setTimeout(() => setCopied(false), 2000)
872
+ }
873
+
874
+ return (
875
+ <div className="space-y-4">
876
+ {version?.updateAvailable && (
877
+ <a
878
+ href={version.releaseUrl}
879
+ target="_blank"
880
+ rel="noreferrer"
881
+ 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"
882
+ >
883
+ <Download className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
884
+ <span className="flex-1 text-theme-text-primary">
885
+ Radar {version.latestVersion} is available
886
+ <span className="text-theme-text-tertiary"> — you're on {version.currentVersion}</span>
887
+ </span>
888
+ <ExternalLink className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
889
+ </a>
890
+ )}
891
+
892
+ <div className="rounded-md border border-theme-border divide-y divide-theme-border-subtle overflow-hidden">
893
+ {rows.map((row) => {
894
+ const Icon = row.icon
895
+ return (
896
+ <div
897
+ key={row.label}
898
+ role="button"
899
+ tabIndex={0}
900
+ onClick={() => onNavigate(row.id)}
901
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(row.id) } }}
902
+ className="group flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-theme-hover transition-colors"
903
+ >
904
+ <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
905
+ <span className="text-sm text-theme-text-primary w-24 shrink-0 truncate">{row.label}</span>
906
+ <OverviewStatus tone={row.tone} />
907
+ <div className="flex-1 min-w-0 flex items-baseline gap-1.5">
908
+ <span className="text-sm text-theme-text-secondary shrink-0">{row.value}</span>
909
+ {row.detail && (
910
+ <span className="text-xs text-theme-text-tertiary truncate">{row.detail}</span>
911
+ )}
912
+ </div>
913
+ {row.copyable && (
914
+ <Tooltip content="Copy MCP URL" wrapperClassName="shrink-0">
915
+ <button
916
+ onClick={(e) => { e.stopPropagation(); copyMcp() }}
917
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded transition-colors"
918
+ >
919
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
920
+ </button>
921
+ </Tooltip>
922
+ )}
923
+ <ChevronRight className="w-4 h-4 shrink-0 text-theme-text-disabled group-hover:text-theme-text-tertiary" />
924
+ </div>
925
+ )
926
+ })}
927
+ </div>
928
+ </div>
356
929
  )
357
930
  }
358
931
 
359
- // A titled card grouping related config fields. Cards (vs thin dividers) give the
360
- // sections clear visual separation in the scroll.
361
- function ConfigSection({ title, children }: { title: string; children: ReactNode }) {
932
+ function OverviewStatus({ tone }: { tone: OverviewTone }) {
933
+ const cls =
934
+ tone === 'ok' ? 'bg-green-500'
935
+ : tone === 'warn' ? 'bg-amber-500'
936
+ : tone === 'unknown' ? 'bg-theme-text-tertiary animate-pulse'
937
+ : 'bg-theme-text-disabled'
938
+ return <span className={clsx('w-2 h-2 rounded-full shrink-0', cls)} />
939
+ }
940
+
941
+ // AIUnavailableNotice is the body of the AI diagnose tab when no supported agent
942
+ // CLI is installed — the heading/description are provided by the tab itself, so
943
+ // this is just the enable explainer (keeping the feature discoverable to whoever
944
+ // would set it up).
945
+ function AIUnavailableNotice() {
362
946
  return (
363
- <section className="rounded-lg border border-theme-border bg-theme-elevated/30 p-4">
364
- <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">
365
- {title}
366
- </h4>
367
- <div className="space-y-4">{children}</div>
368
- </section>
947
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
948
+ <p className="text-sm font-medium text-theme-text-primary">No supported agent CLI found</p>
949
+ <p className="mt-1 text-xs text-theme-text-tertiary">
950
+ Install <span className="text-theme-text-secondary">Claude Code</span> or{' '}
951
+ <span className="text-theme-text-secondary">Codex</span>, then restart Radar — this tab
952
+ will show the agent, model, and effort controls.
953
+ </p>
954
+ </div>
369
955
  )
370
956
  }
371
957
 
372
- // -- Startup Configuration Tab ------------------------------------------------
958
+ // -- Startup section bodies ---------------------------------------------------
373
959
 
374
- function StartupConfigTab({
960
+ function ClusterSection({
961
+ config,
962
+ effectiveConfig,
963
+ onChange,
964
+ }: {
965
+ config: Config
966
+ effectiveConfig?: Config
967
+ onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
968
+ }) {
969
+ return (
970
+ <>
971
+ <ConfigField
972
+ label="Kubeconfig"
973
+ help="Path to kubeconfig file"
974
+ value={config.kubeconfig ?? ''}
975
+ effectiveValue={effectiveConfig?.kubeconfig}
976
+ placeholder="~/.kube/config"
977
+ onChange={(v) => onChange('kubeconfig', v || undefined)}
978
+ />
979
+ <ConfigArrayField
980
+ label="Kubeconfig Directories"
981
+ help="Comma-separated directories containing kubeconfig files"
982
+ value={config.kubeconfigDirs}
983
+ effectiveValue={effectiveConfig?.kubeconfigDirs}
984
+ placeholder="/path/to/dir1, /path/to/dir2"
985
+ onChange={(v) => onChange('kubeconfigDirs', v)}
986
+ />
987
+ <ConfigField
988
+ label="Default Namespace"
989
+ help="Startup default only — change the active namespace live anytime from the header switcher"
990
+ value={config.namespace ?? ''}
991
+ effectiveValue={effectiveConfig?.namespace}
992
+ placeholder="All namespaces"
993
+ onChange={(v) => onChange('namespace', v || undefined)}
994
+ />
995
+ </>
996
+ )
997
+ }
998
+
999
+ function ServerSection({
375
1000
  config,
376
1001
  effectiveConfig,
377
1002
  isDesktop,
378
- deploymentMode,
379
- prometheusHeaderKeys,
1003
+ showBrowserLaunchControls,
380
1004
  onChange,
381
1005
  }: {
382
1006
  config: Config
383
1007
  effectiveConfig?: Config
384
1008
  isDesktop: boolean
385
- deploymentMode: DeploymentMode
386
- prometheusHeaderKeys: string[]
1009
+ showBrowserLaunchControls: boolean
387
1010
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
388
1011
  }) {
389
- const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
390
1012
  return (
391
- <div className="space-y-3">
392
- <p className="text-xs text-theme-text-tertiary">
393
- Most changes require a restart to take effect.
394
- {isDesktop
395
- ? ' Quit and relaunch Radar to apply.'
396
- : ' Stop and restart the radar command to apply.'}
397
- </p>
398
-
399
- <ConfigSection title="Cluster">
400
- <ConfigField
401
- label="Kubeconfig"
402
- help="Path to kubeconfig file"
403
- value={config.kubeconfig ?? ''}
404
- effectiveValue={effectiveConfig?.kubeconfig}
405
- placeholder="~/.kube/config"
406
- onChange={(v) => onChange('kubeconfig', v || undefined)}
407
- />
408
-
409
- <ConfigArrayField
410
- label="Kubeconfig Directories"
411
- help="Comma-separated directories containing kubeconfig files"
412
- value={config.kubeconfigDirs}
413
- effectiveValue={effectiveConfig?.kubeconfigDirs}
414
- placeholder="/path/to/dir1, /path/to/dir2"
415
- onChange={(v) => onChange('kubeconfigDirs', v)}
416
- />
417
-
418
- <ConfigField
419
- label="Default Namespace"
420
- help="Startup default only — change the active namespace live anytime from the header switcher"
421
- value={config.namespace ?? ''}
422
- effectiveValue={effectiveConfig?.namespace}
423
- placeholder="All namespaces"
424
- onChange={(v) => onChange('namespace', v || undefined)}
425
- />
426
- </ConfigSection>
427
-
428
- <ConfigSection title="Server">
429
- <ConfigNumberField
430
- label="Port"
431
- help={isDesktop
432
- ? 'Fixed server port (leave empty for random). Set this to keep a stable MCP endpoint.'
433
- : 'Server port'}
434
- value={config.port}
435
- effectiveValue={effectiveConfig?.port}
436
- placeholder={isDesktop ? 'Random' : '9280'}
437
- onChange={(v) => onChange('port', v)}
438
- />
1013
+ <>
1014
+ <ConfigNumberField
1015
+ label="Port"
1016
+ help={isDesktop
1017
+ ? 'Fixed server port (leave empty for random). Set this to keep a stable MCP endpoint.'
1018
+ : 'Server port'}
1019
+ value={config.port}
1020
+ effectiveValue={effectiveConfig?.port}
1021
+ placeholder={isDesktop ? 'Random' : '9280'}
1022
+ onChange={(v) => onChange('port', v)}
1023
+ />
439
1024
 
440
- {showBrowserLaunchControls && (
441
- <>
442
- <ConfigToggle
443
- label="Open browser on start"
444
- value={!(config.noBrowser ?? false)}
445
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
446
- />
447
-
448
- <ConfigField
449
- label="Browser"
450
- help="Browser for automatic launch; macOS app names are supported"
451
- value={config.browser ?? ''}
452
- effectiveValue={effectiveConfig?.browser}
453
- placeholder="System default"
454
- onChange={(v) => onChange('browser', v || undefined)}
455
- />
456
- </>
457
- )}
458
- </ConfigSection>
459
-
460
- <ConfigSection title="AI Tools">
461
- <MCPSection
462
- mcpEnabled={config.mcp ?? true}
463
- onToggle={(v) => onChange('mcp', v)}
464
- isDesktop={isDesktop}
465
- portPinned={config.port != null && config.port > 0}
466
- onPinPort={(port) => onChange('port', port)}
467
- />
468
- </ConfigSection>
469
-
470
- <ConfigSection title="Timeline">
471
- <div>
472
- <label className="block text-sm font-medium text-theme-text-primary mb-1">
473
- Storage Backend
474
- </label>
475
- <select
476
- value={config.timelineStorage ?? 'memory'}
477
- onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
478
- 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"
479
- >
480
- <option value="memory">Memory (default)</option>
481
- <option value="sqlite">SQLite (persistent)</option>
482
- </select>
483
- <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
484
- </div>
1025
+ {showBrowserLaunchControls && (
1026
+ <>
1027
+ <ConfigToggle
1028
+ label="Open browser on start"
1029
+ description="Automatically open the Radar UI in your browser when Radar starts — turn off to run headless and open the URL yourself."
1030
+ value={!(config.noBrowser ?? false)}
1031
+ onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
1032
+ />
1033
+ <ConfigField
1034
+ label="Browser"
1035
+ 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."
1036
+ value={config.browser ?? ''}
1037
+ effectiveValue={effectiveConfig?.browser}
1038
+ placeholder="System default"
1039
+ onChange={(v) => onChange('browser', v || undefined)}
1040
+ />
1041
+ </>
1042
+ )}
1043
+ </>
1044
+ )
1045
+ }
485
1046
 
486
- <ConfigNumberField
487
- label="History Limit"
488
- help="Maximum events to retain"
489
- value={config.historyLimit}
490
- effectiveValue={effectiveConfig?.historyLimit}
491
- placeholder="10000"
492
- onChange={(v) => onChange('historyLimit', v)}
493
- />
494
- </ConfigSection>
1047
+ function TimelineSection({
1048
+ config,
1049
+ effectiveConfig,
1050
+ onChange,
1051
+ }: {
1052
+ config: Config
1053
+ effectiveConfig?: Config
1054
+ onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
1055
+ }) {
1056
+ return (
1057
+ <>
1058
+ <div>
1059
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">
1060
+ Storage Backend
1061
+ </label>
1062
+ <select
1063
+ value={config.timelineStorage ?? 'memory'}
1064
+ onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
1065
+ 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"
1066
+ >
1067
+ <option value="memory">Memory (default)</option>
1068
+ <option value="sqlite">SQLite (persistent)</option>
1069
+ </select>
1070
+ <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
1071
+ </div>
495
1072
 
496
- <ConfigSection title="Integrations">
497
- <PrometheusConfigField
498
- value={config.prometheusUrl ?? ''}
499
- configuredHeaderKeys={prometheusHeaderKeys}
500
- onChange={(v) => onChange('prometheusUrl', v || undefined)}
501
- />
502
- </ConfigSection>
503
- </div>
1073
+ <ConfigNumberField
1074
+ label="History Limit"
1075
+ help="Maximum events to retain"
1076
+ value={config.historyLimit}
1077
+ effectiveValue={effectiveConfig?.historyLimit}
1078
+ placeholder="10000"
1079
+ onChange={(v) => onChange('historyLimit', v)}
1080
+ />
1081
+ </>
504
1082
  )
505
1083
  }
506
1084
 
@@ -536,6 +1114,10 @@ function MCPSection({
536
1114
 
537
1115
  return (
538
1116
  <div className="space-y-3">
1117
+ <p className="text-xs text-theme-text-tertiary">
1118
+ Lets AI tools (Claude Code, Cursor, …) query and act on this cluster through Radar over the
1119
+ Model Context Protocol. Point your agent at the endpoint below.
1120
+ </p>
539
1121
  <ConfigToggle
540
1122
  label="MCP Server"
541
1123
  value={mcpEnabled}
@@ -589,13 +1171,12 @@ function MCPSection({
589
1171
 
590
1172
  // -- Prometheus (live-appliable) ----------------------------------------------
591
1173
 
592
- // Unlike the rest of this dialog, the Prometheus URL can be re-pointed without
593
- // a restart — the metrics path reads it from a mutable global. "Apply now" hits
594
- // PUT /integrations/prometheus, which persists the URL AND re-points the running
595
- // client, then probes it so we can confirm reachability inline. The global Save
596
- // still persists this field too (taking effect next launch, like everything
597
- // else); Apply is the shortcut to "now". No EffectiveHint here — the per-field
598
- // restart-diff hint would contradict the whole point of applying live.
1174
+ // The Prometheus URL can be re-pointed without a restart — the metrics path reads
1175
+ // it from a mutable global. "Apply now" hits PUT /integrations/prometheus, which
1176
+ // persists the URL AND re-points the running client, then probes it so we can
1177
+ // confirm reachability inline. onApplied notifies the parent so the footer's
1178
+ // last-committed snapshot stays in sync (see saveConfig). No EffectiveHint here
1179
+ // a restart-diff hint would contradict the whole point of applying live.
599
1180
  type ApplyState =
600
1181
  | { status: 'idle' }
601
1182
  | { status: 'applying' }
@@ -609,10 +1190,12 @@ function PrometheusConfigField({
609
1190
  value,
610
1191
  onChange,
611
1192
  configuredHeaderKeys,
1193
+ onApplied,
612
1194
  }: {
613
1195
  value: string
614
1196
  onChange: (value: string) => void
615
1197
  configuredHeaderKeys: string[]
1198
+ onApplied?: (url: string) => void
616
1199
  }) {
617
1200
  const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
618
1201
  // null = not editing headers (preserve what's stored). A non-null array means
@@ -666,6 +1249,7 @@ function PrometheusConfigField({
666
1249
  setApply({ status: 'failed', error: data?.error || res.statusText })
667
1250
  return
668
1251
  }
1252
+ onApplied?.(value.trim())
669
1253
  if (editedHeaders !== undefined) {
670
1254
  setAppliedKeys(Object.keys(editedHeaders).sort())
671
1255
  }
@@ -684,11 +1268,14 @@ function PrometheusConfigField({
684
1268
 
685
1269
  return (
686
1270
  <div>
1271
+ <p className="text-xs text-theme-text-tertiary mb-3">
1272
+ Powers the CPU / memory graphs, usage history, and rightsizing hints on workload and node pages.
1273
+ </p>
687
1274
  <label className="block text-sm font-medium text-theme-text-primary mb-1">
688
- Prometheus URL
1275
+ Server URL
689
1276
  </label>
690
1277
  <p className="text-xs text-theme-text-tertiary mb-1">
691
- Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)
1278
+ Manual Prometheus / VictoriaMetrics URL set this to skip auto-discovery.
692
1279
  </p>
693
1280
  <div className="flex items-center gap-2">
694
1281
  <Input
@@ -701,7 +1288,7 @@ function PrometheusConfigField({
701
1288
  <button
702
1289
  onClick={handleApply}
703
1290
  disabled={apply.status === 'applying'}
704
- 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 border border-theme-border rounded-md transition-colors disabled:opacity-50"
1291
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
705
1292
  >
706
1293
  {apply.status === 'applying'
707
1294
  ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -804,6 +1391,362 @@ function PrometheusConfigField({
804
1391
  )
805
1392
  }
806
1393
 
1394
+ // -- Argo CD (live-appliable) -------------------------------------------------
1395
+
1396
+ // The Argo CD integration powers the full Git-rendered desired-vs-live diff on
1397
+ // GitOps Application pages. Like Prometheus, it applies to the running server
1398
+ // without a restart: both actions PUT /integrations/argocd, which persists the
1399
+ // settings AND re-points the running client, then verifies reachability (the
1400
+ // server returns 400 distinguishing unreachable from an invalid token). onApplied
1401
+ // notifies the parent so the footer's last-committed snapshot stays in sync. The
1402
+ // token is write-only — never returned, and omitted from the PUT unless the user
1403
+ // types a new value or clicks Clear.
1404
+ type ArgoState =
1405
+ | { status: 'idle' }
1406
+ | { status: 'connecting' }
1407
+ | { status: 'connected' }
1408
+ | { status: 'error'; error: string }
1409
+
1410
+ function ArgoCDConfigField({
1411
+ url,
1412
+ insecureTls,
1413
+ tokenSet,
1414
+ envManaged,
1415
+ envError,
1416
+ cliSession,
1417
+ onChangeUrl,
1418
+ onChangeInsecureTls,
1419
+ onApplied,
1420
+ }: {
1421
+ url: string
1422
+ insecureTls: boolean
1423
+ tokenSet: boolean
1424
+ envManaged?: boolean
1425
+ envError?: string
1426
+ cliSession?: { server: string; user: string; insecure?: boolean }
1427
+ onChangeUrl: (value: string) => void
1428
+ onChangeInsecureTls: (value: boolean) => void
1429
+ onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
1430
+ }) {
1431
+ if (envManaged) {
1432
+ return <ArgoCDEnvManagedField url={url} insecureTls={insecureTls} envError={envError} />
1433
+ }
1434
+ return (
1435
+ <ArgoCDEditableField
1436
+ url={url}
1437
+ insecureTls={insecureTls}
1438
+ tokenSet={tokenSet}
1439
+ cliSession={cliSession}
1440
+ onChangeUrl={onChangeUrl}
1441
+ onChangeInsecureTls={onChangeInsecureTls}
1442
+ onApplied={onApplied}
1443
+ />
1444
+ )
1445
+ }
1446
+
1447
+ // Read-only card for the environment-managed integration: the token/URL/TLS come
1448
+ // from the deployment (RADAR_ARGOCD_TOKEN / _TOKEN_FILE / _URL), so editing here
1449
+ // is disabled — the server refuses the PUT. Shows the effective endpoint and points
1450
+ // the operator at the deployment for changes.
1451
+ function ArgoCDEnvManagedField({
1452
+ url,
1453
+ insecureTls,
1454
+ envError,
1455
+ }: {
1456
+ url: string
1457
+ insecureTls: boolean
1458
+ envError?: string
1459
+ }) {
1460
+ if (envError) {
1461
+ return (
1462
+ <div>
1463
+ <p className="text-xs text-theme-text-tertiary mb-3">
1464
+ Powers the full Git-rendered desired-vs-live diff on GitOps Application pages — what Git
1465
+ declares vs what&apos;s actually running.
1466
+ </p>
1467
+ <div className="rounded-md border border-red-500/30 bg-red-500/[0.07] p-3">
1468
+ <p className="flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400/90">
1469
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
1470
+ Environment Argo CD configuration is invalid
1471
+ </p>
1472
+ <p className="mt-1 text-xs text-theme-text-secondary break-words">{envError}</p>
1473
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1474
+ The deployment sets <code className="inline-code">RADAR_ARGOCD_TOKEN</code> (or{' '}
1475
+ <code className="inline-code">RADAR_ARGOCD_TOKEN_FILE</code>), but it couldn&apos;t be
1476
+ used, so the deep diff is disabled. Fix the deployment&apos;s environment or Secret and
1477
+ restart; check the Radar pod logs for details.
1478
+ </p>
1479
+ </div>
1480
+ </div>
1481
+ )
1482
+ }
1483
+ return (
1484
+ <div>
1485
+ <p className="text-xs text-theme-text-tertiary mb-3">
1486
+ Powers the full Git-rendered desired-vs-live diff on GitOps Application pages — what Git
1487
+ declares vs what&apos;s actually running.
1488
+ </p>
1489
+ <div className="rounded-md border border-skyhook-500/30 bg-skyhook-500/[0.07] p-3">
1490
+ <p className="flex items-center gap-1.5 text-sm font-medium text-theme-text-primary">
1491
+ <Terminal className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
1492
+ Configured from the environment
1493
+ </p>
1494
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1495
+ The token is provided by the deployment via{' '}
1496
+ <code className="inline-code">RADAR_ARGOCD_TOKEN</code> (or{' '}
1497
+ <code className="inline-code">RADAR_ARGOCD_TOKEN_FILE</code>), so this integration is
1498
+ read-only here. Edit the deployment&apos;s environment or Secret and restart to change it.
1499
+ </p>
1500
+ <dl className="mt-3 space-y-1 text-xs">
1501
+ <div className="flex gap-2">
1502
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">Server</dt>
1503
+ <dd className="min-w-0 truncate text-theme-text-secondary">
1504
+ {url || 'auto-discovered in this cluster'}
1505
+ </dd>
1506
+ </div>
1507
+ <div className="flex gap-2">
1508
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">Token</dt>
1509
+ <dd className="text-theme-text-secondary">provided via environment</dd>
1510
+ </div>
1511
+ {insecureTls && (
1512
+ <div className="flex gap-2">
1513
+ <dt className="w-24 shrink-0 text-theme-text-tertiary">TLS</dt>
1514
+ <dd className="text-amber-600 dark:text-amber-400/80">verification skipped</dd>
1515
+ </div>
1516
+ )}
1517
+ </dl>
1518
+ </div>
1519
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1520
+ If the diff isn&apos;t loading, verify the token is valid for this cluster&apos;s Argo CD and
1521
+ check the Radar pod logs.
1522
+ </p>
1523
+ </div>
1524
+ )
1525
+ }
1526
+
1527
+ function ArgoCDEditableField({
1528
+ url,
1529
+ insecureTls,
1530
+ tokenSet,
1531
+ cliSession,
1532
+ onChangeUrl,
1533
+ onChangeInsecureTls,
1534
+ onApplied,
1535
+ }: {
1536
+ url: string
1537
+ insecureTls: boolean
1538
+ tokenSet: boolean
1539
+ cliSession?: { server: string; user: string; insecure?: boolean }
1540
+ onChangeUrl: (value: string) => void
1541
+ onChangeInsecureTls: (value: boolean) => void
1542
+ onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
1543
+ }) {
1544
+ const [state, setState] = useState<ArgoState>({ status: 'idle' })
1545
+ // Token editor three-way state: `touched` = user typed a value (replaces the
1546
+ // stored token); `cleared` = user hit Clear (send "" to wipe it); neither =
1547
+ // leave the stored token untouched (omit from the PUT).
1548
+ const [token, setToken] = useState('')
1549
+ const [tokenTouched, setTokenTouched] = useState(false)
1550
+ const [tokenCleared, setTokenCleared] = useState(false)
1551
+ // Optimistic "is a token stored" after a successful apply (config isn't
1552
+ // refetched), so the placeholder reflects the new reality without a reopen.
1553
+ const [tokenSetLocal, setTokenSetLocal] = useState<boolean | null>(null)
1554
+ const effectiveTokenSet = tokenSetLocal ?? tokenSet
1555
+
1556
+ const clearStatus = () => setState((s) => (s.status === 'connecting' ? s : { status: 'idle' }))
1557
+
1558
+ // Drop a stale "Connected"/error line when the URL changes out from under us
1559
+ // (footer Reset, external edit) so it doesn't describe an emptied field.
1560
+ useEffect(() => {
1561
+ setState((s) => (s.status === 'idle' || s.status === 'connecting' ? s : { status: 'idle' }))
1562
+ }, [url])
1563
+
1564
+ const put = async (body: Record<string, unknown>, resultingTokenSet?: boolean) => {
1565
+ setState({ status: 'connecting' })
1566
+ try {
1567
+ const res = await fetch(apiUrl('/integrations/argocd'), {
1568
+ method: 'PUT',
1569
+ credentials: getCredentialsMode(),
1570
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
1571
+ body: JSON.stringify(body),
1572
+ })
1573
+ const data = await res.json().catch(() => null)
1574
+ if (!res.ok) {
1575
+ setState({ status: 'error', error: data?.error || res.statusText })
1576
+ return
1577
+ }
1578
+ // Reset the token editor so the field reflects the now-stored token.
1579
+ setToken('')
1580
+ setTokenTouched(false)
1581
+ setTokenCleared(false)
1582
+ if (resultingTokenSet !== undefined) setTokenSetLocal(resultingTokenSet)
1583
+ onApplied?.({
1584
+ url: (body.argoCdUrl as string) ?? '',
1585
+ insecureTls: (body.argoCdInsecureTls as boolean) ?? false,
1586
+ tokenSet: resultingTokenSet ?? effectiveTokenSet,
1587
+ })
1588
+ setState({ status: 'connected' })
1589
+ } catch (err) {
1590
+ setState({ status: 'error', error: String(err) })
1591
+ }
1592
+ }
1593
+
1594
+ const handleConnect = () => {
1595
+ let argoCdToken: string | undefined
1596
+ let resultingTokenSet = effectiveTokenSet
1597
+ if (tokenCleared) {
1598
+ argoCdToken = ''
1599
+ resultingTokenSet = false
1600
+ } else if (tokenTouched && token !== '') {
1601
+ argoCdToken = token
1602
+ resultingTokenSet = true
1603
+ }
1604
+ put(
1605
+ {
1606
+ argoCdUrl: url.trim(),
1607
+ argoCdInsecureTls: insecureTls,
1608
+ ...(argoCdToken !== undefined ? { argoCdToken } : {}),
1609
+ },
1610
+ resultingTokenSet,
1611
+ )
1612
+ }
1613
+
1614
+ const handleUseCliToken = () => {
1615
+ // Only reachable from the "Use this session" button, which renders solely
1616
+ // when a session was detected. The detected session is for its own server
1617
+ // (which may differ from the URL field) — connect to that server with its TLS
1618
+ // mode (Argo CD is https; the insecure flag covers self-signed) and reflect
1619
+ // it in the form. An explicit URL is required: the server rejects useCliToken
1620
+ // with an empty URL rather than routing the CLI token to a discovered server.
1621
+ if (!cliSession) return
1622
+ const sessionUrl = /^https?:\/\//i.test(cliSession.server)
1623
+ ? cliSession.server
1624
+ : `https://${cliSession.server}`
1625
+ const insecure = cliSession.insecure ?? false
1626
+ onChangeUrl(sessionUrl)
1627
+ onChangeInsecureTls(insecure)
1628
+ put({ argoCdUrl: sessionUrl, argoCdInsecureTls: insecure, useCliToken: true }, true)
1629
+ }
1630
+
1631
+ const showConfiguredPlaceholder = effectiveTokenSet && !tokenTouched && !tokenCleared
1632
+ const connecting = state.status === 'connecting'
1633
+
1634
+ return (
1635
+ <div>
1636
+ <p className="text-xs text-theme-text-tertiary mb-3">
1637
+ Connect your Argo CD server for the full Git-rendered desired-vs-live diff on GitOps
1638
+ Application pages — what Git declares vs what's actually running. Without it, Radar falls
1639
+ back to a lighter annotation-based drift that can miss fields.
1640
+ </p>
1641
+
1642
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">Server URL</label>
1643
+ <p className="text-xs text-theme-text-tertiary mb-1">
1644
+ Leave blank to auto-discover the argocd-server in this cluster, or enter its API URL.
1645
+ </p>
1646
+ <Input
1647
+ value={url}
1648
+ onChange={(e) => { onChangeUrl(e.target.value); clearStatus() }}
1649
+ placeholder="auto-discover argocd-server"
1650
+ 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"
1651
+ />
1652
+
1653
+ {cliSession && (
1654
+ <div className="mt-3 rounded-md border border-skyhook-500/30 bg-skyhook-500/[0.07] p-3">
1655
+ <div className="flex items-start justify-between gap-3">
1656
+ <div className="min-w-0">
1657
+ <p className="flex items-center gap-1.5 text-sm font-medium text-theme-text-primary">
1658
+ <Terminal className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
1659
+ Argo CD CLI session found
1660
+ </p>
1661
+ <p className="mt-0.5 truncate text-xs text-theme-text-tertiary">
1662
+ {cliSession.user && cliSession.user !== cliSession.server ? (
1663
+ <><span className="text-theme-text-secondary">{cliSession.user}</span> @ {cliSession.server}</>
1664
+ ) : (
1665
+ <span className="text-theme-text-secondary">{cliSession.server}</span>
1666
+ )}
1667
+ </p>
1668
+ </div>
1669
+ <button
1670
+ onClick={handleUseCliToken}
1671
+ disabled={connecting}
1672
+ 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"
1673
+ >
1674
+ {connecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plug className="w-3.5 h-3.5" />}
1675
+ Use this session
1676
+ </button>
1677
+ </div>
1678
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
1679
+ Reuses your <code className="inline-code">argocd login</code> token — nothing to generate or paste.
1680
+ </p>
1681
+ </div>
1682
+ )}
1683
+
1684
+ <label className="block text-sm font-medium text-theme-text-primary mt-3 mb-1">
1685
+ {cliSession ? 'Or paste a token' : 'Auth token'}
1686
+ </label>
1687
+ <p className="text-xs text-theme-text-tertiary mb-1">
1688
+ Lets Radar read your applications. Create one with{' '}
1689
+ <code className="inline-code">argocd account generate-token</code>, or in the Argo CD UI under
1690
+ Settings → Accounts.
1691
+ </p>
1692
+ <div className="flex items-center gap-2">
1693
+ <input
1694
+ type="password"
1695
+ value={showConfiguredPlaceholder ? '' : token}
1696
+ onChange={(e) => { setToken(e.target.value); setTokenTouched(true); setTokenCleared(false); clearStatus() }}
1697
+ placeholder={showConfiguredPlaceholder ? '•••• configured' : 'Argo CD auth token'}
1698
+ 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"
1699
+ />
1700
+ {effectiveTokenSet && !tokenCleared && (
1701
+ <button
1702
+ onClick={() => { setToken(''); setTokenTouched(false); setTokenCleared(true); clearStatus() }}
1703
+ 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"
1704
+ >
1705
+ Clear
1706
+ </button>
1707
+ )}
1708
+ </div>
1709
+ {tokenCleared && (
1710
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be cleared on save.</p>
1711
+ )}
1712
+
1713
+ <label className="mt-2 flex items-center gap-2 cursor-pointer">
1714
+ <input
1715
+ type="checkbox"
1716
+ checked={insecureTls}
1717
+ onChange={(e) => { onChangeInsecureTls(e.target.checked); clearStatus() }}
1718
+ className="h-3.5 w-3.5 accent-skyhook-600"
1719
+ />
1720
+ <span className="text-xs text-theme-text-secondary">Skip TLS verification (self-signed Argo CD server)</span>
1721
+ </label>
1722
+
1723
+ <div className="mt-3">
1724
+ <button
1725
+ onClick={handleConnect}
1726
+ disabled={connecting}
1727
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
1728
+ >
1729
+ {connecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plug className="w-3.5 h-3.5" />}
1730
+ Connect &amp; save
1731
+ </button>
1732
+ </div>
1733
+
1734
+ {state.status === 'connected' ? (
1735
+ <p className="mt-2 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
1736
+ <Check className="w-3 h-3 shrink-0" />
1737
+ Connected to Argo CD — applied, no restart needed
1738
+ </p>
1739
+ ) : state.status === 'error' ? (
1740
+ <p className="mt-2 text-xs text-red-600 dark:text-red-400/80">{state.error}</p>
1741
+ ) : (
1742
+ <p className="mt-2 text-xs text-theme-text-tertiary">
1743
+ Connecting verifies the URL and token before anything is saved.
1744
+ </p>
1745
+ )}
1746
+ </div>
1747
+ )
1748
+ }
1749
+
807
1750
  // -- Shared Field Components --------------------------------------------------
808
1751
 
809
1752
  function ConfigField({
@@ -932,22 +1875,29 @@ function ConfigNumberField({
932
1875
 
933
1876
  function ConfigToggle({
934
1877
  label,
1878
+ description,
935
1879
  value,
936
1880
  onChange,
937
1881
  }: {
938
1882
  label: string
1883
+ description?: string
939
1884
  value: boolean
940
1885
  onChange: (value: boolean) => void
941
1886
  }) {
942
1887
  return (
943
- <label className="flex items-center justify-between py-1 cursor-pointer group">
944
- <span className="text-sm text-theme-text-primary group-hover:text-theme-text-primary">{label}</span>
1888
+ <label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
1889
+ <span className="min-w-0">
1890
+ <span className="block text-sm text-theme-text-primary">{label}</span>
1891
+ {description && (
1892
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
1893
+ )}
1894
+ </span>
945
1895
  <button
946
1896
  role="switch"
947
1897
  aria-checked={value}
948
1898
  onClick={() => onChange(!value)}
949
1899
  className={clsx(
950
- 'relative w-9 h-5 rounded-full transition-colors',
1900
+ 'relative w-9 h-5 shrink-0 rounded-full transition-colors',
951
1901
  value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
952
1902
  )}
953
1903
  >