@skyhook-io/radar-app 1.15.0 → 1.16.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 +2 -0
- package/package.json +6 -5
- package/src/App.tsx +14 -11
- 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.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- 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 +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -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/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- 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/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/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- 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/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/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- 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 +20 -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/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- 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
|
@@ -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'
|
|
@@ -25,16 +25,25 @@ import { PrivacySection } from './PrivacySection'
|
|
|
25
25
|
import { ConfigToggle, SubHeading } from './controls'
|
|
26
26
|
import { useDiagnose } from '../diagnose/DiagnoseContext'
|
|
27
27
|
import { currencyOptionsForValue } from './currency-options'
|
|
28
|
-
import {
|
|
28
|
+
import { UpdateNotification } from '../ui/UpdateNotification'
|
|
29
29
|
import {
|
|
30
30
|
costConfigurationAction,
|
|
31
31
|
costFreshnessLabel,
|
|
32
32
|
costIntegrationUnavailableMessage,
|
|
33
33
|
costSourceLabel,
|
|
34
|
+
costSourcePreferenceLabel,
|
|
35
|
+
COST_SOURCE_OPTIONS,
|
|
34
36
|
} from '../cost/source'
|
|
35
|
-
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'
|
|
36
44
|
import type { SettingsSectionId } from './settings-state'
|
|
37
45
|
import { OperatorManagedNotice } from './OperatorManagedNotice'
|
|
46
|
+
import { FormSaveActions } from './FormSaveActions'
|
|
38
47
|
export type { SettingsSectionId } from './settings-state'
|
|
39
48
|
|
|
40
49
|
function mcpEndpointUrl(): string {
|
|
@@ -62,8 +71,7 @@ interface Config {
|
|
|
62
71
|
restoreLastDesktopContext?: boolean | null
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
interface ConfigResponse {
|
|
66
|
-
management: 'local' | 'operator' | 'cloud'
|
|
74
|
+
interface ConfigResponse extends IntegrationSettingsResponse {
|
|
67
75
|
file: Config
|
|
68
76
|
effective: Config
|
|
69
77
|
isDesktop: boolean
|
|
@@ -130,6 +138,8 @@ function normalizeStartup(c: Config) {
|
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
141
|
+
const localIntegrationCaption = 'Saved for this cluster · applies when you save, no restart.'
|
|
142
|
+
|
|
133
143
|
export function SettingsDialog({
|
|
134
144
|
open,
|
|
135
145
|
onClose,
|
|
@@ -142,6 +152,39 @@ export function SettingsDialog({
|
|
|
142
152
|
const { data: versionInfo } = useVersionCheck()
|
|
143
153
|
const { canAtLeast } = useCloudRole()
|
|
144
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])
|
|
145
188
|
|
|
146
189
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
147
190
|
const operatorManaged = configData?.management === 'operator'
|
|
@@ -158,9 +201,10 @@ export function SettingsDialog({
|
|
|
158
201
|
const configSavingRef = useRef(false)
|
|
159
202
|
const costCurrencySavingRef = useRef(false)
|
|
160
203
|
const pendingCloseActionRef = useRef<(() => void) | null>(null)
|
|
161
|
-
const
|
|
204
|
+
const argoStatusQuery = useArgoStatus(
|
|
162
205
|
open && section === 'argocd'
|
|
163
206
|
)
|
|
207
|
+
const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = argoStatusQuery
|
|
164
208
|
|
|
165
209
|
// Local AI preferences save independently of the owner-gated server settings.
|
|
166
210
|
const diag = useDiagnose()
|
|
@@ -172,11 +216,20 @@ export function SettingsDialog({
|
|
|
172
216
|
effort: diag.effort,
|
|
173
217
|
})
|
|
174
218
|
const [aiSaved, setAiSaved] = useState(false)
|
|
175
|
-
|
|
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 && (
|
|
176
225
|
aiDraft.agent !== diag.selectedAgent ||
|
|
177
226
|
aiDraft.profile !== diag.profile ||
|
|
178
227
|
aiDraft.model !== diag.model ||
|
|
179
|
-
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])
|
|
180
233
|
|
|
181
234
|
// Per-bucket normalized dirty. Only startup fields participate; integration
|
|
182
235
|
// fields apply live and never light up the footer.
|
|
@@ -198,17 +251,47 @@ export function SettingsDialog({
|
|
|
198
251
|
(editedConfig.costSource ?? 'auto') !== (configData?.file.costSource ?? 'auto') ||
|
|
199
252
|
(editedConfig.kubecostUrl ?? '').trim() !== (configData?.file.kubecostUrl ?? '').trim() ||
|
|
200
253
|
(editedConfig.kubecostClusterId ?? '').trim() !== (configData?.file.kubecostClusterId ?? '').trim()
|
|
201
|
-
const costIntegrationDirty = costSourceDirty || costCredentialDirty
|
|
254
|
+
const costIntegrationDirty = costSourceDirty || costCredentialDirty || localDirty.cost
|
|
202
255
|
// Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
|
|
203
256
|
const connectionDirty = clusterDirty || serverDirty
|
|
204
257
|
const advancedDirty = mcpDirty || timelineDirty
|
|
205
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])
|
|
206
285
|
|
|
207
286
|
// Load config on open + snapshot AI prefs + pick a default section that's
|
|
208
287
|
// actually accessible to the current identity.
|
|
209
288
|
useEffect(() => {
|
|
210
289
|
if (!open) return
|
|
290
|
+
if (targetChangedWithDraft || draftFrozen) return
|
|
291
|
+
const controller = new AbortController()
|
|
211
292
|
setConfigData(null)
|
|
293
|
+
setPrometheusCredentialDirty(false)
|
|
294
|
+
setLocalDirty({ metrics: false, argocd: false, cost: false })
|
|
212
295
|
setSaveMessage(null)
|
|
213
296
|
setLoadError(null)
|
|
214
297
|
setConfirmingClose(false)
|
|
@@ -216,28 +299,27 @@ export function SettingsDialog({
|
|
|
216
299
|
setCostCredentialDirty(false)
|
|
217
300
|
setCostDraftReset((current) => current + 1)
|
|
218
301
|
setAiSaved(false)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
model: diag.model,
|
|
223
|
-
effort: diag.effort,
|
|
224
|
-
})
|
|
225
|
-
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() })
|
|
226
305
|
.then((res) => {
|
|
227
306
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
228
307
|
return res.json()
|
|
229
308
|
})
|
|
230
309
|
.then((data: ConfigResponse) => {
|
|
310
|
+
if (controller.signal.aborted || getApiBase() !== settingsApiBase) return
|
|
231
311
|
setConfigData(data)
|
|
232
312
|
setEditedConfig({ ...data.file, prometheusUrl: data.effective.prometheusUrl })
|
|
233
313
|
})
|
|
234
314
|
.catch((err) => {
|
|
315
|
+
if (controller.signal.aborted) return
|
|
235
316
|
console.warn('[settings] Failed to load config:', err)
|
|
236
317
|
setLoadError('Failed to load configuration.')
|
|
237
318
|
})
|
|
319
|
+
return () => controller.abort()
|
|
238
320
|
// Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
|
|
239
321
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
-
}, [open])
|
|
322
|
+
}, [open, isSwitching, settingsCluster?.context, settingsApiBase, draftFrozen, reloadVersion])
|
|
241
323
|
|
|
242
324
|
useEffect(() => {
|
|
243
325
|
if (open) setSection(initialSection)
|
|
@@ -258,7 +340,7 @@ export function SettingsDialog({
|
|
|
258
340
|
}, [])
|
|
259
341
|
|
|
260
342
|
const saveConfig = useCallback(async (): Promise<boolean> => {
|
|
261
|
-
if (!configData || configSavingRef.current || costCurrencySavingRef.current) return false
|
|
343
|
+
if (!configData || integrationBusy || draftFrozen || targetChangedWithDraft || configSavingRef.current || costCurrencySavingRef.current) return false
|
|
262
344
|
configSavingRef.current = true
|
|
263
345
|
setSaving(true)
|
|
264
346
|
setSaveMessage(null)
|
|
@@ -302,7 +384,7 @@ export function SettingsDialog({
|
|
|
302
384
|
configSavingRef.current = false
|
|
303
385
|
setSaving(false)
|
|
304
386
|
}
|
|
305
|
-
}, [editedConfig, configData])
|
|
387
|
+
}, [editedConfig, configData, integrationBusy, draftFrozen, targetChangedWithDraft])
|
|
306
388
|
|
|
307
389
|
const saveCostCurrency = useCallback(async (value: string): Promise<void> => {
|
|
308
390
|
if (!configData) throw new Error('Radar configuration is not available')
|
|
@@ -352,20 +434,31 @@ export function SettingsDialog({
|
|
|
352
434
|
diag.setProfile(aiDraft.profile)
|
|
353
435
|
diag.setModel(aiDraft.model)
|
|
354
436
|
diag.setEffort(aiDraft.effort)
|
|
437
|
+
setAiTouched(false)
|
|
355
438
|
setAiSaved(true)
|
|
356
439
|
}, [diag, aiDraft])
|
|
357
440
|
|
|
441
|
+
const discardAi = useCallback(() => {
|
|
442
|
+
setAiTouched(false)
|
|
443
|
+
setAiSaved(false)
|
|
444
|
+
}, [])
|
|
445
|
+
|
|
358
446
|
const discardChanges = useCallback(() => {
|
|
359
447
|
// Revert unsaved startup edits back to the last-saved values (non-
|
|
360
448
|
// destructive). configData.file holds the committed config, including the
|
|
361
449
|
// live integration fields, so restoring it drops drafts without touching
|
|
362
|
-
// what's saved.
|
|
450
|
+
// what's saved. AI preferences don't depend on the config load.
|
|
451
|
+
if (settingsBusy || draftFrozen || targetChangedWithDraft) return
|
|
452
|
+
discardAi()
|
|
363
453
|
if (!configData) return
|
|
364
454
|
setEditedConfig({ ...configData.file, prometheusUrl: configData.effective.prometheusUrl })
|
|
365
455
|
setCostCredentialDirty(false)
|
|
456
|
+
setPrometheusCredentialDirty(false)
|
|
457
|
+
setDiscardGeneration(value => value + 1)
|
|
458
|
+
setLocalDirty({ metrics: false, argocd: false, cost: false })
|
|
366
459
|
setCostDraftReset((current) => current + 1)
|
|
367
460
|
setSaveMessage(null)
|
|
368
|
-
}, [configData])
|
|
461
|
+
}, [configData, settingsBusy, draftFrozen, targetChangedWithDraft, discardAi])
|
|
369
462
|
|
|
370
463
|
const finishClose = useCallback(() => {
|
|
371
464
|
const action = pendingCloseActionRef.current
|
|
@@ -379,23 +472,33 @@ export function SettingsDialog({
|
|
|
379
472
|
if (ok) finishClose()
|
|
380
473
|
}, [saveConfig, finishClose])
|
|
381
474
|
|
|
382
|
-
const
|
|
475
|
+
const reviewPendingDraft = () => {
|
|
476
|
+
if (!reviewSection) return
|
|
383
477
|
setConfirmingClose(false)
|
|
384
|
-
setSection(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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])
|
|
391
496
|
|
|
392
|
-
// Close guard: a pending startup edit prompts an inline confirm rather than
|
|
393
|
-
// silently discarding. An unsaved AI draft is re-derivable, so it's fine to
|
|
394
|
-
// drop it on close.
|
|
395
497
|
const requestCloseRef = useRef<(afterClose?: () => void) => void>(() => {})
|
|
396
498
|
requestCloseRef.current = (afterClose) => {
|
|
499
|
+
if (settingsBusy) return
|
|
397
500
|
pendingCloseActionRef.current = afterClose ?? null
|
|
398
|
-
if (canEditConfig && (configDirty ||
|
|
501
|
+
if ((canEditConfig && (configDirty || integrationDirty)) || aiDirty) setConfirmingClose(true)
|
|
399
502
|
else finishClose()
|
|
400
503
|
}
|
|
401
504
|
|
|
@@ -447,21 +550,20 @@ export function SettingsDialog({
|
|
|
447
550
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
|
|
448
551
|
{ id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
|
|
449
552
|
{ id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
|
|
450
|
-
{ id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty:
|
|
553
|
+
{ id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: prometheusDirty },
|
|
451
554
|
{ id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
|
|
452
|
-
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty:
|
|
555
|
+
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: localDirty.argocd },
|
|
453
556
|
{ id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
|
|
454
557
|
{ id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
|
|
455
558
|
{ id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
|
|
456
559
|
]
|
|
457
560
|
|
|
458
|
-
const offerCostReview = shouldOfferCostReview(costIntegrationDirty, section)
|
|
459
561
|
const showFooter = shouldShowSettingsFooter({
|
|
460
562
|
canEditConfig,
|
|
461
563
|
confirmingClose,
|
|
462
564
|
configDirty,
|
|
463
|
-
|
|
464
|
-
section,
|
|
565
|
+
integrationDirty: integrationDirty && (!configData?.integrationProfiles || (!!reviewSection && reviewSection !== 'ai')),
|
|
566
|
+
aiDirtyElsewhere: aiDirty && section !== 'ai',
|
|
465
567
|
hasSaveMessage: Boolean(saveMessage),
|
|
466
568
|
})
|
|
467
569
|
|
|
@@ -497,28 +599,30 @@ export function SettingsDialog({
|
|
|
497
599
|
// Fixed height so the dialog doesn't jump when switching tabs — short
|
|
498
600
|
// tabs leave breathing room, tall ones scroll inside the content pane.
|
|
499
601
|
// max-h keeps it on-screen on short viewports.
|
|
500
|
-
'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)]',
|
|
501
603
|
TRANSITION_PANEL,
|
|
502
604
|
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
|
503
605
|
)}
|
|
504
606
|
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
505
607
|
>
|
|
506
608
|
{/* Header — spans both panes */}
|
|
507
|
-
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
508
|
-
<div className="flex items-center gap-2">
|
|
509
|
-
<Settings className="w-5 h-5 text-theme-text-secondary" />
|
|
510
|
-
<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">
|
|
511
613
|
<h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
|
|
512
|
-
<span className="text-[11px] text-theme-text-tertiary">
|
|
513
|
-
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/, '')}` : ''}
|
|
514
616
|
<span className="text-theme-text-disabled"> · by Skyhook</span>
|
|
515
617
|
</span>
|
|
516
|
-
|
|
618
|
+
<UpdateNotification placement="settings" />
|
|
517
619
|
</div>
|
|
518
620
|
</div>
|
|
519
621
|
<button
|
|
520
622
|
onClick={() => requestCloseRef.current()}
|
|
521
|
-
|
|
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"
|
|
522
626
|
>
|
|
523
627
|
<X className="w-5 h-5" />
|
|
524
628
|
</button>
|
|
@@ -566,21 +670,35 @@ export function SettingsDialog({
|
|
|
566
670
|
</div>
|
|
567
671
|
)}
|
|
568
672
|
|
|
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>}
|
|
569
681
|
{!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
|
|
570
682
|
<p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
|
|
571
|
-
) :
|
|
683
|
+
) : <div inert={draftFrozen || settingsBusy || undefined} className={draftFrozen ? 'opacity-60' : undefined}>
|
|
572
684
|
{operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
|
|
573
685
|
{/* Overview — status at a glance; the landing section */}
|
|
574
686
|
<div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
|
|
575
687
|
<div className="mb-1">
|
|
576
|
-
<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>
|
|
577
689
|
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
578
690
|
What this Radar is connected to right now — select a row for details.
|
|
579
691
|
</p>
|
|
580
692
|
</div>
|
|
693
|
+
{configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
|
|
694
|
+
<PreviousIntegrationSettingsNotice profiles={configData.integrationProfiles} onNavigate={setSection} onDismissed={previousNoticeDismissed} />
|
|
695
|
+
)}
|
|
581
696
|
<div className="mt-3">
|
|
582
697
|
<OverviewPanel active={section === 'overview'} onNavigate={setSection} />
|
|
583
698
|
</div>
|
|
699
|
+
{configData?.management === 'local' && configData.integrationProfiles && canEditConfig && (
|
|
700
|
+
<LocalConfigurationDetails onNavigate={setSection} />
|
|
701
|
+
)}
|
|
584
702
|
</div>
|
|
585
703
|
|
|
586
704
|
{/* My permissions — usable by everyone, rendered inline (no launcher) */}
|
|
@@ -603,7 +721,7 @@ export function SettingsDialog({
|
|
|
603
721
|
active={section}
|
|
604
722
|
title="Connection"
|
|
605
723
|
managed={operatorManaged ? <OperatorSettingsSummary section="connection" config={configData} /> : undefined}
|
|
606
|
-
caption="
|
|
724
|
+
caption="Kubeconfig and server changes take effect on next launch."
|
|
607
725
|
locked={!canEditConfig}
|
|
608
726
|
>
|
|
609
727
|
<div className="space-y-4">
|
|
@@ -625,6 +743,15 @@ export function SettingsDialog({
|
|
|
625
743
|
onChange={updateConfigField}
|
|
626
744
|
/>
|
|
627
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
|
+
)}
|
|
628
755
|
</SectionPane>
|
|
629
756
|
|
|
630
757
|
{/* Metrics backend — live */}
|
|
@@ -633,11 +760,15 @@ export function SettingsDialog({
|
|
|
633
760
|
active={section}
|
|
634
761
|
title="Metrics"
|
|
635
762
|
managed={operatorManaged ? <OperatorSettingsSummary section="prometheus" config={configData} /> : undefined}
|
|
636
|
-
caption=
|
|
763
|
+
caption={configData?.integrationProfiles ? localIntegrationCaption : 'Connect and manage your metrics backend.'}
|
|
637
764
|
live
|
|
638
765
|
locked={!canEditConfig}
|
|
639
766
|
>
|
|
640
|
-
<
|
|
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}
|
|
641
772
|
local={deploymentMode === 'local'}
|
|
642
773
|
value={editedConfig.prometheusUrl ?? ''}
|
|
643
774
|
configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
|
|
@@ -645,7 +776,8 @@ export function SettingsDialog({
|
|
|
645
776
|
headersManaged={configData?.prometheusHeadersManaged === true}
|
|
646
777
|
urlFromFlag={configData?.prometheusUrlFromFlag === true}
|
|
647
778
|
onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
|
|
648
|
-
onApplied={(url) =>
|
|
779
|
+
onApplied={(url) => {
|
|
780
|
+
setEditedConfig((prev) => ({ ...prev, prometheusUrl: url || undefined }))
|
|
649
781
|
setConfigData((prev) =>
|
|
650
782
|
prev ? {
|
|
651
783
|
...prev,
|
|
@@ -653,8 +785,8 @@ export function SettingsDialog({
|
|
|
653
785
|
effective: { ...prev.effective, prometheusUrl: url || undefined },
|
|
654
786
|
} : prev
|
|
655
787
|
)
|
|
656
|
-
}
|
|
657
|
-
/>
|
|
788
|
+
}}
|
|
789
|
+
/>}
|
|
658
790
|
</SectionPane>
|
|
659
791
|
|
|
660
792
|
<SectionPane
|
|
@@ -662,11 +794,13 @@ export function SettingsDialog({
|
|
|
662
794
|
active={section}
|
|
663
795
|
title="Cost"
|
|
664
796
|
managed={operatorManaged ? <OperatorSettingsSummary section="cost" config={configData} /> : undefined}
|
|
665
|
-
caption=
|
|
797
|
+
caption={configData?.integrationProfiles ? `${localIntegrationCaption} Display currency applies to all clusters.` : 'Choose where Radar gets cost data and how amounts are labeled.'}
|
|
666
798
|
live
|
|
667
799
|
locked={!canEditConfig}
|
|
668
800
|
>
|
|
669
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}
|
|
670
804
|
currency={editedConfig.opencostCurrency ?? ''}
|
|
671
805
|
source={editedConfig.costSource ?? 'auto'}
|
|
672
806
|
url={editedConfig.kubecostUrl ?? ''}
|
|
@@ -679,7 +813,7 @@ export function SettingsDialog({
|
|
|
679
813
|
managed={configData?.openCostCurrencyManaged ?? false}
|
|
680
814
|
effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
|
|
681
815
|
deploymentMode={deploymentMode}
|
|
682
|
-
settingsSaving={
|
|
816
|
+
settingsSaving={settingsBusy}
|
|
683
817
|
onNavigateToResource={navigateFromSettings}
|
|
684
818
|
onApplyCurrency={saveCostCurrency}
|
|
685
819
|
onChangeSource={(value) => updateConfigField('costSource', value)}
|
|
@@ -707,11 +841,12 @@ export function SettingsDialog({
|
|
|
707
841
|
active={section}
|
|
708
842
|
title="Argo CD"
|
|
709
843
|
managed={operatorManaged ? <OperatorSettingsSummary section="argocd" config={configData} /> : undefined}
|
|
710
|
-
caption=
|
|
844
|
+
caption={configData?.integrationProfiles ? localIntegrationCaption : 'Applies immediately — no restart.'}
|
|
711
845
|
live
|
|
712
846
|
locked={!canEditConfig}
|
|
713
847
|
>
|
|
714
|
-
<
|
|
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
|
|
715
850
|
url={editedConfig.argoCdUrl ?? ''}
|
|
716
851
|
insecureTls={editedConfig.argoCdInsecureTls ?? false}
|
|
717
852
|
tokenSet={configData?.argoCdTokenSet ?? false}
|
|
@@ -746,7 +881,7 @@ export function SettingsDialog({
|
|
|
746
881
|
predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('gitops-'),
|
|
747
882
|
})
|
|
748
883
|
}}
|
|
749
|
-
/>
|
|
884
|
+
/>}
|
|
750
885
|
</SectionPane>
|
|
751
886
|
|
|
752
887
|
{/* AI investigations — self-saving, usable by everyone. Same heading block
|
|
@@ -773,26 +908,19 @@ export function SettingsDialog({
|
|
|
773
908
|
draft={aiDraft}
|
|
774
909
|
onChange={(patch) => {
|
|
775
910
|
setAiDraft((d) => ({ ...d, ...patch }))
|
|
911
|
+
setAiTouched(true)
|
|
776
912
|
setAiSaved(false)
|
|
777
913
|
}}
|
|
778
914
|
onHistoryCleared={diag.refreshRuns}
|
|
779
915
|
/>
|
|
780
|
-
{
|
|
781
|
-
<
|
|
782
|
-
{
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
<button
|
|
789
|
-
onClick={saveAi}
|
|
790
|
-
disabled={!aiDirty}
|
|
791
|
-
className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
|
|
792
|
-
>
|
|
793
|
-
Save
|
|
794
|
-
</button>
|
|
795
|
-
</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
|
+
/>
|
|
796
924
|
)}
|
|
797
925
|
</div>
|
|
798
926
|
) : (
|
|
@@ -834,120 +962,118 @@ export function SettingsDialog({
|
|
|
834
962
|
/>
|
|
835
963
|
</div>
|
|
836
964
|
</SectionPane>
|
|
837
|
-
|
|
965
|
+
</div>}
|
|
838
966
|
</div>
|
|
839
967
|
</div>
|
|
840
968
|
|
|
841
|
-
{/*
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
<
|
|
845
|
-
className=
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
<span className="text-xs text-theme-text-secondary">
|
|
854
|
-
{costIntegrationDirty && configDirty
|
|
855
|
-
? 'Cost source changes are not applied, and other changes are unsaved.'
|
|
856
|
-
: costIntegrationDirty
|
|
857
|
-
? '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.' : ''}`
|
|
858
981
|
: 'Unsaved changes.'}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
pendingCloseActionRef.current = null
|
|
864
|
-
setConfirmingClose(false)
|
|
865
|
-
}}
|
|
866
|
-
disabled={saving}
|
|
867
|
-
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"
|
|
868
|
-
>
|
|
869
|
-
Keep editing
|
|
870
|
-
</button>
|
|
871
|
-
<button
|
|
872
|
-
onClick={finishClose}
|
|
873
|
-
disabled={saving}
|
|
874
|
-
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"
|
|
875
|
-
>
|
|
876
|
-
Discard
|
|
877
|
-
</button>
|
|
878
|
-
{offerCostReview && (
|
|
879
|
-
<button
|
|
880
|
-
onClick={reviewCostDraft}
|
|
881
|
-
className={clsx(
|
|
882
|
-
'flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium',
|
|
883
|
-
configDirty
|
|
884
|
-
? 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
885
|
-
: 'btn-brand',
|
|
886
|
-
)}
|
|
887
|
-
>
|
|
888
|
-
Review Cost
|
|
889
|
-
</button>
|
|
890
|
-
)}
|
|
891
|
-
{configDirty && (
|
|
892
|
-
<button
|
|
893
|
-
onClick={costIntegrationDirty ? saveConfig : handleSaveAndClose}
|
|
894
|
-
disabled={saving || costCurrencySaving}
|
|
895
|
-
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
|
|
896
|
-
>
|
|
897
|
-
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
898
|
-
{costIntegrationDirty ? 'Save other changes' : 'Save'}
|
|
899
|
-
</button>
|
|
900
|
-
)}
|
|
901
|
-
</div>
|
|
902
|
-
</>
|
|
903
|
-
) : (
|
|
904
|
-
<>
|
|
905
|
-
<div className="flex items-center gap-2">
|
|
906
|
-
<Tooltip content="Discard unsaved changes and revert to the last saved values">
|
|
907
|
-
<button
|
|
908
|
-
onClick={discardChanges}
|
|
909
|
-
disabled={saving || (!configDirty && !costIntegrationDirty)}
|
|
910
|
-
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"
|
|
911
|
-
>
|
|
912
|
-
<RotateCcw className="w-3.5 h-3.5" />
|
|
913
|
-
Discard changes
|
|
914
|
-
</button>
|
|
915
|
-
</Tooltip>
|
|
916
|
-
{saveMessage && (
|
|
917
|
-
<span className={clsx('text-xs', saveMessage.startsWith('Error') ? 'text-red-400' : 'text-green-400')}>
|
|
918
|
-
{saveMessage}
|
|
919
|
-
</span>
|
|
920
|
-
)}
|
|
921
|
-
</div>
|
|
922
|
-
<div className="flex items-center gap-2">
|
|
923
|
-
{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">
|
|
924
986
|
<button
|
|
925
|
-
onClick={
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
)}
|
|
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"
|
|
932
993
|
>
|
|
933
|
-
|
|
994
|
+
Keep editing
|
|
934
995
|
</button>
|
|
935
|
-
)}
|
|
936
|
-
{configDirty && (
|
|
937
996
|
<button
|
|
938
|
-
onClick={
|
|
939
|
-
disabled={
|
|
940
|
-
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"
|
|
941
1000
|
>
|
|
942
|
-
|
|
943
|
-
Save
|
|
1001
|
+
Discard
|
|
944
1002
|
</button>
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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>
|
|
949
1075
|
</div>
|
|
950
|
-
</
|
|
1076
|
+
</Collapse>
|
|
951
1077
|
</div>
|
|
952
1078
|
</div>,
|
|
953
1079
|
document.body
|
|
@@ -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">
|