@skyhook-io/radar-app 1.14.6 → 1.16.0
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 +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -2,17 +2,17 @@ import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import {
|
|
4
4
|
Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug,
|
|
5
|
-
|
|
6
|
-
LayoutDashboard, ChevronRight,
|
|
5
|
+
Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
|
|
6
|
+
LayoutDashboard, ChevronRight, AlertTriangle, Coins, EyeOff,
|
|
7
7
|
type LucideIcon,
|
|
8
8
|
} from 'lucide-react'
|
|
9
9
|
import { clsx } from 'clsx'
|
|
10
10
|
import { useQueryClient } from '@tanstack/react-query'
|
|
11
11
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
12
12
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
13
|
-
import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
|
|
13
|
+
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
|
|
14
14
|
import {
|
|
15
|
-
useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
|
|
15
|
+
useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
|
|
16
16
|
useOpenCostSummary,
|
|
17
17
|
} from '../../api/client'
|
|
18
18
|
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
@@ -21,18 +21,29 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
|
|
|
21
21
|
import { Tooltip } from '../ui/Tooltip'
|
|
22
22
|
import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
|
|
23
23
|
import { MyPermissionsContent } from './MyPermissionsDialog'
|
|
24
|
+
import { PrivacySection } from './PrivacySection'
|
|
25
|
+
import { ConfigToggle, SubHeading } from './controls'
|
|
24
26
|
import { useDiagnose } from '../diagnose/DiagnoseContext'
|
|
25
27
|
import { currencyOptionsForValue } from './currency-options'
|
|
26
|
-
import {
|
|
28
|
+
import { UpdateNotification } from '../ui/UpdateNotification'
|
|
27
29
|
import {
|
|
28
30
|
costConfigurationAction,
|
|
29
31
|
costFreshnessLabel,
|
|
30
32
|
costIntegrationUnavailableMessage,
|
|
31
33
|
costSourceLabel,
|
|
34
|
+
costSourcePreferenceLabel,
|
|
35
|
+
COST_SOURCE_OPTIONS,
|
|
32
36
|
} from '../cost/source'
|
|
33
|
-
import { costSourceApplyLabel,
|
|
37
|
+
import { costSourceApplyLabel, pendingSectionLabels, pendingSections, shouldShowSettingsFooter } from './settings-state'
|
|
38
|
+
import { PrometheusConfigField } from './PrometheusConfigField'
|
|
39
|
+
import { LocalConnectionSettings, type IntegrationProfiles, type IntegrationKind } from './LocalConnectionSettings'
|
|
40
|
+
import { LocalIntegrationStatus } from './LocalIntegrationStatus'
|
|
41
|
+
import { LocalConfigurationDetails, SavedClusterConnections, PreviousIntegrationSettingsNotice } from './LocalConfigurationDetails'
|
|
42
|
+
import { useContextSwitch } from '../../context/ContextSwitchContext'
|
|
43
|
+
import { previousIntegrationSettingsKey, type IntegrationSettingsResponse } from '../../hooks/usePreviousIntegrationSettings'
|
|
34
44
|
import type { SettingsSectionId } from './settings-state'
|
|
35
45
|
import { OperatorManagedNotice } from './OperatorManagedNotice'
|
|
46
|
+
import { FormSaveActions } from './FormSaveActions'
|
|
36
47
|
export type { SettingsSectionId } from './settings-state'
|
|
37
48
|
|
|
38
49
|
function mcpEndpointUrl(): string {
|
|
@@ -60,8 +71,7 @@ interface Config {
|
|
|
60
71
|
restoreLastDesktopContext?: boolean | null
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
interface ConfigResponse {
|
|
64
|
-
management: 'local' | 'operator' | 'cloud'
|
|
74
|
+
interface ConfigResponse extends IntegrationSettingsResponse {
|
|
65
75
|
file: Config
|
|
66
76
|
effective: Config
|
|
67
77
|
isDesktop: boolean
|
|
@@ -128,6 +138,8 @@ function normalizeStartup(c: Config) {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
const localIntegrationCaption = 'Saved for this cluster · applies when you save, no restart.'
|
|
142
|
+
|
|
131
143
|
export function SettingsDialog({
|
|
132
144
|
open,
|
|
133
145
|
onClose,
|
|
@@ -140,6 +152,39 @@ export function SettingsDialog({
|
|
|
140
152
|
const { data: versionInfo } = useVersionCheck()
|
|
141
153
|
const { canAtLeast } = useCloudRole()
|
|
142
154
|
const capabilities = useCapabilitiesContext()
|
|
155
|
+
const { data: settingsCluster } = useClusterInfo()
|
|
156
|
+
const { isSwitching } = useContextSwitch()
|
|
157
|
+
const settingsApiBase = getApiBase()
|
|
158
|
+
const [prometheusCredentialDirty, setPrometheusCredentialDirty] = useState(false)
|
|
159
|
+
const [localDirty, setLocalDirty] = useState<Record<IntegrationKind, boolean>>({ metrics: false, argocd: false, cost: false })
|
|
160
|
+
const [discardGeneration, setDiscardGeneration] = useState(0)
|
|
161
|
+
const [localBusy, setLocalBusy] = useState<Record<IntegrationKind, boolean>>({ metrics: false, argocd: false, cost: false })
|
|
162
|
+
const [storageBusy, setStorageBusy] = useState(false)
|
|
163
|
+
const integrationBusy = Object.values(localBusy).some(Boolean)
|
|
164
|
+
const metricsBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, metrics: busy })), [])
|
|
165
|
+
const argoBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, argocd: busy })), [])
|
|
166
|
+
const costBusyChange = useCallback((busy: boolean) => setLocalBusy(value => ({ ...value, cost: busy })), [])
|
|
167
|
+
const metricsDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, metrics: dirty })), [])
|
|
168
|
+
const argoDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, argocd: dirty })), [])
|
|
169
|
+
const costDirtyChange = useCallback((dirty: boolean) => setLocalDirty(value => ({ ...value, cost: dirty })), [])
|
|
170
|
+
const [connectionsChangedAt, setConnectionsChangedAt] = useState(0)
|
|
171
|
+
const overviewHeading = useRef<HTMLHeadingElement>(null)
|
|
172
|
+
const previousNoticeDismissed = useCallback(() => {
|
|
173
|
+
// The notice unmounts with its focused button; keep focus in the dialog.
|
|
174
|
+
requestAnimationFrame(() => overviewHeading.current?.focus())
|
|
175
|
+
setConfigData(value => value?.integrationProfiles ? {
|
|
176
|
+
...value,
|
|
177
|
+
integrationProfiles: Object.fromEntries(Object.entries(value.integrationProfiles).map(([kind, profile]) =>
|
|
178
|
+
[kind, profile.legacy ? { ...profile, legacy: { ...profile.legacy, noticeDismissed: true } } : profile])) as IntegrationProfiles,
|
|
179
|
+
} : value)
|
|
180
|
+
void queryClient.resetQueries({ queryKey: [previousIntegrationSettingsKey] })
|
|
181
|
+
}, [queryClient])
|
|
182
|
+
const connectionsChanged = useCallback((profiles: IntegrationProfiles) => {
|
|
183
|
+
setConnectionsChangedAt(Date.now())
|
|
184
|
+
setConfigData(value => value ? { ...value, integrationProfiles: profiles } : value)
|
|
185
|
+
void queryClient.resetQueries({ queryKey: [previousIntegrationSettingsKey] })
|
|
186
|
+
void queryClient.invalidateQueries({ predicate: query => typeof query.queryKey[0] === 'string' && /^(prometheus|opencost|gitops|argo)/.test(query.queryKey[0]) })
|
|
187
|
+
}, [queryClient])
|
|
143
188
|
|
|
144
189
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
145
190
|
const operatorManaged = configData?.management === 'operator'
|
|
@@ -156,9 +201,10 @@ export function SettingsDialog({
|
|
|
156
201
|
const configSavingRef = useRef(false)
|
|
157
202
|
const costCurrencySavingRef = useRef(false)
|
|
158
203
|
const pendingCloseActionRef = useRef<(() => void) | null>(null)
|
|
159
|
-
const
|
|
204
|
+
const argoStatusQuery = useArgoStatus(
|
|
160
205
|
open && section === 'argocd'
|
|
161
206
|
)
|
|
207
|
+
const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = argoStatusQuery
|
|
162
208
|
|
|
163
209
|
// Local AI preferences save independently of the owner-gated server settings.
|
|
164
210
|
const diag = useDiagnose()
|
|
@@ -170,11 +216,20 @@ export function SettingsDialog({
|
|
|
170
216
|
effort: diag.effort,
|
|
171
217
|
})
|
|
172
218
|
const [aiSaved, setAiSaved] = useState(false)
|
|
173
|
-
|
|
219
|
+
// Until the user edits, the draft follows the saved preferences: the agent
|
|
220
|
+
// list loads after Settings may already be open, and adopting its defaults
|
|
221
|
+
// is not an edit.
|
|
222
|
+
const [aiTouched, setAiTouched] = useState(false)
|
|
223
|
+
const aiEditable = aiAvailable && !diag.hosted && configData?.management !== 'operator'
|
|
224
|
+
const aiDirty = aiEditable && (
|
|
174
225
|
aiDraft.agent !== diag.selectedAgent ||
|
|
175
226
|
aiDraft.profile !== diag.profile ||
|
|
176
227
|
aiDraft.model !== diag.model ||
|
|
177
|
-
aiDraft.effort !== diag.effort
|
|
228
|
+
aiDraft.effort !== diag.effort)
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (aiTouched) return
|
|
231
|
+
setAiDraft({ agent: diag.selectedAgent, profile: diag.profile, model: diag.model, effort: diag.effort })
|
|
232
|
+
}, [aiTouched, diag.selectedAgent, diag.profile, diag.model, diag.effort])
|
|
178
233
|
|
|
179
234
|
// Per-bucket normalized dirty. Only startup fields participate; integration
|
|
180
235
|
// fields apply live and never light up the footer.
|
|
@@ -196,17 +251,47 @@ export function SettingsDialog({
|
|
|
196
251
|
(editedConfig.costSource ?? 'auto') !== (configData?.file.costSource ?? 'auto') ||
|
|
197
252
|
(editedConfig.kubecostUrl ?? '').trim() !== (configData?.file.kubecostUrl ?? '').trim() ||
|
|
198
253
|
(editedConfig.kubecostClusterId ?? '').trim() !== (configData?.file.kubecostClusterId ?? '').trim()
|
|
199
|
-
const costIntegrationDirty = costSourceDirty || costCredentialDirty
|
|
254
|
+
const costIntegrationDirty = costSourceDirty || costCredentialDirty || localDirty.cost
|
|
200
255
|
// Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
|
|
201
256
|
const connectionDirty = clusterDirty || serverDirty
|
|
202
257
|
const advancedDirty = mcpDirty || timelineDirty
|
|
203
258
|
const configDirty = configData != null && (connectionDirty || advancedDirty)
|
|
259
|
+
const prometheusDirty = prometheusCredentialDirty || localDirty.metrics || (editedConfig.prometheusUrl ?? '') !== (configData?.effective.prometheusUrl ?? '')
|
|
260
|
+
const pending = pendingSections({ prometheus: prometheusDirty, cost: costIntegrationDirty, argocd: localDirty.argocd, ai: aiDirty })
|
|
261
|
+
const integrationDirty = pending.some(item => item !== 'ai')
|
|
262
|
+
// Drafts that save through their own form, which "Save startup settings" leaves in place.
|
|
263
|
+
const formDirty = pending.length > 0
|
|
264
|
+
const reviewSection = pending.find(item => item !== section)
|
|
265
|
+
const settingsBusy = saving || costCurrencySaving || integrationBusy || storageBusy
|
|
266
|
+
const metricsDraft = useRef({ open, scope: `${settingsApiBase}:${settingsCluster?.context}`, dirty: false })
|
|
267
|
+
const [draftFrozen, setDraftFrozen] = useState(false)
|
|
268
|
+
const [reloadVersion, setReloadVersion] = useState(0)
|
|
269
|
+
const settingsScope = `${settingsApiBase}:${settingsCluster?.context}`
|
|
270
|
+
const targetChangedWithDraft = open && metricsDraft.current.open && metricsDraft.current.dirty && (isSwitching || metricsDraft.current.scope !== settingsScope)
|
|
271
|
+
const showLocalStatus = open && !draftFrozen && !targetChangedWithDraft && !isSwitching
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!open) {
|
|
274
|
+
setDraftFrozen(false)
|
|
275
|
+
metricsDraft.current = { open: false, scope: settingsScope, dirty: false }
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (targetChangedWithDraft) {
|
|
279
|
+
setDraftFrozen(true)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (draftFrozen) return
|
|
283
|
+
metricsDraft.current = { open, scope: settingsScope, dirty: configData != null && (prometheusDirty || configDirty || costIntegrationDirty || localDirty.argocd || aiDirty) }
|
|
284
|
+
}, [open, settingsScope, targetChangedWithDraft, draftFrozen, configData, prometheusDirty, configDirty, costIntegrationDirty, localDirty.argocd, aiDirty])
|
|
204
285
|
|
|
205
286
|
// Load config on open + snapshot AI prefs + pick a default section that's
|
|
206
287
|
// actually accessible to the current identity.
|
|
207
288
|
useEffect(() => {
|
|
208
289
|
if (!open) return
|
|
290
|
+
if (targetChangedWithDraft || draftFrozen) return
|
|
291
|
+
const controller = new AbortController()
|
|
209
292
|
setConfigData(null)
|
|
293
|
+
setPrometheusCredentialDirty(false)
|
|
294
|
+
setLocalDirty({ metrics: false, argocd: false, cost: false })
|
|
210
295
|
setSaveMessage(null)
|
|
211
296
|
setLoadError(null)
|
|
212
297
|
setConfirmingClose(false)
|
|
@@ -214,28 +299,27 @@ export function SettingsDialog({
|
|
|
214
299
|
setCostCredentialDirty(false)
|
|
215
300
|
setCostDraftReset((current) => current + 1)
|
|
216
301
|
setAiSaved(false)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
model: diag.model,
|
|
221
|
-
effort: diag.effort,
|
|
222
|
-
})
|
|
223
|
-
fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
302
|
+
setAiTouched(false)
|
|
303
|
+
if (isSwitching) return () => controller.abort()
|
|
304
|
+
fetch(apiUrl('/config'), { signal: controller.signal, credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
224
305
|
.then((res) => {
|
|
225
306
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
226
307
|
return res.json()
|
|
227
308
|
})
|
|
228
309
|
.then((data: ConfigResponse) => {
|
|
310
|
+
if (controller.signal.aborted || getApiBase() !== settingsApiBase) return
|
|
229
311
|
setConfigData(data)
|
|
230
312
|
setEditedConfig({ ...data.file, prometheusUrl: data.effective.prometheusUrl })
|
|
231
313
|
})
|
|
232
314
|
.catch((err) => {
|
|
315
|
+
if (controller.signal.aborted) return
|
|
233
316
|
console.warn('[settings] Failed to load config:', err)
|
|
234
317
|
setLoadError('Failed to load configuration.')
|
|
235
318
|
})
|
|
319
|
+
return () => controller.abort()
|
|
236
320
|
// Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
|
|
237
321
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
238
|
-
}, [open])
|
|
322
|
+
}, [open, isSwitching, settingsCluster?.context, settingsApiBase, draftFrozen, reloadVersion])
|
|
239
323
|
|
|
240
324
|
useEffect(() => {
|
|
241
325
|
if (open) setSection(initialSection)
|
|
@@ -256,7 +340,7 @@ export function SettingsDialog({
|
|
|
256
340
|
}, [])
|
|
257
341
|
|
|
258
342
|
const saveConfig = useCallback(async (): Promise<boolean> => {
|
|
259
|
-
if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
|
|
343
|
+
if (!configData || integrationBusy || draftFrozen || targetChangedWithDraft || configSavingRef.current || costCurrencySavingRef.current) return false
|
|
260
344
|
configSavingRef.current = true
|
|
261
345
|
setSaving(true)
|
|
262
346
|
setSaveMessage(null)
|
|
@@ -300,7 +384,7 @@ export function SettingsDialog({
|
|
|
300
384
|
configSavingRef.current = false
|
|
301
385
|
setSaving(false)
|
|
302
386
|
}
|
|
303
|
-
}, [editedConfig, configData])
|
|
387
|
+
}, [editedConfig, configData, integrationBusy, draftFrozen, targetChangedWithDraft])
|
|
304
388
|
|
|
305
389
|
const saveCostCurrency = useCallback(async (value: string): Promise<void> => {
|
|
306
390
|
if (!configData) throw new Error('Radar configuration is not available')
|
|
@@ -350,20 +434,31 @@ export function SettingsDialog({
|
|
|
350
434
|
diag.setProfile(aiDraft.profile)
|
|
351
435
|
diag.setModel(aiDraft.model)
|
|
352
436
|
diag.setEffort(aiDraft.effort)
|
|
437
|
+
setAiTouched(false)
|
|
353
438
|
setAiSaved(true)
|
|
354
439
|
}, [diag, aiDraft])
|
|
355
440
|
|
|
441
|
+
const discardAi = useCallback(() => {
|
|
442
|
+
setAiTouched(false)
|
|
443
|
+
setAiSaved(false)
|
|
444
|
+
}, [])
|
|
445
|
+
|
|
356
446
|
const discardChanges = useCallback(() => {
|
|
357
447
|
// Revert unsaved startup edits back to the last-saved values (non-
|
|
358
448
|
// destructive). configData.file holds the committed config, including the
|
|
359
449
|
// live integration fields, so restoring it drops drafts without touching
|
|
360
|
-
// what's saved.
|
|
450
|
+
// what's saved. AI preferences don't depend on the config load.
|
|
451
|
+
if (settingsBusy || draftFrozen || targetChangedWithDraft) return
|
|
452
|
+
discardAi()
|
|
361
453
|
if (!configData) return
|
|
362
454
|
setEditedConfig({ ...configData.file, prometheusUrl: configData.effective.prometheusUrl })
|
|
363
455
|
setCostCredentialDirty(false)
|
|
456
|
+
setPrometheusCredentialDirty(false)
|
|
457
|
+
setDiscardGeneration(value => value + 1)
|
|
458
|
+
setLocalDirty({ metrics: false, argocd: false, cost: false })
|
|
364
459
|
setCostDraftReset((current) => current + 1)
|
|
365
460
|
setSaveMessage(null)
|
|
366
|
-
}, [configData])
|
|
461
|
+
}, [configData, settingsBusy, draftFrozen, targetChangedWithDraft, discardAi])
|
|
367
462
|
|
|
368
463
|
const finishClose = useCallback(() => {
|
|
369
464
|
const action = pendingCloseActionRef.current
|
|
@@ -377,23 +472,33 @@ export function SettingsDialog({
|
|
|
377
472
|
if (ok) finishClose()
|
|
378
473
|
}, [saveConfig, finishClose])
|
|
379
474
|
|
|
380
|
-
const
|
|
475
|
+
const reviewPendingDraft = () => {
|
|
476
|
+
if (!reviewSection) return
|
|
381
477
|
setConfirmingClose(false)
|
|
382
|
-
setSection(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
478
|
+
setSection(reviewSection)
|
|
479
|
+
focusIntegration.current = true
|
|
480
|
+
}
|
|
481
|
+
const focusIntegration = useRef(false)
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
if (!focusIntegration.current) return
|
|
484
|
+
focusIntegration.current = false
|
|
485
|
+
const panel = dialogRef.current?.querySelector<HTMLElement>('[role="tabpanel"]:not([inert])')
|
|
486
|
+
panel?.querySelector<HTMLElement>('fieldset[tabindex], input, select, button')?.focus()
|
|
487
|
+
}, [section])
|
|
488
|
+
|
|
489
|
+
// Close guard: any pending draft prompts an inline confirm rather than
|
|
490
|
+
// silently discarding. AI drafts count for every user; the rest need owner access.
|
|
491
|
+
// Saving or discarding the last draft from its own tab answers the close
|
|
492
|
+
// prompt; a stale "Unsaved changes" would otherwise linger in the footer.
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
if (confirmingClose && !configDirty && !formDirty) setConfirmingClose(false)
|
|
495
|
+
}, [confirmingClose, configDirty, formDirty])
|
|
389
496
|
|
|
390
|
-
// Close guard: a pending startup edit prompts an inline confirm rather than
|
|
391
|
-
// silently discarding. An unsaved AI draft is re-derivable, so it's fine to
|
|
392
|
-
// drop it on close.
|
|
393
497
|
const requestCloseRef = useRef<(afterClose?: () => void) => void>(() => {})
|
|
394
498
|
requestCloseRef.current = (afterClose) => {
|
|
499
|
+
if (settingsBusy) return
|
|
395
500
|
pendingCloseActionRef.current = afterClose ?? null
|
|
396
|
-
if (canEditConfig && (configDirty ||
|
|
501
|
+
if ((canEditConfig && (configDirty || integrationDirty)) || aiDirty) setConfirmingClose(true)
|
|
397
502
|
else finishClose()
|
|
398
503
|
}
|
|
399
504
|
|
|
@@ -445,20 +550,20 @@ export function SettingsDialog({
|
|
|
445
550
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
|
|
446
551
|
{ id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
|
|
447
552
|
{ id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
|
|
448
|
-
{ id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty:
|
|
553
|
+
{ id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: prometheusDirty },
|
|
449
554
|
{ id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
|
|
450
|
-
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty:
|
|
555
|
+
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: localDirty.argocd },
|
|
451
556
|
{ id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
|
|
557
|
+
{ id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
|
|
452
558
|
{ id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
|
|
453
559
|
]
|
|
454
560
|
|
|
455
|
-
const offerCostReview = shouldOfferCostReview(costIntegrationDirty, section)
|
|
456
561
|
const showFooter = shouldShowSettingsFooter({
|
|
457
562
|
canEditConfig,
|
|
458
563
|
confirmingClose,
|
|
459
564
|
configDirty,
|
|
460
|
-
|
|
461
|
-
section,
|
|
565
|
+
integrationDirty: integrationDirty && (!configData?.integrationProfiles || (!!reviewSection && reviewSection !== 'ai')),
|
|
566
|
+
aiDirtyElsewhere: aiDirty && section !== 'ai',
|
|
462
567
|
hasSaveMessage: Boolean(saveMessage),
|
|
463
568
|
})
|
|
464
569
|
|
|
@@ -494,27 +599,30 @@ export function SettingsDialog({
|
|
|
494
599
|
// Fixed height so the dialog doesn't jump when switching tabs — short
|
|
495
600
|
// tabs leave breathing room, tall ones scroll inside the content pane.
|
|
496
601
|
// max-h keeps it on-screen on short viewports.
|
|
497
|
-
'sm:rounded-xl sm:max-w-
|
|
602
|
+
'sm:rounded-xl sm:max-w-5xl sm:mx-6 sm:h-[800px] sm:max-h-[calc(100dvh-64px)]',
|
|
498
603
|
TRANSITION_PANEL,
|
|
499
604
|
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
|
500
605
|
)}
|
|
501
606
|
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
502
607
|
>
|
|
503
608
|
{/* Header — spans both panes */}
|
|
504
|
-
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
505
|
-
<div className="flex items-center gap-2">
|
|
506
|
-
<Settings className="w-5 h-5 text-theme-text-secondary" />
|
|
507
|
-
<div className="flex items-baseline gap-2">
|
|
609
|
+
<div className="relative flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
610
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
611
|
+
<Settings className="w-5 h-5 shrink-0 text-theme-text-secondary" />
|
|
612
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
508
613
|
<h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
|
|
509
|
-
<span className="text-[11px] text-theme-text-tertiary">
|
|
510
|
-
Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
|
|
614
|
+
<span className="break-all text-[11px] text-theme-text-tertiary">
|
|
615
|
+
Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion.replace(/^v/, '')}` : ''}
|
|
511
616
|
<span className="text-theme-text-disabled"> · by Skyhook</span>
|
|
512
617
|
</span>
|
|
618
|
+
<UpdateNotification placement="settings" />
|
|
513
619
|
</div>
|
|
514
620
|
</div>
|
|
515
621
|
<button
|
|
516
622
|
onClick={() => requestCloseRef.current()}
|
|
517
|
-
|
|
623
|
+
aria-label="Close settings"
|
|
624
|
+
disabled={settingsBusy}
|
|
625
|
+
className="shrink-0 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
|
|
518
626
|
>
|
|
519
627
|
<X className="w-5 h-5" />
|
|
520
628
|
</button>
|
|
@@ -562,21 +670,35 @@ export function SettingsDialog({
|
|
|
562
670
|
</div>
|
|
563
671
|
)}
|
|
564
672
|
|
|
565
|
-
{
|
|
673
|
+
{draftFrozen && <div role="status" className="mb-4 space-y-2 rounded-md border border-theme-border p-3 text-sm text-theme-text-secondary">
|
|
674
|
+
<p>The cluster changed while you had unsaved edits. Your previous draft is still shown below, but cannot be applied to this cluster.</p>
|
|
675
|
+
<button type="button" disabled={isSwitching} className="text-xs text-accent-text hover:underline disabled:opacity-50" onClick={() => {
|
|
676
|
+
metricsDraft.current = { open, scope: settingsScope, dirty: false }
|
|
677
|
+
setDraftFrozen(false)
|
|
678
|
+
setReloadVersion((version) => version + 1)
|
|
679
|
+
}}>Discard draft and load current cluster</button>
|
|
680
|
+
</div>}
|
|
681
|
+
{!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
|
|
566
682
|
<p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
|
|
567
|
-
) :
|
|
568
|
-
{operatorManaged && section !== 'perms' && section !== 'ai' && <div className="mb-4"><OperatorManagedNotice /></div>}
|
|
683
|
+
) : <div inert={draftFrozen || settingsBusy || undefined} className={draftFrozen ? 'opacity-60' : undefined}>
|
|
684
|
+
{operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
|
|
569
685
|
{/* Overview — status at a glance; the landing section */}
|
|
570
686
|
<div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
|
|
571
687
|
<div className="mb-1">
|
|
572
|
-
<h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
|
|
688
|
+
<h3 ref={overviewHeading} tabIndex={-1} className="text-base font-semibold text-theme-text-primary outline-none">Overview</h3>
|
|
573
689
|
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
574
690
|
What this Radar is connected to right now — select a row for details.
|
|
575
691
|
</p>
|
|
576
692
|
</div>
|
|
693
|
+
{configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
|
|
694
|
+
<PreviousIntegrationSettingsNotice profiles={configData.integrationProfiles} onNavigate={setSection} onDismissed={previousNoticeDismissed} />
|
|
695
|
+
)}
|
|
577
696
|
<div className="mt-3">
|
|
578
697
|
<OverviewPanel active={section === 'overview'} onNavigate={setSection} />
|
|
579
698
|
</div>
|
|
699
|
+
{configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
|
|
700
|
+
<LocalConfigurationDetails onNavigate={setSection} />
|
|
701
|
+
)}
|
|
580
702
|
</div>
|
|
581
703
|
|
|
582
704
|
{/* My permissions — usable by everyone, rendered inline (no launcher) */}
|
|
@@ -599,7 +721,7 @@ export function SettingsDialog({
|
|
|
599
721
|
active={section}
|
|
600
722
|
title="Connection"
|
|
601
723
|
managed={operatorManaged ? <OperatorSettingsSummary section="connection" config={configData} /> : undefined}
|
|
602
|
-
caption="
|
|
724
|
+
caption="Kubeconfig and server changes take effect on next launch."
|
|
603
725
|
locked={!canEditConfig}
|
|
604
726
|
>
|
|
605
727
|
<div className="space-y-4">
|
|
@@ -621,6 +743,15 @@ export function SettingsDialog({
|
|
|
621
743
|
onChange={updateConfigField}
|
|
622
744
|
/>
|
|
623
745
|
</div>
|
|
746
|
+
{configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
|
|
747
|
+
<SavedClusterConnections
|
|
748
|
+
key={settingsScope}
|
|
749
|
+
active={open && section === 'connection' && !draftFrozen && !isSwitching}
|
|
750
|
+
profiles={configData.integrationProfiles}
|
|
751
|
+
onChange={connectionsChanged}
|
|
752
|
+
onBusyChange={setStorageBusy}
|
|
753
|
+
/>
|
|
754
|
+
)}
|
|
624
755
|
</SectionPane>
|
|
625
756
|
|
|
626
757
|
{/* Metrics backend — live */}
|
|
@@ -629,11 +760,15 @@ export function SettingsDialog({
|
|
|
629
760
|
active={section}
|
|
630
761
|
title="Metrics"
|
|
631
762
|
managed={operatorManaged ? <OperatorSettingsSummary section="prometheus" config={configData} /> : undefined}
|
|
632
|
-
caption=
|
|
763
|
+
caption={configData?.integrationProfiles ? localIntegrationCaption : 'Connect and manage your metrics backend.'}
|
|
633
764
|
live
|
|
634
765
|
locked={!canEditConfig}
|
|
635
766
|
>
|
|
636
|
-
<
|
|
767
|
+
{configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.metrics.target)}`} kind="metrics" profiles={configData.integrationProfiles} onChange={connectionsChanged} onDirtyChange={metricsDirtyChange} onBusyChange={metricsBusyChange}
|
|
768
|
+
status={showLocalStatus && section === 'prometheus' && !['error', 'target_changed'].includes(configData.integrationProfiles.metrics.state) ? <LocalIntegrationStatus kind="metrics" profile={configData.integrationProfiles.metrics} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : <PrometheusConfigField
|
|
769
|
+
key={`${settingsApiBase}:${discardGeneration}`}
|
|
770
|
+
onBusyChange={metricsBusyChange}
|
|
771
|
+
onDirtyChange={setPrometheusCredentialDirty}
|
|
637
772
|
local={deploymentMode === 'local'}
|
|
638
773
|
value={editedConfig.prometheusUrl ?? ''}
|
|
639
774
|
configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
|
|
@@ -641,7 +776,8 @@ export function SettingsDialog({
|
|
|
641
776
|
headersManaged={configData?.prometheusHeadersManaged === true}
|
|
642
777
|
urlFromFlag={configData?.prometheusUrlFromFlag === true}
|
|
643
778
|
onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
|
|
644
|
-
onApplied={(url) =>
|
|
779
|
+
onApplied={(url) => {
|
|
780
|
+
setEditedConfig((prev) => ({ ...prev, prometheusUrl: url || undefined }))
|
|
645
781
|
setConfigData((prev) =>
|
|
646
782
|
prev ? {
|
|
647
783
|
...prev,
|
|
@@ -649,8 +785,8 @@ export function SettingsDialog({
|
|
|
649
785
|
effective: { ...prev.effective, prometheusUrl: url || undefined },
|
|
650
786
|
} : prev
|
|
651
787
|
)
|
|
652
|
-
}
|
|
653
|
-
/>
|
|
788
|
+
}}
|
|
789
|
+
/>}
|
|
654
790
|
</SectionPane>
|
|
655
791
|
|
|
656
792
|
<SectionPane
|
|
@@ -658,11 +794,13 @@ export function SettingsDialog({
|
|
|
658
794
|
active={section}
|
|
659
795
|
title="Cost"
|
|
660
796
|
managed={operatorManaged ? <OperatorSettingsSummary section="cost" config={configData} /> : undefined}
|
|
661
|
-
caption=
|
|
797
|
+
caption={configData?.integrationProfiles ? `${localIntegrationCaption} Display currency applies to all clusters.` : 'Choose where Radar gets cost data and how amounts are labeled.'}
|
|
662
798
|
live
|
|
663
799
|
locked={!canEditConfig}
|
|
664
800
|
>
|
|
665
801
|
<CostSection
|
|
802
|
+
integration={configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.cost.target)}`} kind="cost" profiles={configData.integrationProfiles} onChange={connectionsChanged} onDirtyChange={costDirtyChange} onBusyChange={costBusyChange}
|
|
803
|
+
status={showLocalStatus && section === 'cost' && !['error', 'target_changed'].includes(configData.integrationProfiles.cost.state) ? <LocalIntegrationStatus kind="cost" profile={configData.integrationProfiles.cost} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : undefined}
|
|
666
804
|
currency={editedConfig.opencostCurrency ?? ''}
|
|
667
805
|
source={editedConfig.costSource ?? 'auto'}
|
|
668
806
|
url={editedConfig.kubecostUrl ?? ''}
|
|
@@ -675,7 +813,7 @@ export function SettingsDialog({
|
|
|
675
813
|
managed={configData?.openCostCurrencyManaged ?? false}
|
|
676
814
|
effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
|
|
677
815
|
deploymentMode={deploymentMode}
|
|
678
|
-
settingsSaving={
|
|
816
|
+
settingsSaving={settingsBusy}
|
|
679
817
|
onNavigateToResource={navigateFromSettings}
|
|
680
818
|
onApplyCurrency={saveCostCurrency}
|
|
681
819
|
onChangeSource={(value) => updateConfigField('costSource', value)}
|
|
@@ -703,11 +841,12 @@ export function SettingsDialog({
|
|
|
703
841
|
active={section}
|
|
704
842
|
title="Argo CD"
|
|
705
843
|
managed={operatorManaged ? <OperatorSettingsSummary section="argocd" config={configData} /> : undefined}
|
|
706
|
-
caption=
|
|
844
|
+
caption={configData?.integrationProfiles ? localIntegrationCaption : 'Applies immediately — no restart.'}
|
|
707
845
|
live
|
|
708
846
|
locked={!canEditConfig}
|
|
709
847
|
>
|
|
710
|
-
<
|
|
848
|
+
{configData?.integrationProfiles ? <LocalConnectionSettings key={`${discardGeneration}:${JSON.stringify(configData.integrationProfiles.argocd.target)}`} kind="argocd" profiles={configData.integrationProfiles} cliSession={configData.argoCdCliSession} onChange={connectionsChanged} onDirtyChange={argoDirtyChange} onBusyChange={argoBusyChange}
|
|
849
|
+
status={showLocalStatus && section === 'argocd' && !['error', 'target_changed'].includes(configData.integrationProfiles.argocd.state) ? <LocalIntegrationStatus kind="argocd" profile={configData.integrationProfiles.argocd} argo={argoStatusQuery} busy={integrationBusy} changedAt={connectionsChangedAt} /> : undefined} /> : <ArgoCDConfigField
|
|
711
850
|
url={editedConfig.argoCdUrl ?? ''}
|
|
712
851
|
insecureTls={editedConfig.argoCdInsecureTls ?? false}
|
|
713
852
|
tokenSet={configData?.argoCdTokenSet ?? false}
|
|
@@ -742,7 +881,7 @@ export function SettingsDialog({
|
|
|
742
881
|
predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('gitops-'),
|
|
743
882
|
})
|
|
744
883
|
}}
|
|
745
|
-
/>
|
|
884
|
+
/>}
|
|
746
885
|
</SectionPane>
|
|
747
886
|
|
|
748
887
|
{/* AI investigations — self-saving, usable by everyone. Same heading block
|
|
@@ -769,26 +908,19 @@ export function SettingsDialog({
|
|
|
769
908
|
draft={aiDraft}
|
|
770
909
|
onChange={(patch) => {
|
|
771
910
|
setAiDraft((d) => ({ ...d, ...patch }))
|
|
911
|
+
setAiTouched(true)
|
|
772
912
|
setAiSaved(false)
|
|
773
913
|
}}
|
|
774
914
|
onHistoryCleared={diag.refreshRuns}
|
|
775
915
|
/>
|
|
776
|
-
{
|
|
777
|
-
<
|
|
778
|
-
{
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
<button
|
|
785
|
-
onClick={saveAi}
|
|
786
|
-
disabled={!aiDirty}
|
|
787
|
-
className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
|
|
788
|
-
>
|
|
789
|
-
Save
|
|
790
|
-
</button>
|
|
791
|
-
</div>
|
|
916
|
+
{aiEditable && (
|
|
917
|
+
<FormSaveActions
|
|
918
|
+
dirty={aiDirty}
|
|
919
|
+
busy={false}
|
|
920
|
+
onSave={saveAi}
|
|
921
|
+
onDiscard={discardAi}
|
|
922
|
+
feedback={aiSaved ? { tone: 'success', message: 'Saved' } : undefined}
|
|
923
|
+
/>
|
|
792
924
|
)}
|
|
793
925
|
</div>
|
|
794
926
|
) : (
|
|
@@ -796,6 +928,12 @@ export function SettingsDialog({
|
|
|
796
928
|
)}
|
|
797
929
|
</div>
|
|
798
930
|
|
|
931
|
+
{/* Privacy: the usage-stats choice. Readable by everyone; only a local
|
|
932
|
+
install can change it here. */}
|
|
933
|
+
<SectionPane id="privacy" active={section} title="Privacy" caption="Applies immediately." live>
|
|
934
|
+
<PrivacySection active={section === 'privacy'} />
|
|
935
|
+
</SectionPane>
|
|
936
|
+
|
|
799
937
|
{/* Advanced — MCP + Timeline merged */}
|
|
800
938
|
<SectionPane
|
|
801
939
|
id="advanced"
|
|
@@ -824,120 +962,118 @@ export function SettingsDialog({
|
|
|
824
962
|
/>
|
|
825
963
|
</div>
|
|
826
964
|
</SectionPane>
|
|
827
|
-
|
|
965
|
+
</div>}
|
|
828
966
|
</div>
|
|
829
967
|
</div>
|
|
830
968
|
|
|
831
|
-
{/*
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
<
|
|
835
|
-
className=
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
<span className="text-xs text-theme-text-secondary">
|
|
844
|
-
{costIntegrationDirty && configDirty
|
|
845
|
-
? 'Cost source changes are not applied, and other changes are unsaved.'
|
|
846
|
-
: costIntegrationDirty
|
|
847
|
-
? 'Cost source changes have not been applied.'
|
|
969
|
+
{/* Startup settings only apply on the next launch, so they save here
|
|
970
|
+
instead of in their tabs; drafts left in other tabs surface here
|
|
971
|
+
too, so closing can't drop them unnoticed. */}
|
|
972
|
+
<Collapse open={showFooter} className="shrink-0">
|
|
973
|
+
<div className="border-t border-theme-border">
|
|
974
|
+
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
|
975
|
+
{confirmingClose ? (
|
|
976
|
+
<>
|
|
977
|
+
<div className="space-y-1 text-xs text-theme-text-secondary">
|
|
978
|
+
<p>
|
|
979
|
+
{formDirty
|
|
980
|
+
? `${pending.map(item => pendingSectionLabels[item]).join(', ')} changes are not saved.${configDirty ? ' Startup settings are unsaved.' : ''}`
|
|
848
981
|
: 'Unsaved changes.'}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
pendingCloseActionRef.current = null
|
|
854
|
-
setConfirmingClose(false)
|
|
855
|
-
}}
|
|
856
|
-
disabled={saving}
|
|
857
|
-
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"
|
|
858
|
-
>
|
|
859
|
-
Keep editing
|
|
860
|
-
</button>
|
|
861
|
-
<button
|
|
862
|
-
onClick={finishClose}
|
|
863
|
-
disabled={saving}
|
|
864
|
-
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"
|
|
865
|
-
>
|
|
866
|
-
Discard
|
|
867
|
-
</button>
|
|
868
|
-
{offerCostReview && (
|
|
869
|
-
<button
|
|
870
|
-
onClick={reviewCostDraft}
|
|
871
|
-
className={clsx(
|
|
872
|
-
'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
|
|
873
|
-
configDirty
|
|
874
|
-
? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
875
|
-
: 'btn-brand',
|
|
876
|
-
)}
|
|
877
|
-
>
|
|
878
|
-
Review Cost
|
|
879
|
-
</button>
|
|
880
|
-
)}
|
|
881
|
-
{configDirty && (
|
|
882
|
-
<button
|
|
883
|
-
onClick={costIntegrationDirty ? saveConfig : handleSaveAndClose}
|
|
884
|
-
disabled={saving || costCurrencySaving}
|
|
885
|
-
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
|
|
886
|
-
>
|
|
887
|
-
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
888
|
-
{costIntegrationDirty ? 'Save other changes' : 'Save'}
|
|
889
|
-
</button>
|
|
890
|
-
)}
|
|
891
|
-
</div>
|
|
892
|
-
</>
|
|
893
|
-
) : (
|
|
894
|
-
<>
|
|
895
|
-
<div className="flex items-center gap-2">
|
|
896
|
-
<Tooltip content="Discard unsaved changes and revert to the last saved values">
|
|
897
|
-
<button
|
|
898
|
-
onClick={discardChanges}
|
|
899
|
-
disabled={saving || (!configDirty && !costIntegrationDirty)}
|
|
900
|
-
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"
|
|
901
|
-
>
|
|
902
|
-
<RotateCcw className="w-3.5 h-3.5" />
|
|
903
|
-
Discard changes
|
|
904
|
-
</button>
|
|
905
|
-
</Tooltip>
|
|
906
|
-
{saveMessage && (
|
|
907
|
-
<span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
|
|
908
|
-
{saveMessage}
|
|
909
|
-
</span>
|
|
910
|
-
)}
|
|
911
|
-
</div>
|
|
912
|
-
<div className="flex items-center gap-2">
|
|
913
|
-
{offerCostReview && (
|
|
982
|
+
</p>
|
|
983
|
+
{saveMessage && <p role="status" className={saveMessage.startsWith('Error') ? 'text-semantic-error' : undefined}>{saveMessage}</p>}
|
|
984
|
+
</div>
|
|
985
|
+
<div className="flex items-center gap-2">
|
|
914
986
|
<button
|
|
915
|
-
onClick={
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
)}
|
|
987
|
+
onClick={() => {
|
|
988
|
+
pendingCloseActionRef.current = null
|
|
989
|
+
setConfirmingClose(false)
|
|
990
|
+
}}
|
|
991
|
+
disabled={settingsBusy}
|
|
992
|
+
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"
|
|
922
993
|
>
|
|
923
|
-
|
|
994
|
+
Keep editing
|
|
924
995
|
</button>
|
|
925
|
-
)}
|
|
926
|
-
{configDirty && (
|
|
927
996
|
<button
|
|
928
|
-
onClick={
|
|
929
|
-
disabled={
|
|
930
|
-
className="
|
|
997
|
+
onClick={finishClose}
|
|
998
|
+
disabled={settingsBusy}
|
|
999
|
+
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"
|
|
931
1000
|
>
|
|
932
|
-
|
|
933
|
-
Save
|
|
1001
|
+
Discard
|
|
934
1002
|
</button>
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1003
|
+
{reviewSection && (
|
|
1004
|
+
<button
|
|
1005
|
+
onClick={reviewPendingDraft}
|
|
1006
|
+
className={clsx(
|
|
1007
|
+
'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
|
|
1008
|
+
configDirty
|
|
1009
|
+
? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
1010
|
+
: 'btn-brand',
|
|
1011
|
+
)}
|
|
1012
|
+
>
|
|
1013
|
+
Review {pendingSectionLabels[reviewSection]}
|
|
1014
|
+
</button>
|
|
1015
|
+
)}
|
|
1016
|
+
{configDirty && canEditConfig && (
|
|
1017
|
+
<button
|
|
1018
|
+
onClick={formDirty ? saveConfig : handleSaveAndClose}
|
|
1019
|
+
disabled={settingsBusy || draftFrozen || targetChangedWithDraft}
|
|
1020
|
+
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
|
|
1021
|
+
>
|
|
1022
|
+
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
1023
|
+
Save startup settings
|
|
1024
|
+
</button>
|
|
1025
|
+
)}
|
|
1026
|
+
</div>
|
|
1027
|
+
</>
|
|
1028
|
+
) : (
|
|
1029
|
+
<>
|
|
1030
|
+
<div className="flex items-center gap-2">
|
|
1031
|
+
{(!configData?.integrationProfiles || configDirty || !!reviewSection) && <Tooltip content="Discard unsaved changes and revert to the last saved values">
|
|
1032
|
+
<button
|
|
1033
|
+
onClick={discardChanges}
|
|
1034
|
+
disabled={settingsBusy || draftFrozen || targetChangedWithDraft || (!configDirty && !formDirty)}
|
|
1035
|
+
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"
|
|
1036
|
+
>
|
|
1037
|
+
<RotateCcw className="w-3.5 h-3.5" />
|
|
1038
|
+
{formDirty && (configDirty || pending.length > 1) ? 'Discard all changes' : 'Discard changes'}
|
|
1039
|
+
</button>
|
|
1040
|
+
</Tooltip>}
|
|
1041
|
+
{saveMessage && (
|
|
1042
|
+
<span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
|
|
1043
|
+
{saveMessage}
|
|
1044
|
+
</span>
|
|
1045
|
+
)}
|
|
1046
|
+
</div>
|
|
1047
|
+
<div className="flex items-center gap-2">
|
|
1048
|
+
{reviewSection && (
|
|
1049
|
+
<button
|
|
1050
|
+
onClick={reviewPendingDraft}
|
|
1051
|
+
className={clsx(
|
|
1052
|
+
'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
|
|
1053
|
+
configDirty
|
|
1054
|
+
? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
1055
|
+
: 'btn-brand',
|
|
1056
|
+
)}
|
|
1057
|
+
>
|
|
1058
|
+
Review {pendingSectionLabels[reviewSection]}
|
|
1059
|
+
</button>
|
|
1060
|
+
)}
|
|
1061
|
+
{configDirty && canEditConfig && (
|
|
1062
|
+
<button
|
|
1063
|
+
onClick={saveConfig}
|
|
1064
|
+
disabled={settingsBusy || draftFrozen || targetChangedWithDraft}
|
|
1065
|
+
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
|
|
1066
|
+
>
|
|
1067
|
+
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
1068
|
+
Save startup settings
|
|
1069
|
+
</button>
|
|
1070
|
+
)}
|
|
1071
|
+
</div>
|
|
1072
|
+
</>
|
|
1073
|
+
)}
|
|
1074
|
+
</div>
|
|
939
1075
|
</div>
|
|
940
|
-
</
|
|
1076
|
+
</Collapse>
|
|
941
1077
|
</div>
|
|
942
1078
|
</div>,
|
|
943
1079
|
document.body
|
|
@@ -954,16 +1090,6 @@ interface NavItemDef {
|
|
|
954
1090
|
dirty: boolean
|
|
955
1091
|
}
|
|
956
1092
|
|
|
957
|
-
// Light subheading separating the two field groups inside a merged pane
|
|
958
|
-
// (Cluster/Server, MCP/Timeline).
|
|
959
|
-
function SubHeading({ children }: { children: ReactNode }) {
|
|
960
|
-
return (
|
|
961
|
-
<h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
962
|
-
{children}
|
|
963
|
-
</h4>
|
|
964
|
-
)
|
|
965
|
-
}
|
|
966
|
-
|
|
967
1093
|
function NavItem({
|
|
968
1094
|
item,
|
|
969
1095
|
active,
|
|
@@ -1096,9 +1222,9 @@ function OperatorSettingsSummary({ section, config }: { section: SettingsSection
|
|
|
1096
1222
|
case 'cost':
|
|
1097
1223
|
rows = [
|
|
1098
1224
|
['Status', costError ? 'Status unavailable' : !cost ? 'Checking…' : cost.available ? 'Available' : 'Unavailable'],
|
|
1099
|
-
['Source preference', c.costSource
|
|
1225
|
+
['Source preference', costSourcePreferenceLabel(c.costSource)],
|
|
1100
1226
|
['Kubecost endpoint', c.kubecostUrl || 'Auto-discovery'],
|
|
1101
|
-
['
|
|
1227
|
+
['Kubecost cluster ID', c.kubecostClusterId || 'Detected automatically'],
|
|
1102
1228
|
['API key', config.kubecostApiKeySet ? 'Configured' : 'None configured'],
|
|
1103
1229
|
['Currency override', c.opencostCurrency || 'Automatic'],
|
|
1104
1230
|
]
|
|
@@ -1168,9 +1294,6 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1168
1294
|
const { data: prom } = usePrometheusStatus()
|
|
1169
1295
|
const { data: cost } = useOpenCostSummary()
|
|
1170
1296
|
const { data: argo } = useArgoStatus(active)
|
|
1171
|
-
const { data: capabilitiesData } = useCapabilities()
|
|
1172
|
-
const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
|
|
1173
|
-
const { data: version } = useVersionCheck()
|
|
1174
1297
|
const capabilities = useCapabilitiesContext()
|
|
1175
1298
|
const diag = useDiagnose()
|
|
1176
1299
|
const [copied, setCopied] = useState(false)
|
|
@@ -1201,8 +1324,8 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1201
1324
|
{
|
|
1202
1325
|
id: 'prometheus', icon: Activity, label: 'Metrics',
|
|
1203
1326
|
tone: prom?.connected ? 'ok' : prom?.discovering ? 'unknown' : prom?.error ? 'warn' : 'off',
|
|
1204
|
-
value: prom?.connected ? 'Connected' : prom?.discovering ? 'Discovering…' :
|
|
1205
|
-
detail: prom?.connected ? prom.address : prom?.discovering ? undefined : prom?.error,
|
|
1327
|
+
value: prom?.connected ? 'Connected' : prom?.discovering ? 'Discovering…' : 'Not connected',
|
|
1328
|
+
detail: prom?.connected ? prom.address : prom?.discovering ? undefined : prom?.error ? 'Open Metrics for connection details.' : undefined,
|
|
1206
1329
|
},
|
|
1207
1330
|
{
|
|
1208
1331
|
id: costConfigurationAction(cost?.reason).section, icon: Coins, label: 'Cost',
|
|
@@ -1218,12 +1341,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1218
1341
|
},
|
|
1219
1342
|
{
|
|
1220
1343
|
id: 'argocd', icon: GitBranch, label: 'Argo CD',
|
|
1221
|
-
tone: argo?.connected ? 'ok' : argo?.configured ? 'warn' : 'off',
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
// doesn't imply it will recover on its own.
|
|
1225
|
-
value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : argo?.configured ? 'Not reachable' : 'Not connected',
|
|
1226
|
-
detail: argo?.connected ? argo.address : argo?.reason,
|
|
1344
|
+
tone: argo?.connected ? 'ok' : argo?.configured || argo?.reason ? 'warn' : 'off',
|
|
1345
|
+
value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : 'Not connected',
|
|
1346
|
+
detail: argo?.connected ? argo.address : argo?.reason ? 'Open Argo CD for connection details.' : undefined,
|
|
1227
1347
|
},
|
|
1228
1348
|
{
|
|
1229
1349
|
id: 'advanced', icon: Zap, label: 'MCP',
|
|
@@ -1248,22 +1368,6 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1248
1368
|
|
|
1249
1369
|
return (
|
|
1250
1370
|
<div className="space-y-4">
|
|
1251
|
-
{version?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud' && (
|
|
1252
|
-
<a
|
|
1253
|
-
href={versionUpdateURL(deploymentMode, version.releaseUrl)}
|
|
1254
|
-
target="_blank"
|
|
1255
|
-
rel="noreferrer"
|
|
1256
|
-
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"
|
|
1257
|
-
>
|
|
1258
|
-
<Download className="w-3.5 h-3.5 shrink-0 text-skyhook-500" />
|
|
1259
|
-
<span className="flex-1 text-theme-text-primary">
|
|
1260
|
-
Radar {version.latestVersion} is available
|
|
1261
|
-
<span className="text-theme-text-tertiary"> — you're on {version.currentVersion}</span>
|
|
1262
|
-
</span>
|
|
1263
|
-
<ExternalLink className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
|
|
1264
|
-
</a>
|
|
1265
|
-
)}
|
|
1266
|
-
|
|
1267
1371
|
<div className="rounded-md border border-theme-border divide-y divide-theme-border-subtle overflow-hidden">
|
|
1268
1372
|
{rows.map((row) => {
|
|
1269
1373
|
const Icon = row.icon
|
|
@@ -1274,10 +1378,10 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1274
1378
|
tabIndex={0}
|
|
1275
1379
|
onClick={() => onNavigate(row.id)}
|
|
1276
1380
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(row.id) } }}
|
|
1277
|
-
className="group flex items-center gap-3 px-3 py-
|
|
1381
|
+
className="group flex items-center gap-3 px-3 py-1.5 cursor-pointer hover:bg-theme-hover transition-colors"
|
|
1278
1382
|
>
|
|
1279
1383
|
<Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
|
|
1280
|
-
<span className="text-sm text-theme-text-primary w-
|
|
1384
|
+
<span className="text-sm text-theme-text-primary w-32 shrink-0">{row.label}</span>
|
|
1281
1385
|
<OverviewStatus tone={row.tone} />
|
|
1282
1386
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
1283
1387
|
<span className="truncate text-sm text-theme-text-secondary">{row.value}</span>
|
|
@@ -1474,6 +1578,7 @@ function TimelineSection({
|
|
|
1474
1578
|
}
|
|
1475
1579
|
|
|
1476
1580
|
function CostSection({
|
|
1581
|
+
integration,
|
|
1477
1582
|
currency,
|
|
1478
1583
|
source,
|
|
1479
1584
|
url,
|
|
@@ -1495,6 +1600,7 @@ function CostSection({
|
|
|
1495
1600
|
onCredentialDirtyChange,
|
|
1496
1601
|
onApplied,
|
|
1497
1602
|
}: {
|
|
1603
|
+
integration?: ReactNode
|
|
1498
1604
|
currency: string
|
|
1499
1605
|
source: 'auto' | 'prometheus' | 'kubecost'
|
|
1500
1606
|
url: string
|
|
@@ -1632,7 +1738,12 @@ function CostSection({
|
|
|
1632
1738
|
|
|
1633
1739
|
return (
|
|
1634
1740
|
<div className="space-y-5">
|
|
1635
|
-
{
|
|
1741
|
+
{integration ? (
|
|
1742
|
+
<div className="space-y-4">
|
|
1743
|
+
<SubHeading>This cluster</SubHeading>
|
|
1744
|
+
{integration}
|
|
1745
|
+
</div>
|
|
1746
|
+
) : <>{sourceEnvManaged && (
|
|
1636
1747
|
<div id="cost-source-managed" className={clsx(
|
|
1637
1748
|
'rounded-md border p-3',
|
|
1638
1749
|
sourceEnvError
|
|
@@ -1665,11 +1776,7 @@ function CostSection({
|
|
|
1665
1776
|
<SelectMenu
|
|
1666
1777
|
id="cost-source"
|
|
1667
1778
|
value={source}
|
|
1668
|
-
options={
|
|
1669
|
-
{ value: 'auto', label: 'Automatic (recommended)' },
|
|
1670
|
-
{ value: 'prometheus', label: 'OpenCost metrics only' },
|
|
1671
|
-
{ value: 'kubecost', label: 'Kubecost only' },
|
|
1672
|
-
]}
|
|
1779
|
+
options={COST_SOURCE_OPTIONS}
|
|
1673
1780
|
onChange={(value) => { onChangeSource(value as typeof source); setApply({ status: 'idle' }) }}
|
|
1674
1781
|
disabled={sourceEnvManaged}
|
|
1675
1782
|
className="w-full"
|
|
@@ -1698,7 +1805,7 @@ function CostSection({
|
|
|
1698
1805
|
<div id="cost-advanced-connection" className="pt-3">
|
|
1699
1806
|
<div className="space-y-3 rounded-md border border-theme-border-subtle bg-theme-base/60 p-3">
|
|
1700
1807
|
<div>
|
|
1701
|
-
<label htmlFor="cost-kubecost-url" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost URL</label>
|
|
1808
|
+
<label htmlFor="cost-kubecost-url" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost Aggregator URL</label>
|
|
1702
1809
|
<p id="cost-kubecost-url-help" className="mb-1 text-xs text-theme-text-tertiary">
|
|
1703
1810
|
Leave blank when Kubecost runs in this cluster. Enter the central Aggregator URL
|
|
1704
1811
|
for an agent-only or federated setup.
|
|
@@ -1714,7 +1821,7 @@ function CostSection({
|
|
|
1714
1821
|
/>
|
|
1715
1822
|
</div>
|
|
1716
1823
|
<div>
|
|
1717
|
-
<label htmlFor="cost-kubecost-cluster-id" className="mb-1 block text-sm font-medium text-theme-text-primary">
|
|
1824
|
+
<label htmlFor="cost-kubecost-cluster-id" className="mb-1 block text-sm font-medium text-theme-text-primary">Kubecost cluster ID</label>
|
|
1718
1825
|
<p id="cost-kubecost-cluster-id-help" className="mb-1 text-xs text-theme-text-tertiary">
|
|
1719
1826
|
Usually detected automatically. Set it only if detection fails or the Kubecost
|
|
1720
1827
|
server contains data for more than one cluster. Use the <code>CLUSTER_ID</code>{' '}
|
|
@@ -1726,7 +1833,7 @@ function CostSection({
|
|
|
1726
1833
|
value={clusterId}
|
|
1727
1834
|
onChange={(event) => { onChangeClusterId(event.target.value); setApply({ status: 'idle' }) }}
|
|
1728
1835
|
disabled={sourceEnvManaged}
|
|
1729
|
-
placeholder="
|
|
1836
|
+
placeholder="Detected automatically"
|
|
1730
1837
|
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"
|
|
1731
1838
|
/>
|
|
1732
1839
|
</div>
|
|
@@ -1758,17 +1865,17 @@ function CostSection({
|
|
|
1758
1865
|
autoComplete="off"
|
|
1759
1866
|
spellCheck={false}
|
|
1760
1867
|
disabled={sourceEnvManaged}
|
|
1761
|
-
placeholder={apiKeySet && !apiKeyCleared ? '
|
|
1868
|
+
placeholder={apiKeySet && !apiKeyCleared ? 'Saved value' : 'Optional API key'}
|
|
1762
1869
|
className="min-w-0 flex-1 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"
|
|
1763
1870
|
/>
|
|
1764
1871
|
{apiKeySet && !apiKeyCleared && !sourceEnvManaged && (
|
|
1765
1872
|
<button
|
|
1766
1873
|
type="button"
|
|
1767
|
-
aria-label="
|
|
1874
|
+
aria-label="Remove saved Kubecost API key"
|
|
1768
1875
|
onClick={() => { setApiKey(''); setApiKeyTouched(false); setApiKeyCleared(true); setApply({ status: 'idle' }) }}
|
|
1769
1876
|
className="px-2 py-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
1770
1877
|
>
|
|
1771
|
-
|
|
1878
|
+
Remove
|
|
1772
1879
|
</button>
|
|
1773
1880
|
)}
|
|
1774
1881
|
</div>
|
|
@@ -1834,45 +1941,52 @@ function CostSection({
|
|
|
1834
1941
|
</div>}
|
|
1835
1942
|
</div>
|
|
1836
1943
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
ariaLabel="Display currency"
|
|
1851
|
-
ariaDescribedBy="cost-currency-help"
|
|
1852
|
-
searchPlaceholder="Search currencies by name or code"
|
|
1853
|
-
disabled={settingsSaving || currencySave.status === 'saving'}
|
|
1854
|
-
className="w-full"
|
|
1855
|
-
/>
|
|
1856
|
-
{currencySave.status === 'saving' && (
|
|
1857
|
-
<p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
1858
|
-
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
|
1859
|
-
</p>
|
|
1860
|
-
)}
|
|
1861
|
-
{currencySave.status === 'saved' && (
|
|
1862
|
-
<p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
|
|
1863
|
-
<Check className="h-3 w-3" /> {managed ? 'Saved for when the CLI or Helm override is removed' : 'Saved'}
|
|
1864
|
-
</p>
|
|
1865
|
-
)}
|
|
1866
|
-
{currencySave.status === 'failed' && (
|
|
1867
|
-
<p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400/80">
|
|
1868
|
-
Could not save: {currencySave.error}
|
|
1869
|
-
</p>
|
|
1870
|
-
)}
|
|
1871
|
-
{managed && (
|
|
1872
|
-
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
|
|
1873
|
-
CLI or Helm currently sets {effectiveCurrency || 'Auto'}; that override stays active until it is removed.
|
|
1944
|
+
</>}
|
|
1945
|
+
<div className={integration ? 'space-y-4 border-t border-theme-border-subtle pt-4' : 'rounded-lg border border-theme-border bg-theme-base/40 p-4'}>
|
|
1946
|
+
{integration && <SubHeading>All clusters</SubHeading>}
|
|
1947
|
+
<div>
|
|
1948
|
+
<label htmlFor="cost-currency" className="block text-sm font-semibold text-theme-text-primary">
|
|
1949
|
+
Display currency
|
|
1950
|
+
</label>
|
|
1951
|
+
{integration
|
|
1952
|
+
? <p className="mt-1 text-xs text-theme-text-secondary">Saved automatically.</p>
|
|
1953
|
+
: deploymentMode === 'local' && <p className="mt-1 text-xs text-theme-text-secondary">Display preference · all local clusters. Saved automatically.</p>}
|
|
1954
|
+
<p id="cost-currency-help" className="mb-1 mt-0.5 text-xs text-theme-text-tertiary">
|
|
1955
|
+
Automatic uses the currency reported by the active cost source, or USD when unavailable.
|
|
1956
|
+
Overrides relabel amounts; Radar does not convert them.
|
|
1874
1957
|
</p>
|
|
1875
|
-
|
|
1958
|
+
<SelectMenu
|
|
1959
|
+
id="cost-currency"
|
|
1960
|
+
value={currencyDraft}
|
|
1961
|
+
options={currencyOptionsForValue(currencyDraft)}
|
|
1962
|
+
onChange={(value) => { void saveCurrency(value) }}
|
|
1963
|
+
ariaLabel="Display currency"
|
|
1964
|
+
ariaDescribedBy="cost-currency-help"
|
|
1965
|
+
searchPlaceholder="Search currencies by name or code"
|
|
1966
|
+
disabled={settingsSaving || currencySave.status === 'saving'}
|
|
1967
|
+
className="w-full"
|
|
1968
|
+
/>
|
|
1969
|
+
{currencySave.status === 'saving' && (
|
|
1970
|
+
<p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
1971
|
+
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
|
1972
|
+
</p>
|
|
1973
|
+
)}
|
|
1974
|
+
{currencySave.status === 'saved' && (
|
|
1975
|
+
<p role="status" aria-live="polite" className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
|
|
1976
|
+
<Check className="h-3 w-3" /> {managed ? 'Saved for when the CLI or Helm override is removed' : 'Saved'}
|
|
1977
|
+
</p>
|
|
1978
|
+
)}
|
|
1979
|
+
{currencySave.status === 'failed' && (
|
|
1980
|
+
<p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400/80">
|
|
1981
|
+
Could not save: {currencySave.error}
|
|
1982
|
+
</p>
|
|
1983
|
+
)}
|
|
1984
|
+
{managed && (
|
|
1985
|
+
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
|
|
1986
|
+
CLI or Helm currently sets {effectiveCurrency || 'Automatic'}; that override stays active until it is removed.
|
|
1987
|
+
</p>
|
|
1988
|
+
)}
|
|
1989
|
+
</div>
|
|
1876
1990
|
</div>
|
|
1877
1991
|
</div>
|
|
1878
1992
|
)
|
|
@@ -1973,12 +2087,6 @@ function MCPSection({
|
|
|
1973
2087
|
// confirm reachability inline. onApplied notifies the parent so the footer's
|
|
1974
2088
|
// last-committed snapshot stays in sync (see saveConfig). No EffectiveHint here —
|
|
1975
2089
|
// a restart-diff hint would contradict the whole point of applying live.
|
|
1976
|
-
type ApplyState =
|
|
1977
|
-
| { status: 'idle' }
|
|
1978
|
-
| { status: 'applying' }
|
|
1979
|
-
| { status: 'connected'; address: string }
|
|
1980
|
-
| { status: 'unreachable'; error: string } // persisted, but the probe failed
|
|
1981
|
-
| { status: 'failed'; error: string } // request itself failed — nothing saved
|
|
1982
2090
|
|
|
1983
2091
|
type CostApplyState =
|
|
1984
2092
|
| { status: 'idle' }
|
|
@@ -1992,253 +2100,6 @@ type CurrencySaveState =
|
|
|
1992
2100
|
| { status: 'saved' }
|
|
1993
2101
|
| { status: 'failed'; error: string }
|
|
1994
2102
|
|
|
1995
|
-
type HeaderRow = { key: string; value: string }
|
|
1996
|
-
|
|
1997
|
-
function PrometheusConfigField({
|
|
1998
|
-
local,
|
|
1999
|
-
value,
|
|
2000
|
-
onChange,
|
|
2001
|
-
configuredHeaderKeys,
|
|
2002
|
-
serverManaged,
|
|
2003
|
-
headersManaged,
|
|
2004
|
-
urlFromFlag,
|
|
2005
|
-
onApplied,
|
|
2006
|
-
}: {
|
|
2007
|
-
local: boolean
|
|
2008
|
-
value: string
|
|
2009
|
-
onChange: (value: string) => void
|
|
2010
|
-
configuredHeaderKeys: string[]
|
|
2011
|
-
serverManaged: boolean
|
|
2012
|
-
headersManaged: boolean
|
|
2013
|
-
urlFromFlag: boolean
|
|
2014
|
-
onApplied?: (url: string) => void
|
|
2015
|
-
}) {
|
|
2016
|
-
const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
|
|
2017
|
-
// null = not editing headers (preserve what's stored). A non-null array means
|
|
2018
|
-
// the user opened the editor; on Apply we send it verbatim, replacing all
|
|
2019
|
-
// stored headers (values are write-only, so the server never sends them back).
|
|
2020
|
-
const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
|
|
2021
|
-
// Show the server's configured header keys, but let a successful apply override
|
|
2022
|
-
// optimistically (config isn't refetched). Derived from the prop — not a
|
|
2023
|
-
// mount-time snapshot — so it stays correct as config loads asynchronously.
|
|
2024
|
-
const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
|
|
2025
|
-
const storedKeys = appliedKeys ?? configuredHeaderKeys
|
|
2026
|
-
|
|
2027
|
-
const clearStatus = () => {
|
|
2028
|
-
if (apply.status !== 'applying') setApply({ status: 'idle' })
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
// Footer Reset (and any external edit) clears the URL field without a keystroke;
|
|
2032
|
-
// drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
|
|
2033
|
-
useEffect(() => {
|
|
2034
|
-
setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
|
|
2035
|
-
}, [value])
|
|
2036
|
-
|
|
2037
|
-
const handleApply = async () => {
|
|
2038
|
-
setApply({ status: 'applying' })
|
|
2039
|
-
// Decide what to do with headers. undefined = leave them untouched. Only send
|
|
2040
|
-
// a replacement when the editor has real content, or {} when the user emptied
|
|
2041
|
-
// every row (explicit clear) — blank in-progress rows must NOT wipe stored
|
|
2042
|
-
// secrets just because the editor happens to be open for a URL-only change.
|
|
2043
|
-
try {
|
|
2044
|
-
const editedHeaders = prometheusHeadersFromRows(headerRows)
|
|
2045
|
-
const res = await fetch(apiUrl('/integrations/prometheus'), {
|
|
2046
|
-
method: 'PUT',
|
|
2047
|
-
credentials: getCredentialsMode(),
|
|
2048
|
-
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
2049
|
-
body: JSON.stringify({
|
|
2050
|
-
prometheusUrl: value.trim(),
|
|
2051
|
-
...(editedHeaders !== undefined ? { headers: editedHeaders } : {}),
|
|
2052
|
-
}),
|
|
2053
|
-
})
|
|
2054
|
-
const data = await res.json().catch(() => null)
|
|
2055
|
-
if (!res.ok) {
|
|
2056
|
-
setApply({ status: 'failed', error: data?.error || res.statusText })
|
|
2057
|
-
return
|
|
2058
|
-
}
|
|
2059
|
-
onApplied?.(value.trim())
|
|
2060
|
-
if (editedHeaders !== undefined) {
|
|
2061
|
-
setAppliedKeys(Object.keys(editedHeaders).sort())
|
|
2062
|
-
}
|
|
2063
|
-
if (headerRows !== null) {
|
|
2064
|
-
setHeaderRows(null)
|
|
2065
|
-
}
|
|
2066
|
-
if (data?.connected) {
|
|
2067
|
-
setApply({ status: 'connected', address: data.address || value.trim() })
|
|
2068
|
-
} else {
|
|
2069
|
-
setApply({ status: 'unreachable', error: data?.error || 'not reachable' })
|
|
2070
|
-
}
|
|
2071
|
-
} catch (err) {
|
|
2072
|
-
setApply({ status: 'failed', error: String(err) })
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
return (
|
|
2077
|
-
<div>
|
|
2078
|
-
<p className="text-xs text-theme-text-tertiary mb-3">
|
|
2079
|
-
Connect existing Prometheus-compatible data for resource usage, workload HTTP metrics and rightsizing. Available charts depend on collected metrics.
|
|
2080
|
-
</p>
|
|
2081
|
-
<label className="block text-sm font-medium text-theme-text-primary mb-1">
|
|
2082
|
-
Metrics backend URL
|
|
2083
|
-
</label>
|
|
2084
|
-
<p className="text-xs text-theme-text-tertiary mb-1">
|
|
2085
|
-
Base URL reachable from Radar, not your browser — Prometheus, VictoriaMetrics, Thanos or
|
|
2086
|
-
Mimir. Include any backend path prefix, but not /api/v1/query. Leave empty for cluster discovery; headers require a URL.
|
|
2087
|
-
</p>
|
|
2088
|
-
<div className="flex items-center gap-2">
|
|
2089
|
-
<Input
|
|
2090
|
-
value={value}
|
|
2091
|
-
disabled={apply.status === 'applying'}
|
|
2092
|
-
onChange={(e) => onChange(e.target.value)}
|
|
2093
|
-
placeholder="http://prometheus-server.monitoring:9090"
|
|
2094
|
-
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"
|
|
2095
|
-
/>
|
|
2096
|
-
<Tooltip content="Save and apply this connection, then check reachability. Applying clears any workload scope override and resumes automatic identity matching." wrapperClassName="shrink-0">
|
|
2097
|
-
<button
|
|
2098
|
-
onClick={handleApply}
|
|
2099
|
-
disabled={apply.status === 'applying'}
|
|
2100
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
|
|
2101
|
-
>
|
|
2102
|
-
{apply.status === 'applying'
|
|
2103
|
-
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
2104
|
-
: <Plug className="w-3.5 h-3.5" />}
|
|
2105
|
-
Apply now
|
|
2106
|
-
</button>
|
|
2107
|
-
</Tooltip>
|
|
2108
|
-
</div>
|
|
2109
|
-
{apply.status === 'connected' ? (
|
|
2110
|
-
<p className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
|
|
2111
|
-
<Check className="w-3 h-3 shrink-0" />
|
|
2112
|
-
Connected to {apply.address} — applied, no restart needed
|
|
2113
|
-
</p>
|
|
2114
|
-
) : apply.status === 'unreachable' ? (
|
|
2115
|
-
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
|
|
2116
|
-
Saved, but not reachable: {apply.error}
|
|
2117
|
-
</p>
|
|
2118
|
-
) : apply.status === 'failed' ? (
|
|
2119
|
-
<p className="mt-1 text-xs text-red-600 dark:text-red-400/80">
|
|
2120
|
-
Couldn't apply: {apply.error}
|
|
2121
|
-
</p>
|
|
2122
|
-
) : (
|
|
2123
|
-
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
2124
|
-
Saves and applies before checking the connection.
|
|
2125
|
-
</p>
|
|
2126
|
-
)}
|
|
2127
|
-
<p className="mt-2 text-xs text-theme-text-tertiary">
|
|
2128
|
-
{local
|
|
2129
|
-
? 'Saved URL and headers apply across all local cluster contexts.'
|
|
2130
|
-
: 'Changes affect this Radar installation. Use deployment settings for configuration that survives Pod replacement.'}
|
|
2131
|
-
{' Changing servers requires replacing or clearing the saved headers.'}
|
|
2132
|
-
</p>
|
|
2133
|
-
{serverManaged && (
|
|
2134
|
-
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
2135
|
-
Server controlled by startup configuration. To set or change the server,
|
|
2136
|
-
{local ? ' update the startup flags or environment references and restart Radar.' : ' update the deployment configuration (such as Helm values) and restart Radar.'}
|
|
2137
|
-
</p>
|
|
2138
|
-
)}
|
|
2139
|
-
{urlFromFlag && (
|
|
2140
|
-
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
2141
|
-
Path edits apply until restart; the URL supplied at launch will then be restored.
|
|
2142
|
-
</p>
|
|
2143
|
-
)}
|
|
2144
|
-
|
|
2145
|
-
{/* Auth headers — for token / multi-tenant backends (Bearer, X-Scope-OrgID). */}
|
|
2146
|
-
<div className="mt-3">
|
|
2147
|
-
{headerRows === null ? (
|
|
2148
|
-
<div className="flex items-center justify-between gap-2">
|
|
2149
|
-
<span className="text-xs text-theme-text-tertiary">
|
|
2150
|
-
{storedKeys.length > 0
|
|
2151
|
-
? <>Auth headers: <span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> <span className="text-theme-text-disabled">(values hidden)</span></>
|
|
2152
|
-
: 'No auth headers'}
|
|
2153
|
-
</span>
|
|
2154
|
-
{!headersManaged && <button
|
|
2155
|
-
onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
|
|
2156
|
-
disabled={apply.status === 'applying'}
|
|
2157
|
-
className="shrink-0 text-xs font-medium text-accent-text hover:underline"
|
|
2158
|
-
>
|
|
2159
|
-
{storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
|
|
2160
|
-
</button>}
|
|
2161
|
-
</div>
|
|
2162
|
-
) : (
|
|
2163
|
-
<div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
|
|
2164
|
-
{headerRows.map((row, i) => (
|
|
2165
|
-
<div key={i} className="flex items-center gap-2">
|
|
2166
|
-
<Input
|
|
2167
|
-
value={row.key}
|
|
2168
|
-
disabled={apply.status === 'applying'}
|
|
2169
|
-
onChange={(e) => {
|
|
2170
|
-
setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
|
|
2171
|
-
clearStatus()
|
|
2172
|
-
}}
|
|
2173
|
-
placeholder="Header (e.g. Authorization)"
|
|
2174
|
-
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"
|
|
2175
|
-
/>
|
|
2176
|
-
<input
|
|
2177
|
-
type="password"
|
|
2178
|
-
value={row.value}
|
|
2179
|
-
disabled={apply.status === 'applying'}
|
|
2180
|
-
onChange={(e) => {
|
|
2181
|
-
setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
|
|
2182
|
-
clearStatus()
|
|
2183
|
-
}}
|
|
2184
|
-
placeholder="Value (e.g. Bearer …)"
|
|
2185
|
-
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"
|
|
2186
|
-
/>
|
|
2187
|
-
<Tooltip content="Remove header" wrapperClassName="shrink-0">
|
|
2188
|
-
<button
|
|
2189
|
-
onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
|
|
2190
|
-
disabled={apply.status === 'applying'}
|
|
2191
|
-
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
2192
|
-
>
|
|
2193
|
-
<X className="w-3.5 h-3.5" />
|
|
2194
|
-
</button>
|
|
2195
|
-
</Tooltip>
|
|
2196
|
-
</div>
|
|
2197
|
-
))}
|
|
2198
|
-
<div className="flex items-center justify-between gap-2">
|
|
2199
|
-
<button
|
|
2200
|
-
onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
|
|
2201
|
-
disabled={apply.status === 'applying'}
|
|
2202
|
-
className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
|
|
2203
|
-
>
|
|
2204
|
-
<Plus className="w-3 h-3" /> Add header
|
|
2205
|
-
</button>
|
|
2206
|
-
<button
|
|
2207
|
-
onClick={() => { setHeaderRows(null); clearStatus() }}
|
|
2208
|
-
disabled={apply.status === 'applying'}
|
|
2209
|
-
className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
2210
|
-
>
|
|
2211
|
-
Cancel
|
|
2212
|
-
</button>
|
|
2213
|
-
</div>
|
|
2214
|
-
<p className="text-xs text-theme-text-tertiary">
|
|
2215
|
-
Saved when you click Apply now. Entered headers replace all stored
|
|
2216
|
-
ones — values are hidden, so re-enter any you want to keep. Leave
|
|
2217
|
-
all rows blank to keep existing headers unchanged. To clear all saved
|
|
2218
|
-
headers, remove every row and click Apply now.
|
|
2219
|
-
</p>
|
|
2220
|
-
</div>
|
|
2221
|
-
)}
|
|
2222
|
-
</div>
|
|
2223
|
-
{headersManaged ? (
|
|
2224
|
-
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
2225
|
-
Headers are controlled by startup configuration. Change them at their source and restart Radar.
|
|
2226
|
-
</p>
|
|
2227
|
-
) : storedKeys.length > 0 && (
|
|
2228
|
-
<button
|
|
2229
|
-
onClick={() => { setHeaderRows([]); clearStatus() }}
|
|
2230
|
-
disabled={apply.status === 'applying'}
|
|
2231
|
-
className="mt-2 text-xs text-theme-text-secondary hover:underline"
|
|
2232
|
-
>
|
|
2233
|
-
Clear saved headers
|
|
2234
|
-
</button>
|
|
2235
|
-
)}
|
|
2236
|
-
{headerRows?.length === 0 && (
|
|
2237
|
-
<p className="mt-1 text-xs text-warning-text">Headers will be cleared when you click Apply now.</p>
|
|
2238
|
-
)}
|
|
2239
|
-
</div>
|
|
2240
|
-
)
|
|
2241
|
-
}
|
|
2242
2103
|
|
|
2243
2104
|
// -- Argo CD (live-appliable) -------------------------------------------------
|
|
2244
2105
|
|
|
@@ -2385,7 +2246,7 @@ function ArgoCDEnvManagedField({
|
|
|
2385
2246
|
Connected at {connectedAddress}
|
|
2386
2247
|
</span>
|
|
2387
2248
|
) : statusReason ? (
|
|
2388
|
-
<span className="text-warning-text">Not
|
|
2249
|
+
<span className="text-warning-text">Not connected — {statusReason}</span>
|
|
2389
2250
|
) : (
|
|
2390
2251
|
'Not connected'
|
|
2391
2252
|
)}
|
|
@@ -2606,7 +2467,7 @@ function ArgoCDEditableField({
|
|
|
2606
2467
|
)}
|
|
2607
2468
|
|
|
2608
2469
|
<label className="block text-sm font-medium text-theme-text-primary mt-3 mb-1">
|
|
2609
|
-
{cliSession ? 'Or paste a token' : '
|
|
2470
|
+
{cliSession ? 'Or paste a token' : 'API token'}
|
|
2610
2471
|
</label>
|
|
2611
2472
|
<p className="text-xs text-theme-text-tertiary mb-1">
|
|
2612
2473
|
Lets Radar read your applications. Create one with{' '}
|
|
@@ -2619,7 +2480,7 @@ function ArgoCDEditableField({
|
|
|
2619
2480
|
type="password"
|
|
2620
2481
|
value={showConfiguredPlaceholder ? '' : token}
|
|
2621
2482
|
onChange={(e) => { setToken(e.target.value); setTokenTouched(true); setTokenCleared(false); clearStatus() }}
|
|
2622
|
-
placeholder={showConfiguredPlaceholder ? '
|
|
2483
|
+
placeholder={showConfiguredPlaceholder ? 'Saved value' : 'Argo CD API token'}
|
|
2623
2484
|
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"
|
|
2624
2485
|
/>
|
|
2625
2486
|
{effectiveTokenSet && !tokenCleared && (
|
|
@@ -2627,12 +2488,12 @@ function ArgoCDEditableField({
|
|
|
2627
2488
|
onClick={() => { setToken(''); setTokenTouched(false); setTokenCleared(true); clearStatus() }}
|
|
2628
2489
|
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"
|
|
2629
2490
|
>
|
|
2630
|
-
|
|
2491
|
+
Remove
|
|
2631
2492
|
</button>
|
|
2632
2493
|
)}
|
|
2633
2494
|
</div>
|
|
2634
2495
|
{tokenCleared && (
|
|
2635
|
-
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be
|
|
2496
|
+
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">Token will be removed when you save.</p>
|
|
2636
2497
|
)}
|
|
2637
2498
|
|
|
2638
2499
|
<label className="mt-2 flex items-center gap-2 cursor-pointer">
|
|
@@ -2798,45 +2659,6 @@ function ConfigNumberField({
|
|
|
2798
2659
|
)
|
|
2799
2660
|
}
|
|
2800
2661
|
|
|
2801
|
-
function ConfigToggle({
|
|
2802
|
-
label,
|
|
2803
|
-
description,
|
|
2804
|
-
value,
|
|
2805
|
-
onChange,
|
|
2806
|
-
}: {
|
|
2807
|
-
label: string
|
|
2808
|
-
description?: string
|
|
2809
|
-
value: boolean
|
|
2810
|
-
onChange: (value: boolean) => void
|
|
2811
|
-
}) {
|
|
2812
|
-
return (
|
|
2813
|
-
<label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
|
|
2814
|
-
<span className="min-w-0">
|
|
2815
|
-
<span className="block text-sm text-theme-text-primary">{label}</span>
|
|
2816
|
-
{description && (
|
|
2817
|
-
<span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
|
|
2818
|
-
)}
|
|
2819
|
-
</span>
|
|
2820
|
-
<button
|
|
2821
|
-
role="switch"
|
|
2822
|
-
aria-checked={value}
|
|
2823
|
-
onClick={() => onChange(!value)}
|
|
2824
|
-
className={clsx(
|
|
2825
|
-
'relative w-9 h-5 shrink-0 rounded-full transition-colors',
|
|
2826
|
-
value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
|
|
2827
|
-
)}
|
|
2828
|
-
>
|
|
2829
|
-
<span
|
|
2830
|
-
className={clsx(
|
|
2831
|
-
'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
|
|
2832
|
-
value && 'translate-x-4'
|
|
2833
|
-
)}
|
|
2834
|
-
/>
|
|
2835
|
-
</button>
|
|
2836
|
-
</label>
|
|
2837
|
-
)
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
2662
|
function EffectiveHint({
|
|
2841
2663
|
current,
|
|
2842
2664
|
effective,
|