@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.
- package/package.json +4 -4
- package/src/App.tsx +58 -50
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -234
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/diagnose/AISettings.tsx +7 -12
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/index.css +5 -1
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
50
|
-
// (OSS, OIDC, kubectl plugin) have no role and pass — single-user
|
|
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)
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
273
|
+
requestCloseRef.current()
|
|
116
274
|
}
|
|
117
275
|
}
|
|
118
276
|
document.addEventListener('keydown', handleKeyDown, true)
|
|
119
277
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
120
|
-
}, [open
|
|
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={
|
|
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
|
-
'
|
|
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={
|
|
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
|
-
{/*
|
|
235
|
-
<div className="
|
|
236
|
-
{
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
<div className="
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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 —
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
// --
|
|
662
|
+
// -- Sidebar primitives -------------------------------------------------------
|
|
350
663
|
|
|
351
|
-
|
|
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
|
-
<
|
|
676
|
+
<h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
354
677
|
{children}
|
|
355
|
-
</
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
<
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
958
|
+
// -- Startup section bodies ---------------------------------------------------
|
|
373
959
|
|
|
374
|
-
function
|
|
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
|
-
|
|
379
|
-
prometheusHeaderKeys,
|
|
1003
|
+
showBrowserLaunchControls,
|
|
380
1004
|
onChange,
|
|
381
1005
|
}: {
|
|
382
1006
|
config: Config
|
|
383
1007
|
effectiveConfig?: Config
|
|
384
1008
|
isDesktop: boolean
|
|
385
|
-
|
|
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
|
-
|
|
392
|
-
<
|
|
393
|
-
|
|
394
|
-
{isDesktop
|
|
395
|
-
? '
|
|
396
|
-
: '
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
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
|
-
|
|
1275
|
+
Server URL
|
|
689
1276
|
</label>
|
|
690
1277
|
<p className="text-xs text-theme-text-tertiary mb-1">
|
|
691
|
-
Manual Prometheus/VictoriaMetrics URL
|
|
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
|
|
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'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't be
|
|
1476
|
+
used, so the deep diff is disabled. Fix the deployment'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'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'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't loading, verify the token is valid for this cluster'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 & 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-
|
|
944
|
-
<span className="
|
|
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
|
>
|