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