@skyhook-io/radar-app 1.8.6 → 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/README.md +7 -1
- package/package.json +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- 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/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- 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/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- 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 +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -1,14 +1,24 @@
|
|
|
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'
|
|
17
|
+
import { Input } from '@skyhook-io/k8s-ui'
|
|
10
18
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
|
-
import type
|
|
19
|
+
import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
|
|
20
|
+
import { MyPermissionsContent } from './MyPermissionsDialog'
|
|
21
|
+
import { useDiagnose } from '../diagnose/DiagnoseContext'
|
|
12
22
|
|
|
13
23
|
interface Config {
|
|
14
24
|
kubeconfig?: string
|
|
@@ -21,6 +31,8 @@ interface Config {
|
|
|
21
31
|
timelineDbPath?: string
|
|
22
32
|
historyLimit?: number
|
|
23
33
|
prometheusUrl?: string
|
|
34
|
+
argoCdUrl?: string
|
|
35
|
+
argoCdInsecureTls?: boolean
|
|
24
36
|
mcp?: boolean | null
|
|
25
37
|
}
|
|
26
38
|
|
|
@@ -29,39 +41,132 @@ interface ConfigResponse {
|
|
|
29
41
|
effective: Config
|
|
30
42
|
isDesktop: boolean
|
|
31
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 }
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
interface SettingsDialogProps {
|
|
35
61
|
open: boolean
|
|
36
62
|
onClose: () => void
|
|
37
|
-
onShowMyPermissions?: () => void
|
|
38
63
|
}
|
|
39
64
|
|
|
40
|
-
|
|
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) {
|
|
41
93
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
42
94
|
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
43
95
|
const { data: versionInfo } = useVersionCheck()
|
|
44
96
|
// Radar configuration (kubeconfig, port, integrations…) is host-level and
|
|
45
97
|
// affects every user of this instance, so it's gated to owners. Personal
|
|
46
|
-
// sections (My permissions) stay
|
|
47
|
-
// (OSS, OIDC, kubectl plugin) have no role and pass — single-user
|
|
48
|
-
// 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.
|
|
49
101
|
const { canAtLeast } = useCloudRole()
|
|
50
102
|
const capabilities = useCapabilitiesContext()
|
|
51
103
|
const canEditConfig = canAtLeast('owner')
|
|
104
|
+
|
|
52
105
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
53
106
|
const [editedConfig, setEditedConfig] = useState<Config>({})
|
|
54
107
|
const [saving, setSaving] = useState(false)
|
|
55
108
|
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
|
56
|
-
const [configDirty, setConfigDirty] = useState(false)
|
|
57
109
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
110
|
+
const [section, setSection] = useState<SectionId>('overview')
|
|
111
|
+
const [confirmingClose, setConfirmingClose] = useState(false)
|
|
112
|
+
|
|
113
|
+
// AI Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
|
|
114
|
+
// section has its own Save that commits the draft to DiagnoseContext, so it's
|
|
115
|
+
// independent of the owner-gated footer. The draft is snapshotted on open.
|
|
116
|
+
const diag = useDiagnose()
|
|
117
|
+
const aiAvailable = diag.available && diag.agents.length > 0
|
|
118
|
+
const [aiDraft, setAiDraft] = useState<AIDraft>({
|
|
119
|
+
agent: diag.selectedAgent,
|
|
120
|
+
isolated: diag.isolated,
|
|
121
|
+
model: diag.model,
|
|
122
|
+
effort: diag.effort,
|
|
123
|
+
})
|
|
124
|
+
const [aiSaved, setAiSaved] = useState(false)
|
|
125
|
+
const aiDirty =
|
|
126
|
+
aiDraft.agent !== diag.selectedAgent ||
|
|
127
|
+
aiDraft.isolated !== diag.isolated ||
|
|
128
|
+
aiDraft.model !== diag.model ||
|
|
129
|
+
aiDraft.effort !== diag.effort
|
|
58
130
|
|
|
59
|
-
//
|
|
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.
|
|
60
153
|
useEffect(() => {
|
|
61
154
|
if (!open) return
|
|
62
155
|
setSaveMessage(null)
|
|
63
|
-
setConfigDirty(false)
|
|
64
156
|
setLoadError(null)
|
|
157
|
+
setConfirmingClose(false)
|
|
158
|
+
setAiSaved(false)
|
|
159
|
+
setAiDraft({
|
|
160
|
+
agent: diag.selectedAgent,
|
|
161
|
+
isolated: diag.isolated,
|
|
162
|
+
model: diag.model,
|
|
163
|
+
effort: diag.effort,
|
|
164
|
+
})
|
|
165
|
+
// Overview is the landing section — a status-at-a-glance of what Radar is
|
|
166
|
+
// connected to (cluster, integrations, MCP, AI), useful to owners and
|
|
167
|
+
// viewers alike, rather than dropping owners on a config form or everyone
|
|
168
|
+
// on a permissions dump.
|
|
169
|
+
setSection('overview')
|
|
65
170
|
|
|
66
171
|
fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
67
172
|
.then((res) => {
|
|
@@ -76,68 +181,137 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
76
181
|
console.warn('[settings] Failed to load config:', err)
|
|
77
182
|
setLoadError('Failed to load configuration.')
|
|
78
183
|
})
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// ESC key
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (!open) return
|
|
84
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
85
|
-
if (e.key === 'Escape') {
|
|
86
|
-
e.stopPropagation()
|
|
87
|
-
onClose()
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
document.addEventListener('keydown', handleKeyDown, true)
|
|
91
|
-
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
92
|
-
}, [open, onClose])
|
|
93
|
-
|
|
94
|
-
// Focus trap
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
if (open && dialogRef.current) {
|
|
97
|
-
dialogRef.current.focus()
|
|
98
|
-
}
|
|
184
|
+
// Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
|
|
185
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
186
|
}, [open])
|
|
100
187
|
|
|
101
188
|
const updateConfigField = useCallback(<K extends keyof Config>(field: K, value: Config[K]) => {
|
|
102
189
|
setEditedConfig((prev) => ({ ...prev, [field]: value }))
|
|
103
|
-
setConfigDirty(true)
|
|
104
190
|
setSaveMessage(null)
|
|
105
191
|
}, [])
|
|
106
192
|
|
|
107
|
-
const saveConfig = useCallback(async () => {
|
|
193
|
+
const saveConfig = useCallback(async (): Promise<boolean> => {
|
|
194
|
+
if (!configData) return false
|
|
108
195
|
setSaving(true)
|
|
109
196
|
setSaveMessage(null)
|
|
110
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
|
+
}
|
|
111
209
|
const res = await fetch(apiUrl('/config'), {
|
|
112
210
|
method: 'PUT',
|
|
113
211
|
credentials: getCredentialsMode(),
|
|
114
212
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
115
|
-
body: JSON.stringify(
|
|
213
|
+
body: JSON.stringify(body),
|
|
116
214
|
})
|
|
117
215
|
if (!res.ok) {
|
|
118
216
|
const data = await res.json().catch(() => null)
|
|
119
217
|
setSaveMessage(`Error: ${data?.error || res.statusText}`)
|
|
120
|
-
|
|
121
|
-
setConfigDirty(false)
|
|
122
|
-
setSaveMessage('Saved. Changes take effect on next launch.')
|
|
218
|
+
return false
|
|
123
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
|
|
124
224
|
} catch (err) {
|
|
125
225
|
setSaveMessage(`Error: ${err}`)
|
|
226
|
+
return false
|
|
126
227
|
} finally {
|
|
127
228
|
setSaving(false)
|
|
128
229
|
}
|
|
129
|
-
}, [editedConfig])
|
|
230
|
+
}, [editedConfig, configData])
|
|
130
231
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
232
|
+
// AI prefs are client-side (localStorage) — commit the staged draft now.
|
|
233
|
+
// setSelectedAgent clears model/effort (they're agent-specific), so set the
|
|
234
|
+
// agent first, then restore the draft's model/effort.
|
|
235
|
+
const saveAi = useCallback(() => {
|
|
236
|
+
diag.setSelectedAgent(aiDraft.agent)
|
|
237
|
+
diag.setIsolated(aiDraft.isolated)
|
|
238
|
+
diag.setModel(aiDraft.model)
|
|
239
|
+
diag.setEffort(aiDraft.effort)
|
|
240
|
+
setAiSaved(true)
|
|
241
|
+
}, [diag, aiDraft])
|
|
242
|
+
|
|
243
|
+
const discardChanges = useCallback(() => {
|
|
244
|
+
// Revert unsaved startup edits back to the last-saved values (non-
|
|
245
|
+
// destructive). configData.file holds the committed config, including the
|
|
246
|
+
// live integration fields, so restoring it drops drafts without touching
|
|
247
|
+
// what's saved.
|
|
248
|
+
if (!configData) return
|
|
249
|
+
setEditedConfig(configData.file)
|
|
250
|
+
setSaveMessage(null)
|
|
251
|
+
}, [configData])
|
|
252
|
+
|
|
253
|
+
const handleSaveAndClose = useCallback(async () => {
|
|
254
|
+
const ok = await saveConfig()
|
|
255
|
+
if (ok) onClose()
|
|
256
|
+
}, [saveConfig, onClose])
|
|
257
|
+
|
|
258
|
+
// Close guard: a pending startup edit prompts an inline confirm rather than
|
|
259
|
+
// silently discarding. An unsaved AI draft is re-derivable, so it's fine to
|
|
260
|
+
// drop it on close. Held in a ref so the ESC listener reads current dirtiness.
|
|
261
|
+
const requestCloseRef = useRef<() => void>(() => {})
|
|
262
|
+
requestCloseRef.current = () => {
|
|
263
|
+
if (canEditConfig && startupDirty) setConfirmingClose(true)
|
|
264
|
+
else onClose()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ESC key
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (!open) return
|
|
270
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
271
|
+
if (e.key === 'Escape') {
|
|
272
|
+
e.stopPropagation()
|
|
273
|
+
requestCloseRef.current()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
277
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
278
|
+
}, [open])
|
|
279
|
+
|
|
280
|
+
// Post-save feedback ("Saved. Restart Radar to apply.") is scoped to the
|
|
281
|
+
// section it was saved from. Clear it on navigation so it doesn't linger — with
|
|
282
|
+
// its disabled footer — on a live-apply section where "Restart to apply" is
|
|
283
|
+
// wrong. It still stays put while the user remains on the saved section.
|
|
284
|
+
useEffect(() => { setSaveMessage(null) }, [section])
|
|
285
|
+
|
|
286
|
+
// Focus trap
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (open && dialogRef.current) {
|
|
289
|
+
dialogRef.current.focus()
|
|
290
|
+
}
|
|
291
|
+
}, [open])
|
|
136
292
|
|
|
137
293
|
if (!shouldRender) return null
|
|
138
294
|
|
|
139
295
|
const isDesktop = configData?.isDesktop ?? false
|
|
140
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)
|
|
141
315
|
|
|
142
316
|
return createPortal(
|
|
143
317
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
@@ -148,7 +322,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
148
322
|
TRANSITION_BACKDROP,
|
|
149
323
|
isOpen ? 'opacity-100' : 'opacity-0'
|
|
150
324
|
)}
|
|
151
|
-
onClick={
|
|
325
|
+
onClick={() => requestCloseRef.current()}
|
|
152
326
|
/>
|
|
153
327
|
|
|
154
328
|
{/* Dialog */}
|
|
@@ -158,12 +332,15 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
158
332
|
className={clsx(
|
|
159
333
|
'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
|
|
160
334
|
'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
|
|
161
|
-
'
|
|
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]',
|
|
162
339
|
TRANSITION_PANEL,
|
|
163
340
|
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
|
164
341
|
)}
|
|
165
342
|
>
|
|
166
|
-
{/* Header */}
|
|
343
|
+
{/* Header — spans both panes */}
|
|
167
344
|
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
168
345
|
<div className="flex items-center gap-2">
|
|
169
346
|
<Settings className="w-5 h-5 text-theme-text-secondary" />
|
|
@@ -176,270 +353,732 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
176
353
|
</div>
|
|
177
354
|
</div>
|
|
178
355
|
<button
|
|
179
|
-
onClick={
|
|
356
|
+
onClick={() => requestCloseRef.current()}
|
|
180
357
|
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
181
358
|
>
|
|
182
359
|
<X className="w-5 h-5" />
|
|
183
360
|
</button>
|
|
184
361
|
</div>
|
|
185
362
|
|
|
186
|
-
{/*
|
|
187
|
-
<div className="
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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} />
|
|
212
415
|
</div>
|
|
213
416
|
</div>
|
|
214
|
-
)}
|
|
215
417
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
effectiveConfig={configData?.effective}
|
|
221
|
-
isDesktop={isDesktop}
|
|
222
|
-
deploymentMode={deploymentMode}
|
|
223
|
-
prometheusHeaderKeys={configData?.prometheusHeaderKeys ?? []}
|
|
224
|
-
onChange={updateConfigField}
|
|
225
|
-
/>
|
|
226
|
-
) : (
|
|
227
|
-
<div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
|
|
228
|
-
<Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
|
|
229
|
-
<div className="min-w-0">
|
|
230
|
-
<p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
|
|
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>
|
|
231
422
|
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
owner if you need a change here.
|
|
423
|
+
What your current identity can do in this cluster — roles bound to you and your
|
|
424
|
+
effective, flattened permissions.
|
|
235
425
|
</p>
|
|
236
426
|
</div>
|
|
427
|
+
<div className="mt-3">
|
|
428
|
+
<MyPermissionsContent active={section === 'perms'} />
|
|
429
|
+
</div>
|
|
237
430
|
</div>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
431
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
</
|
|
257
|
-
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{
|
|
263
|
-
|
|
432
|
+
{/* Connection — Cluster + Server merged */}
|
|
433
|
+
<SectionPane
|
|
434
|
+
id="connection"
|
|
435
|
+
active={section}
|
|
436
|
+
title="Connection"
|
|
437
|
+
caption="Takes effect on next launch."
|
|
438
|
+
locked={!canEditConfig}
|
|
439
|
+
>
|
|
440
|
+
<div className="space-y-4">
|
|
441
|
+
<SubHeading>Cluster</SubHeading>
|
|
442
|
+
<ClusterSection
|
|
443
|
+
config={editedConfig}
|
|
444
|
+
effectiveConfig={configData?.effective}
|
|
445
|
+
onChange={updateConfigField}
|
|
446
|
+
/>
|
|
447
|
+
</div>
|
|
448
|
+
<div className="space-y-4 border-t border-theme-border-subtle pt-4">
|
|
449
|
+
<SubHeading>Server</SubHeading>
|
|
450
|
+
<ServerSection
|
|
451
|
+
config={editedConfig}
|
|
452
|
+
effectiveConfig={configData?.effective}
|
|
453
|
+
isDesktop={isDesktop}
|
|
454
|
+
showBrowserLaunchControls={showBrowserLaunchControls}
|
|
455
|
+
onChange={updateConfigField}
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</SectionPane>
|
|
459
|
+
|
|
460
|
+
{/* Prometheus — live */}
|
|
461
|
+
<SectionPane
|
|
462
|
+
id="prometheus"
|
|
463
|
+
active={section}
|
|
464
|
+
title="Prometheus"
|
|
465
|
+
caption="Applies immediately — no restart."
|
|
466
|
+
live
|
|
467
|
+
locked={!canEditConfig}
|
|
468
|
+
>
|
|
469
|
+
<PrometheusConfigField
|
|
470
|
+
value={editedConfig.prometheusUrl ?? ''}
|
|
471
|
+
configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
|
|
472
|
+
onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
|
|
473
|
+
onApplied={(url) =>
|
|
474
|
+
setConfigData((prev) =>
|
|
475
|
+
prev ? { ...prev, file: { ...prev.file, prometheusUrl: url || undefined } } : prev
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
/>
|
|
479
|
+
</SectionPane>
|
|
480
|
+
|
|
481
|
+
{/* Argo CD — live */}
|
|
482
|
+
<SectionPane
|
|
483
|
+
id="argocd"
|
|
484
|
+
active={section}
|
|
485
|
+
title="Argo CD"
|
|
486
|
+
caption="Applies immediately — no restart."
|
|
487
|
+
live
|
|
488
|
+
locked={!canEditConfig}
|
|
489
|
+
>
|
|
490
|
+
<ArgoCDConfigField
|
|
491
|
+
url={editedConfig.argoCdUrl ?? ''}
|
|
492
|
+
insecureTls={editedConfig.argoCdInsecureTls ?? false}
|
|
493
|
+
tokenSet={configData?.argoCdTokenSet ?? false}
|
|
494
|
+
envManaged={configData?.argoCdEnvManaged ?? false}
|
|
495
|
+
envError={configData?.argoCdEnvError}
|
|
496
|
+
cliSession={configData?.argoCdCliSession}
|
|
497
|
+
onChangeUrl={(v) => updateConfigField('argoCdUrl', v || undefined)}
|
|
498
|
+
onChangeInsecureTls={(v) => updateConfigField('argoCdInsecureTls', v || undefined)}
|
|
499
|
+
onApplied={({ url, insecureTls, tokenSet }) =>
|
|
500
|
+
setConfigData((prev) =>
|
|
501
|
+
prev
|
|
502
|
+
? {
|
|
503
|
+
...prev,
|
|
504
|
+
file: { ...prev.file, argoCdUrl: url || undefined, argoCdInsecureTls: insecureTls || undefined },
|
|
505
|
+
argoCdTokenSet: tokenSet,
|
|
506
|
+
}
|
|
507
|
+
: prev
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
/>
|
|
511
|
+
</SectionPane>
|
|
512
|
+
|
|
513
|
+
{/* AI diagnose — self-saving, usable by everyone. Same heading block
|
|
514
|
+
as every other tab; the body is the agent controls (when a CLI is
|
|
515
|
+
installed) or an enable explainer (when not). */}
|
|
516
|
+
<div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel">
|
|
517
|
+
<div className="mb-4">
|
|
518
|
+
<h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
|
|
519
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
520
|
+
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.
|
|
522
|
+
</p>
|
|
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 />
|
|
264
554
|
)}
|
|
265
555
|
</div>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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>
|
|
280
584
|
</div>
|
|
281
|
-
|
|
585
|
+
</div>
|
|
586
|
+
|
|
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
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
282
656
|
</div>
|
|
283
657
|
</div>,
|
|
284
658
|
document.body
|
|
285
659
|
)
|
|
286
660
|
}
|
|
287
661
|
|
|
288
|
-
// --
|
|
662
|
+
// -- Sidebar primitives -------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
interface NavItemDef {
|
|
665
|
+
id: SectionId
|
|
666
|
+
label: string
|
|
667
|
+
icon: LucideIcon
|
|
668
|
+
ownerOnly: boolean
|
|
669
|
+
dirty: boolean
|
|
670
|
+
}
|
|
289
671
|
|
|
290
|
-
|
|
672
|
+
// Light subheading separating the two field groups inside a merged pane
|
|
673
|
+
// (Cluster/Server, MCP/Timeline).
|
|
674
|
+
function SubHeading({ children }: { children: ReactNode }) {
|
|
291
675
|
return (
|
|
292
|
-
<
|
|
676
|
+
<h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
293
677
|
{children}
|
|
294
|
-
</
|
|
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>
|
|
295
929
|
)
|
|
296
930
|
}
|
|
297
931
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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() {
|
|
301
946
|
return (
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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>
|
|
308
955
|
)
|
|
309
956
|
}
|
|
310
957
|
|
|
311
|
-
// -- Startup
|
|
958
|
+
// -- Startup section bodies ---------------------------------------------------
|
|
312
959
|
|
|
313
|
-
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({
|
|
314
1000
|
config,
|
|
315
1001
|
effectiveConfig,
|
|
316
1002
|
isDesktop,
|
|
317
|
-
|
|
318
|
-
prometheusHeaderKeys,
|
|
1003
|
+
showBrowserLaunchControls,
|
|
319
1004
|
onChange,
|
|
320
1005
|
}: {
|
|
321
1006
|
config: Config
|
|
322
1007
|
effectiveConfig?: Config
|
|
323
1008
|
isDesktop: boolean
|
|
324
|
-
|
|
325
|
-
prometheusHeaderKeys: string[]
|
|
1009
|
+
showBrowserLaunchControls: boolean
|
|
326
1010
|
onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
|
|
327
1011
|
}) {
|
|
328
|
-
const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
|
|
329
1012
|
return (
|
|
330
|
-
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
{isDesktop
|
|
334
|
-
? '
|
|
335
|
-
: '
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
help="Path to kubeconfig file"
|
|
342
|
-
value={config.kubeconfig ?? ''}
|
|
343
|
-
effectiveValue={effectiveConfig?.kubeconfig}
|
|
344
|
-
placeholder="~/.kube/config"
|
|
345
|
-
onChange={(v) => onChange('kubeconfig', v || undefined)}
|
|
346
|
-
/>
|
|
347
|
-
|
|
348
|
-
<ConfigArrayField
|
|
349
|
-
label="Kubeconfig Directories"
|
|
350
|
-
help="Comma-separated directories containing kubeconfig files"
|
|
351
|
-
value={config.kubeconfigDirs}
|
|
352
|
-
effectiveValue={effectiveConfig?.kubeconfigDirs}
|
|
353
|
-
placeholder="/path/to/dir1, /path/to/dir2"
|
|
354
|
-
onChange={(v) => onChange('kubeconfigDirs', v)}
|
|
355
|
-
/>
|
|
356
|
-
|
|
357
|
-
<ConfigField
|
|
358
|
-
label="Default Namespace"
|
|
359
|
-
help="Startup default only — change the active namespace live anytime from the header switcher"
|
|
360
|
-
value={config.namespace ?? ''}
|
|
361
|
-
effectiveValue={effectiveConfig?.namespace}
|
|
362
|
-
placeholder="All namespaces"
|
|
363
|
-
onChange={(v) => onChange('namespace', v || undefined)}
|
|
364
|
-
/>
|
|
365
|
-
</ConfigSection>
|
|
366
|
-
|
|
367
|
-
<ConfigSection title="Server">
|
|
368
|
-
<ConfigNumberField
|
|
369
|
-
label="Port"
|
|
370
|
-
help={isDesktop
|
|
371
|
-
? 'Fixed server port (leave empty for random). Set this to keep a stable MCP endpoint.'
|
|
372
|
-
: 'Server port'}
|
|
373
|
-
value={config.port}
|
|
374
|
-
effectiveValue={effectiveConfig?.port}
|
|
375
|
-
placeholder={isDesktop ? 'Random' : '9280'}
|
|
376
|
-
onChange={(v) => onChange('port', v)}
|
|
377
|
-
/>
|
|
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
|
+
/>
|
|
378
1024
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
<MCPSection
|
|
401
|
-
mcpEnabled={config.mcp ?? true}
|
|
402
|
-
onToggle={(v) => onChange('mcp', v)}
|
|
403
|
-
isDesktop={isDesktop}
|
|
404
|
-
portPinned={config.port != null && config.port > 0}
|
|
405
|
-
onPinPort={(port) => onChange('port', port)}
|
|
406
|
-
/>
|
|
407
|
-
</ConfigSection>
|
|
408
|
-
|
|
409
|
-
<ConfigSection title="Timeline">
|
|
410
|
-
<div>
|
|
411
|
-
<label className="block text-sm font-medium text-theme-text-primary mb-1">
|
|
412
|
-
Storage Backend
|
|
413
|
-
</label>
|
|
414
|
-
<select
|
|
415
|
-
value={config.timelineStorage ?? 'memory'}
|
|
416
|
-
onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
|
|
417
|
-
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"
|
|
418
|
-
>
|
|
419
|
-
<option value="memory">Memory (default)</option>
|
|
420
|
-
<option value="sqlite">SQLite (persistent)</option>
|
|
421
|
-
</select>
|
|
422
|
-
<EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
|
|
423
|
-
</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
|
+
}
|
|
424
1046
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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>
|
|
434
1072
|
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
</>
|
|
443
1082
|
)
|
|
444
1083
|
}
|
|
445
1084
|
|
|
@@ -475,6 +1114,10 @@ function MCPSection({
|
|
|
475
1114
|
|
|
476
1115
|
return (
|
|
477
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>
|
|
478
1121
|
<ConfigToggle
|
|
479
1122
|
label="MCP Server"
|
|
480
1123
|
value={mcpEnabled}
|
|
@@ -528,13 +1171,12 @@ function MCPSection({
|
|
|
528
1171
|
|
|
529
1172
|
// -- Prometheus (live-appliable) ----------------------------------------------
|
|
530
1173
|
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
// 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.
|
|
538
1180
|
type ApplyState =
|
|
539
1181
|
| { status: 'idle' }
|
|
540
1182
|
| { status: 'applying' }
|
|
@@ -548,10 +1190,12 @@ function PrometheusConfigField({
|
|
|
548
1190
|
value,
|
|
549
1191
|
onChange,
|
|
550
1192
|
configuredHeaderKeys,
|
|
1193
|
+
onApplied,
|
|
551
1194
|
}: {
|
|
552
1195
|
value: string
|
|
553
1196
|
onChange: (value: string) => void
|
|
554
1197
|
configuredHeaderKeys: string[]
|
|
1198
|
+
onApplied?: (url: string) => void
|
|
555
1199
|
}) {
|
|
556
1200
|
const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
|
|
557
1201
|
// null = not editing headers (preserve what's stored). A non-null array means
|
|
@@ -605,6 +1249,7 @@ function PrometheusConfigField({
|
|
|
605
1249
|
setApply({ status: 'failed', error: data?.error || res.statusText })
|
|
606
1250
|
return
|
|
607
1251
|
}
|
|
1252
|
+
onApplied?.(value.trim())
|
|
608
1253
|
if (editedHeaders !== undefined) {
|
|
609
1254
|
setAppliedKeys(Object.keys(editedHeaders).sort())
|
|
610
1255
|
}
|
|
@@ -623,15 +1268,17 @@ function PrometheusConfigField({
|
|
|
623
1268
|
|
|
624
1269
|
return (
|
|
625
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>
|
|
626
1274
|
<label className="block text-sm font-medium text-theme-text-primary mb-1">
|
|
627
|
-
|
|
1275
|
+
Server URL
|
|
628
1276
|
</label>
|
|
629
1277
|
<p className="text-xs text-theme-text-tertiary mb-1">
|
|
630
|
-
Manual Prometheus/VictoriaMetrics URL
|
|
1278
|
+
Manual Prometheus / VictoriaMetrics URL — set this to skip auto-discovery.
|
|
631
1279
|
</p>
|
|
632
1280
|
<div className="flex items-center gap-2">
|
|
633
|
-
<
|
|
634
|
-
type="text"
|
|
1281
|
+
<Input
|
|
635
1282
|
value={value}
|
|
636
1283
|
onChange={(e) => onChange(e.target.value)}
|
|
637
1284
|
placeholder="http://prometheus-server.monitoring:9090"
|
|
@@ -641,7 +1288,7 @@ function PrometheusConfigField({
|
|
|
641
1288
|
<button
|
|
642
1289
|
onClick={handleApply}
|
|
643
1290
|
disabled={apply.status === 'applying'}
|
|
644
|
-
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"
|
|
645
1292
|
>
|
|
646
1293
|
{apply.status === 'applying'
|
|
647
1294
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
@@ -689,8 +1336,7 @@ function PrometheusConfigField({
|
|
|
689
1336
|
<div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
|
|
690
1337
|
{headerRows.map((row, i) => (
|
|
691
1338
|
<div key={i} className="flex items-center gap-2">
|
|
692
|
-
<
|
|
693
|
-
type="text"
|
|
1339
|
+
<Input
|
|
694
1340
|
value={row.key}
|
|
695
1341
|
onChange={(e) => {
|
|
696
1342
|
setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
|
|
@@ -745,6 +1391,362 @@ function PrometheusConfigField({
|
|
|
745
1391
|
)
|
|
746
1392
|
}
|
|
747
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
|
+
|
|
748
1750
|
// -- Shared Field Components --------------------------------------------------
|
|
749
1751
|
|
|
750
1752
|
function ConfigField({
|
|
@@ -768,8 +1770,7 @@ function ConfigField({
|
|
|
768
1770
|
{label}
|
|
769
1771
|
</label>
|
|
770
1772
|
{help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
|
|
771
|
-
<
|
|
772
|
-
type="text"
|
|
1773
|
+
<Input
|
|
773
1774
|
value={value}
|
|
774
1775
|
onChange={(e) => onChange(e.target.value)}
|
|
775
1776
|
placeholder={placeholder}
|
|
@@ -820,8 +1821,7 @@ function ConfigArrayField({
|
|
|
820
1821
|
{label}
|
|
821
1822
|
</label>
|
|
822
1823
|
{help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
|
|
823
|
-
<
|
|
824
|
-
type="text"
|
|
1824
|
+
<Input
|
|
825
1825
|
value={text}
|
|
826
1826
|
onFocus={() => { focusedRef.current = true }}
|
|
827
1827
|
onBlur={() => {
|
|
@@ -875,22 +1875,29 @@ function ConfigNumberField({
|
|
|
875
1875
|
|
|
876
1876
|
function ConfigToggle({
|
|
877
1877
|
label,
|
|
1878
|
+
description,
|
|
878
1879
|
value,
|
|
879
1880
|
onChange,
|
|
880
1881
|
}: {
|
|
881
1882
|
label: string
|
|
1883
|
+
description?: string
|
|
882
1884
|
value: boolean
|
|
883
1885
|
onChange: (value: boolean) => void
|
|
884
1886
|
}) {
|
|
885
1887
|
return (
|
|
886
|
-
<label className="flex items-
|
|
887
|
-
<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>
|
|
888
1895
|
<button
|
|
889
1896
|
role="switch"
|
|
890
1897
|
aria-checked={value}
|
|
891
1898
|
onClick={() => onChange(!value)}
|
|
892
1899
|
className={clsx(
|
|
893
|
-
'relative w-9 h-5 rounded-full transition-colors',
|
|
1900
|
+
'relative w-9 h-5 shrink-0 rounded-full transition-colors',
|
|
894
1901
|
value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
|
|
895
1902
|
)}
|
|
896
1903
|
>
|