@skyhook-io/radar-app 1.15.0 → 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 +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 +232 -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 +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/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 +36 -13
- 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
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Badge, Collapse, ConfirmDialog, SelectMenu } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { ArrowLeft, Info } from 'lucide-react'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
import type { FormFeedback } from './FormSaveActions'
|
|
6
|
+
import {
|
|
7
|
+
apiUrl,
|
|
8
|
+
getApiBase,
|
|
9
|
+
getAuthHeaders,
|
|
10
|
+
getCredentialsMode
|
|
11
|
+
} from '../../api/config'
|
|
12
|
+
import { ApiError } from '../../api/client'
|
|
13
|
+
import { PrometheusConnectionForm } from './PrometheusConnectionForm'
|
|
14
|
+
import {
|
|
15
|
+
ArgoCDConnectionForm,
|
|
16
|
+
CostConnectionForm,
|
|
17
|
+
type ArgoConnectionDraft,
|
|
18
|
+
type CostConnectionDraft,
|
|
19
|
+
type SecretEdit
|
|
20
|
+
} from './IntegrationConnectionForms'
|
|
21
|
+
import type { HeaderOperation } from './ConnectionHeadersEditor'
|
|
22
|
+
|
|
23
|
+
export type IntegrationKind = 'metrics' | 'argocd' | 'cost'
|
|
24
|
+
interface TargetIdentity {
|
|
25
|
+
server: string
|
|
26
|
+
user?: string
|
|
27
|
+
tlsName?: string
|
|
28
|
+
trust?: string
|
|
29
|
+
proxy?: string
|
|
30
|
+
insecureTls?: boolean
|
|
31
|
+
}
|
|
32
|
+
interface Target {
|
|
33
|
+
binding: string
|
|
34
|
+
context: string
|
|
35
|
+
source?: string
|
|
36
|
+
inFileName?: string
|
|
37
|
+
fingerprint: string
|
|
38
|
+
clientGeneration: number
|
|
39
|
+
operationGeneration: number
|
|
40
|
+
identity: TargetIdentity
|
|
41
|
+
}
|
|
42
|
+
export interface StoredConnection {
|
|
43
|
+
revision: string
|
|
44
|
+
binding: string
|
|
45
|
+
integration: IntegrationKind
|
|
46
|
+
context: string
|
|
47
|
+
source: string
|
|
48
|
+
inFileName: string
|
|
49
|
+
availability: 'available' | 'removed' | 'unavailable'
|
|
50
|
+
url: string
|
|
51
|
+
headerKeys: string[]
|
|
52
|
+
envHeaderKeys: string[]
|
|
53
|
+
secretSet: boolean
|
|
54
|
+
insecureTls: boolean
|
|
55
|
+
error?: string
|
|
56
|
+
}
|
|
57
|
+
export interface IntegrationProfile {
|
|
58
|
+
target: Target
|
|
59
|
+
revision: string
|
|
60
|
+
state: 'auto' | 'saved' | 'launch' | 'target_changed' | 'error'
|
|
61
|
+
mode: string
|
|
62
|
+
url: string
|
|
63
|
+
headerKeys: string[]
|
|
64
|
+
envHeaderKeys: string[]
|
|
65
|
+
headersManaged: boolean
|
|
66
|
+
secretSet: boolean
|
|
67
|
+
insecureTls: boolean
|
|
68
|
+
clusterId: string
|
|
69
|
+
previousIdentity?: TargetIdentity
|
|
70
|
+
error?: string
|
|
71
|
+
legacy?: {
|
|
72
|
+
url: string
|
|
73
|
+
headerKeys: string[]
|
|
74
|
+
envHeaderKeys: string[]
|
|
75
|
+
insecureTls: boolean
|
|
76
|
+
mode: string
|
|
77
|
+
clusterId: string
|
|
78
|
+
secretSet: boolean
|
|
79
|
+
revision: string
|
|
80
|
+
error?: string
|
|
81
|
+
omitted: string[]
|
|
82
|
+
noticeDismissed: boolean
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export type IntegrationProfiles = Record<IntegrationKind, IntegrationProfile>
|
|
86
|
+
export interface ConnectionResponse {
|
|
87
|
+
profiles: IntegrationProfiles
|
|
88
|
+
connections: StoredConnection[]
|
|
89
|
+
connected: boolean
|
|
90
|
+
checked: boolean
|
|
91
|
+
error?: string
|
|
92
|
+
}
|
|
93
|
+
interface Update {
|
|
94
|
+
action: string
|
|
95
|
+
legacyRevision?: string
|
|
96
|
+
binding?: string
|
|
97
|
+
sourceRevision?: string
|
|
98
|
+
url?: string
|
|
99
|
+
headers?: HeaderOperation[]
|
|
100
|
+
secret?: SecretEdit
|
|
101
|
+
insecureTls?: boolean
|
|
102
|
+
mode?: string
|
|
103
|
+
clusterId?: string
|
|
104
|
+
confirmRemoval?: boolean
|
|
105
|
+
kinds?: IntegrationKind[]
|
|
106
|
+
useCliToken?: boolean
|
|
107
|
+
}
|
|
108
|
+
type Task = 'main' | 'reconfirm' | 'replace'
|
|
109
|
+
const previousSource = '__previous__'
|
|
110
|
+
const savedCredential: Record<IntegrationKind, string> = {
|
|
111
|
+
metrics: 'saved headers',
|
|
112
|
+
argocd: 'a saved token',
|
|
113
|
+
cost: 'a saved API key'
|
|
114
|
+
}
|
|
115
|
+
function describeSource(kind: IntegrationKind, source: { url: string; secretSet: boolean; headerKeys: string[]; mode?: string; clusterId?: string }) {
|
|
116
|
+
if (source.url) return source.url
|
|
117
|
+
if (kind === 'cost' && source.mode === 'prometheus') return 'OpenCost metrics'
|
|
118
|
+
if (source.secretSet || source.headerKeys.length > 0) return `Auto-discovery with ${savedCredential[kind]}`
|
|
119
|
+
if (source.clusterId) return 'Auto-discovery with a Kubecost cluster mapping'
|
|
120
|
+
return 'Auto-discovery'
|
|
121
|
+
}
|
|
122
|
+
// Cost's reset also returns the source picker to Automatic, so it can't share
|
|
123
|
+
// the endpoint-only "auto-discovery" wording.
|
|
124
|
+
const automaticAction: Record<IntegrationKind, string> = {
|
|
125
|
+
metrics: 'Use auto-discovery',
|
|
126
|
+
argocd: 'Use auto-discovery',
|
|
127
|
+
cost: 'Reset to Automatic'
|
|
128
|
+
}
|
|
129
|
+
const names: Record<IntegrationKind, string> = {
|
|
130
|
+
metrics: 'Metrics',
|
|
131
|
+
argocd: 'Argo CD',
|
|
132
|
+
cost: 'Cost'
|
|
133
|
+
}
|
|
134
|
+
export { names as integrationNames }
|
|
135
|
+
|
|
136
|
+
export function LocalConnectionSettings({
|
|
137
|
+
kind,
|
|
138
|
+
profiles,
|
|
139
|
+
cliSession,
|
|
140
|
+
onChange,
|
|
141
|
+
onDirtyChange,
|
|
142
|
+
onBusyChange,
|
|
143
|
+
status
|
|
144
|
+
}: {
|
|
145
|
+
kind: IntegrationKind
|
|
146
|
+
profiles: IntegrationProfiles
|
|
147
|
+
cliSession?: { server: string; user: string; insecure?: boolean }
|
|
148
|
+
onChange: (profiles: IntegrationProfiles) => void
|
|
149
|
+
onDirtyChange: (dirty: boolean) => void
|
|
150
|
+
onBusyChange?: (busy: boolean) => void
|
|
151
|
+
status?: ReactNode
|
|
152
|
+
}) {
|
|
153
|
+
const [snapshot, setSnapshot] = useState(profiles)
|
|
154
|
+
const profile = snapshot[kind]
|
|
155
|
+
const [catalog, setCatalog] = useState<StoredConnection[]>([])
|
|
156
|
+
const [task, setTask] = useState<Task>('main')
|
|
157
|
+
const [busy, setBusy] = useState(false)
|
|
158
|
+
const wasBusy = useRef(false)
|
|
159
|
+
const restoreTriggerFocus = useRef(false)
|
|
160
|
+
const [error, setError] = useState('')
|
|
161
|
+
const [catalogError, setCatalogError] = useState(false)
|
|
162
|
+
const [catalogRetry, setCatalogRetry] = useState(0)
|
|
163
|
+
const [message, setMessage] = useState('')
|
|
164
|
+
const [messageWarning, setMessageWarning] = useState(false)
|
|
165
|
+
const [messageRevision, setMessageRevision] = useState('')
|
|
166
|
+
const [url, setUrl] = useState(profile.url)
|
|
167
|
+
const [insecureTls, setInsecureTls] = useState(profile.insecureTls)
|
|
168
|
+
const [mode, setMode] = useState<CostConnectionDraft['mode']>(
|
|
169
|
+
profile.mode as CostConnectionDraft['mode']
|
|
170
|
+
)
|
|
171
|
+
const [clusterId, setClusterId] = useState(profile.clusterId)
|
|
172
|
+
const [credentialDirty, setCredentialDirty] = useState(false)
|
|
173
|
+
const [discoveryDraft, setDiscoveryDraft] = useState(false)
|
|
174
|
+
const [legacyDraft, setLegacyDraft] = useState<IntegrationProfile['legacy']>()
|
|
175
|
+
const [draftGeneration, setDraftGeneration] = useState(0)
|
|
176
|
+
const [selected, setSelected] = useState<
|
|
177
|
+
StoredConnection | null
|
|
178
|
+
>(null)
|
|
179
|
+
const [pending, setPending] = useState<Update | null>(null)
|
|
180
|
+
const confirmationCopy = useRef({ title: '', message: '', label: '' })
|
|
181
|
+
const [confirmBack, setConfirmBack] = useState(false)
|
|
182
|
+
const [accepted, setAccepted] = useState<IntegrationKind[]>([])
|
|
183
|
+
const acceptedChanges = accepted.filter(
|
|
184
|
+
(k) => snapshot[k].state === 'target_changed'
|
|
185
|
+
)
|
|
186
|
+
const request = useRef<AbortController | null>(null)
|
|
187
|
+
const catalogRequest = useRef<AbortController | null>(null)
|
|
188
|
+
const heading = useRef<HTMLHeadingElement>(null)
|
|
189
|
+
const confirmationError = useRef<HTMLParagraphElement>(null)
|
|
190
|
+
const region = useRef<HTMLFieldSetElement>(null)
|
|
191
|
+
const editorRegion = useRef<HTMLFieldSetElement>(null)
|
|
192
|
+
const trigger = useRef<HTMLElement | null>(null)
|
|
193
|
+
const mounted = useRef(true)
|
|
194
|
+
const copySources = catalog.filter(c => c.integration === kind && !c.error && c.url && c.binding !== profile.target.binding)
|
|
195
|
+
const removalContext = profile.target.context
|
|
196
|
+
const dirty =
|
|
197
|
+
!!legacyDraft ||
|
|
198
|
+
!!selected ||
|
|
199
|
+
discoveryDraft ||
|
|
200
|
+
url !== profile.url ||
|
|
201
|
+
insecureTls !== profile.insecureTls ||
|
|
202
|
+
mode !== profile.mode ||
|
|
203
|
+
clusterId !== profile.clusterId ||
|
|
204
|
+
credentialDirty
|
|
205
|
+
const hasSavedConfiguration = !!(
|
|
206
|
+
profile.url || profile.secretSet ||
|
|
207
|
+
profile.headerKeys.length || profile.insecureTls || profile.clusterId
|
|
208
|
+
)
|
|
209
|
+
const automaticDraft = discoveryDraft && !url.trim() && mode === 'auto' &&
|
|
210
|
+
!credentialDirty && !insecureTls && !clusterId
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
catalogRequest.current = controller
|
|
214
|
+
const base = getApiBase()
|
|
215
|
+
void fetch(apiUrl('/integrations/connections'), {
|
|
216
|
+
signal: controller.signal,
|
|
217
|
+
credentials: getCredentialsMode(),
|
|
218
|
+
headers: getAuthHeaders()
|
|
219
|
+
})
|
|
220
|
+
.then(async (response) => {
|
|
221
|
+
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw new Error(data.error || 'Could not load saved connections.')
|
|
224
|
+
if (!controller.signal.aborted && getApiBase() === base) {
|
|
225
|
+
setCatalog(data.connections)
|
|
226
|
+
setCatalogError(false)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
if (!controller.signal.aborted && getApiBase() === base)
|
|
231
|
+
setCatalogError(true)
|
|
232
|
+
})
|
|
233
|
+
return () => controller.abort()
|
|
234
|
+
}, [kind, profile.target.binding, profiles, draftGeneration, catalogRetry])
|
|
235
|
+
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange])
|
|
236
|
+
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
mounted.current = true
|
|
239
|
+
return () => {
|
|
240
|
+
mounted.current = false
|
|
241
|
+
request.current?.abort()
|
|
242
|
+
}
|
|
243
|
+
}, [])
|
|
244
|
+
const openTask = (next: Task) => {
|
|
245
|
+
trigger.current = document.activeElement as HTMLElement
|
|
246
|
+
setTask(next)
|
|
247
|
+
setError('')
|
|
248
|
+
setMessage('')
|
|
249
|
+
setConfirmBack(false)
|
|
250
|
+
}
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (task !== 'main') heading.current?.focus()
|
|
253
|
+
}, [task])
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (pending && error && !busy) confirmationError.current?.focus()
|
|
256
|
+
}, [pending, error, busy])
|
|
257
|
+
const resetDraft = (next: IntegrationProfile) => {
|
|
258
|
+
setLegacyDraft(undefined)
|
|
259
|
+
setSelected(null)
|
|
260
|
+
setDiscoveryDraft(false)
|
|
261
|
+
setUrl(next.url)
|
|
262
|
+
setInsecureTls(next.insecureTls)
|
|
263
|
+
setMode(next.mode as CostConnectionDraft['mode'])
|
|
264
|
+
setClusterId(next.clusterId)
|
|
265
|
+
setCredentialDirty(false)
|
|
266
|
+
}
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
const finished = wasBusy.current && !busy
|
|
269
|
+
wasBusy.current = busy
|
|
270
|
+
if (busy || pending) return
|
|
271
|
+
if (restoreTriggerFocus.current) {
|
|
272
|
+
restoreTriggerFocus.current = false
|
|
273
|
+
if (trigger.current?.isConnected) trigger.current.focus()
|
|
274
|
+
else region.current?.focus()
|
|
275
|
+
} else if (finished && document.activeElement === document.body) {
|
|
276
|
+
region.current?.focus()
|
|
277
|
+
}
|
|
278
|
+
}, [busy, pending, task])
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (
|
|
281
|
+
!dirty &&
|
|
282
|
+
task === 'main' &&
|
|
283
|
+
profiles[kind].revision !== snapshot[kind].revision
|
|
284
|
+
) {
|
|
285
|
+
setSnapshot(profiles)
|
|
286
|
+
resetDraft(profiles[kind])
|
|
287
|
+
} else if (
|
|
288
|
+
(Object.keys(profiles) as IntegrationKind[]).some(
|
|
289
|
+
(k) => k !== kind && profiles[k] !== snapshot[k]
|
|
290
|
+
)
|
|
291
|
+
) {
|
|
292
|
+
setSnapshot({ ...profiles, [kind]: snapshot[kind] })
|
|
293
|
+
}
|
|
294
|
+
}, [profiles, snapshot, kind, dirty, task])
|
|
295
|
+
const leaveTask = () => {
|
|
296
|
+
restoreTriggerFocus.current = true
|
|
297
|
+
setConfirmBack(false)
|
|
298
|
+
setTask('main')
|
|
299
|
+
setPending(null)
|
|
300
|
+
setSelected(null)
|
|
301
|
+
setError('')
|
|
302
|
+
resetDraft(profile)
|
|
303
|
+
}
|
|
304
|
+
const back = () => {
|
|
305
|
+
if (dirty && task === 'replace') {
|
|
306
|
+
setConfirmBack(true)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
leaveTask()
|
|
310
|
+
}
|
|
311
|
+
const fetchConnections = async (
|
|
312
|
+
update?: Update
|
|
313
|
+
): Promise<ConnectionResponse> => {
|
|
314
|
+
if (request.current)
|
|
315
|
+
throw new Error('A settings request is already in progress.')
|
|
316
|
+
catalogRequest.current?.abort()
|
|
317
|
+
const controller = new AbortController()
|
|
318
|
+
request.current = controller
|
|
319
|
+
const base = getApiBase()
|
|
320
|
+
setBusy(true)
|
|
321
|
+
onBusyChange?.(true)
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetch(apiUrl('/integrations/connections'), {
|
|
324
|
+
method: update ? 'PUT' : 'GET',
|
|
325
|
+
signal: controller.signal,
|
|
326
|
+
credentials: getCredentialsMode(),
|
|
327
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
328
|
+
...(update
|
|
329
|
+
? {
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
...update,
|
|
332
|
+
target: profile.target,
|
|
333
|
+
kind,
|
|
334
|
+
revision: profile.revision,
|
|
335
|
+
...(update.action === 'reconfirm'
|
|
336
|
+
? {
|
|
337
|
+
revisions: Object.fromEntries(
|
|
338
|
+
update.kinds!.map((k) => [k, snapshot[k].revision])
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
342
|
+
legacyRevision: update.legacyRevision ?? profile.legacy?.revision
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
: {})
|
|
346
|
+
})
|
|
347
|
+
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
348
|
+
if (
|
|
349
|
+
controller.signal.aborted ||
|
|
350
|
+
getApiBase() !== base ||
|
|
351
|
+
!mounted.current
|
|
352
|
+
)
|
|
353
|
+
throw new Error('Cluster changed; reload settings.')
|
|
354
|
+
if (!response.ok)
|
|
355
|
+
throw new ApiError(
|
|
356
|
+
data.error || `Settings request failed (${response.status})`,
|
|
357
|
+
response.status
|
|
358
|
+
)
|
|
359
|
+
return data
|
|
360
|
+
} finally {
|
|
361
|
+
if (request.current === controller) request.current = null
|
|
362
|
+
if (mounted.current) {
|
|
363
|
+
setBusy(false)
|
|
364
|
+
onBusyChange?.(false)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const receive = (data: ConnectionResponse) => {
|
|
369
|
+
setSnapshot(data.profiles)
|
|
370
|
+
setCatalog(data.connections)
|
|
371
|
+
onChange(data.profiles)
|
|
372
|
+
resetDraft(data.profiles[kind])
|
|
373
|
+
}
|
|
374
|
+
const save = async (update: Update) => {
|
|
375
|
+
const data = await fetchConnections(update)
|
|
376
|
+
receive(data)
|
|
377
|
+
setConfirmBack(false)
|
|
378
|
+
setTask('main')
|
|
379
|
+
setPending(null)
|
|
380
|
+
setSelected(null)
|
|
381
|
+
setMessageRevision(data.profiles[kind].revision)
|
|
382
|
+
setMessageWarning(!!data.error)
|
|
383
|
+
setMessage(
|
|
384
|
+
data.error ? data.error : data.checked ? 'Saved · Connected' : 'Saved'
|
|
385
|
+
)
|
|
386
|
+
return data
|
|
387
|
+
}
|
|
388
|
+
const act = async (update: Update) => {
|
|
389
|
+
setError('')
|
|
390
|
+
try {
|
|
391
|
+
await save(update)
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (mounted.current) setError(e instanceof Error ? e.message : String(e))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const loadTask = async (next: Task) => {
|
|
397
|
+
setError('')
|
|
398
|
+
try {
|
|
399
|
+
const data = await fetchConnections()
|
|
400
|
+
receive(data)
|
|
401
|
+
setDraftGeneration(generation => generation + 1)
|
|
402
|
+
setPending(null)
|
|
403
|
+
setSelected(null)
|
|
404
|
+
openTask(next)
|
|
405
|
+
} catch (e) {
|
|
406
|
+
if (mounted.current) setError(e instanceof Error ? e.message : String(e))
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// The forms disable Save while applying, which blurs it before the dialog
|
|
410
|
+
// can record it as the element to return focus to.
|
|
411
|
+
const confirmOpener = useRef<HTMLElement | null>(null)
|
|
412
|
+
const confirm = (update: Update) => {
|
|
413
|
+
confirmOpener.current = document.activeElement as HTMLElement
|
|
414
|
+
setError('')
|
|
415
|
+
setPending(update)
|
|
416
|
+
}
|
|
417
|
+
const cancelConfirmation = () => {
|
|
418
|
+
confirmOpener.current?.focus()
|
|
419
|
+
setPending(null)
|
|
420
|
+
}
|
|
421
|
+
const confirmPending = async () => {
|
|
422
|
+
if (!pending) return
|
|
423
|
+
setError('')
|
|
424
|
+
try {
|
|
425
|
+
await save({ ...pending, confirmRemoval: true })
|
|
426
|
+
// Saving remounts the form, so the dialog's opener no longer exists.
|
|
427
|
+
region.current?.focus()
|
|
428
|
+
} catch (e) {
|
|
429
|
+
if (mounted.current) setError(e instanceof Error ? e.message : String(e))
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const discard = () => {
|
|
433
|
+
resetDraft(profile)
|
|
434
|
+
setDraftGeneration((generation) => generation + 1)
|
|
435
|
+
setError('')
|
|
436
|
+
setMessage('')
|
|
437
|
+
onDirtyChange(false)
|
|
438
|
+
}
|
|
439
|
+
// A conflict needs the reload control, which the form's inline error lacks.
|
|
440
|
+
const saveFromForm = async (update: Update) => {
|
|
441
|
+
setError('')
|
|
442
|
+
try {
|
|
443
|
+
return await save(update)
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (!(e instanceof ApiError) || e.status !== 409) throw e
|
|
446
|
+
if (mounted.current) setError(e.message)
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const apply = async (draft: Omit<Update, 'action'>) => {
|
|
451
|
+
const action = selected ? 'copy' : legacyDraft ? 'adopt' : task === 'replace' || discoveryDraft ? 'replace' : 'save'
|
|
452
|
+
const credentialSource = selected ?? legacyDraft ?? profile
|
|
453
|
+
if ((action === 'save' || action === 'copy' || action === 'adopt') && credentialSource.url && draft.url?.trim() === '' && !(kind === 'cost' && draft.mode === 'prometheus')) {
|
|
454
|
+
const keptHeaders = credentialSource.headerKeys.some((key) => !draft.headers?.some((header) =>
|
|
455
|
+
header.key.toLowerCase() === key.toLowerCase() && header.action === 'clear'))
|
|
456
|
+
const keptSecret = credentialSource.secretSet && (!draft.secret || draft.secret.action === 'keep') && !draft.useCliToken
|
|
457
|
+
if (keptHeaders || keptSecret) {
|
|
458
|
+
throw new Error(`Remove or replace the saved credentials, or choose ${automaticAction[kind]} to clear this connection.`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const update: Update = automaticDraft && !draft.useCliToken
|
|
462
|
+
? { action: 'auto' }
|
|
463
|
+
: { ...draft, action, ...(legacyDraft ? { legacyRevision: legacyDraft.revision } : {}), ...(selected ? {
|
|
464
|
+
binding: selected.binding,
|
|
465
|
+
sourceRevision: selected.revision,
|
|
466
|
+
} : {}) }
|
|
467
|
+
if (kind === 'cost' && draft.mode === 'prometheus') {
|
|
468
|
+
update.action = 'auto'
|
|
469
|
+
delete update.url
|
|
470
|
+
delete update.secret
|
|
471
|
+
delete update.clusterId
|
|
472
|
+
}
|
|
473
|
+
// The staged discovery notice already said what Save removes.
|
|
474
|
+
if (automaticDraft && update.action === 'auto')
|
|
475
|
+
return saveFromForm(hasSavedConfiguration ? { ...update, confirmRemoval: true } : update)
|
|
476
|
+
const removing =
|
|
477
|
+
(!!profile.url && draft.url?.trim() === '') ||
|
|
478
|
+
(hasSavedConfiguration && (action === 'replace' || action === 'copy')) ||
|
|
479
|
+
(update.action === 'auto' && hasSavedConfiguration)
|
|
480
|
+
if (removing) {
|
|
481
|
+
confirm(update)
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
return saveFromForm(update)
|
|
485
|
+
}
|
|
486
|
+
const setSecretDirty = useCallback(
|
|
487
|
+
(value: boolean) => setCredentialDirty(value),
|
|
488
|
+
[]
|
|
489
|
+
)
|
|
490
|
+
const editor =
|
|
491
|
+
task === 'replace' ||
|
|
492
|
+
(task === 'main' && (!!selected || (
|
|
493
|
+
profile.state !== 'target_changed' &&
|
|
494
|
+
profile.state !== 'error' &&
|
|
495
|
+
profile.state !== 'launch')))
|
|
496
|
+
const replacing = task === 'replace' || discoveryDraft
|
|
497
|
+
const title =
|
|
498
|
+
task === 'reconfirm'
|
|
499
|
+
? 'Do these settings still apply to this cluster?'
|
|
500
|
+
: 'Use a different connection'
|
|
501
|
+
const beginReplace = () => {
|
|
502
|
+
resetDraft({
|
|
503
|
+
...profile,
|
|
504
|
+
url: '',
|
|
505
|
+
insecureTls: false,
|
|
506
|
+
mode: 'auto',
|
|
507
|
+
clusterId: ''
|
|
508
|
+
})
|
|
509
|
+
openTask('replace')
|
|
510
|
+
}
|
|
511
|
+
const autoDiscoveryAction =
|
|
512
|
+
(task === 'main' && ['auto', 'saved'].includes(profile.state)) || task === 'replace' ? (
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
disabled={!!pending || automaticDraft || (!dirty && !hasSavedConfiguration && profile.mode === 'auto')}
|
|
516
|
+
onClick={() => {
|
|
517
|
+
resetDraft({ ...profile, url: '', mode: 'auto', insecureTls: false, clusterId: '' })
|
|
518
|
+
setDiscoveryDraft(true)
|
|
519
|
+
setDraftGeneration((generation) => generation + 1)
|
|
520
|
+
setError('')
|
|
521
|
+
// Cost's first control is its source picker, a listbox trigger.
|
|
522
|
+
requestAnimationFrame(() => region.current?.querySelector<HTMLElement>('input:not(:disabled), button[aria-haspopup="listbox"]:not(:disabled)')?.focus())
|
|
523
|
+
}}
|
|
524
|
+
className="text-xs text-accent-text hover:underline disabled:opacity-50 shrink-0"
|
|
525
|
+
>
|
|
526
|
+
{automaticAction[kind]}
|
|
527
|
+
</button>
|
|
528
|
+
) : undefined
|
|
529
|
+
const previous = task === 'main' && profile.state === 'auto' ? profile.legacy : undefined
|
|
530
|
+
const loadPrevious = (legacy: NonNullable<IntegrationProfile['legacy']>) => {
|
|
531
|
+
setSelected(null)
|
|
532
|
+
setLegacyDraft(legacy)
|
|
533
|
+
setUrl(legacy.url)
|
|
534
|
+
setInsecureTls(legacy.insecureTls)
|
|
535
|
+
setMode(legacy.mode as CostConnectionDraft['mode'])
|
|
536
|
+
setClusterId(legacy.clusterId)
|
|
537
|
+
setDiscoveryDraft(false)
|
|
538
|
+
setCredentialDirty(false)
|
|
539
|
+
setDraftGeneration(generation => generation + 1)
|
|
540
|
+
setMessage('')
|
|
541
|
+
setError('')
|
|
542
|
+
requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
|
|
543
|
+
}
|
|
544
|
+
const copySource = (source: StoredConnection) => {
|
|
545
|
+
setSelected(source)
|
|
546
|
+
setLegacyDraft(undefined)
|
|
547
|
+
setUrl(source.url)
|
|
548
|
+
setInsecureTls(source.insecureTls)
|
|
549
|
+
if (kind === 'cost') {
|
|
550
|
+
setMode('kubecost')
|
|
551
|
+
setClusterId(profile.state === 'target_changed' ? '' : profile.clusterId)
|
|
552
|
+
}
|
|
553
|
+
setDiscoveryDraft(false)
|
|
554
|
+
setCredentialDirty(false)
|
|
555
|
+
setDraftGeneration(generation => generation + 1)
|
|
556
|
+
setMessage('')
|
|
557
|
+
setError('')
|
|
558
|
+
requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
|
|
559
|
+
}
|
|
560
|
+
const reuseOptions = task === 'main' && profile.state !== 'launch' ? [
|
|
561
|
+
...(previous ? [{
|
|
562
|
+
value: previousSource,
|
|
563
|
+
label: 'Previous global settings',
|
|
564
|
+
description: previous.error
|
|
565
|
+
? `Can’t be used: ${previous.error.replace(/\.$/, '')}. Fix it in ~/.radar/config.json.`
|
|
566
|
+
: describeSource(kind, previous),
|
|
567
|
+
disabled: !!previous.error,
|
|
568
|
+
}] : []),
|
|
569
|
+
...copySources.map(connection => ({
|
|
570
|
+
value: connection.binding,
|
|
571
|
+
label: `${connection.context}${copySources.filter(other => other.context === connection.context).length > 1 ? ` · ${connection.source} · ${connection.inFileName}` : ''}`,
|
|
572
|
+
description: connection.url,
|
|
573
|
+
})),
|
|
574
|
+
] : []
|
|
575
|
+
const copyAction = task === 'main' && profile.state !== 'launch' && (reuseOptions.length > 0 || catalogError) ? (
|
|
576
|
+
<>
|
|
577
|
+
{reuseOptions.length > 0 && (
|
|
578
|
+
<SelectMenu
|
|
579
|
+
variant="text"
|
|
580
|
+
value=""
|
|
581
|
+
placeholder="Copy settings from…"
|
|
582
|
+
ariaLabel="Copy settings from…"
|
|
583
|
+
searchPlaceholder="Search clusters or URLs"
|
|
584
|
+
disabled={!!pending}
|
|
585
|
+
options={reuseOptions}
|
|
586
|
+
onChange={value => {
|
|
587
|
+
if (value === previousSource) {
|
|
588
|
+
if (previous && !previous.error) loadPrevious(previous)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
const source = copySources.find(connection => connection.binding === value)
|
|
592
|
+
if (source) copySource(source)
|
|
593
|
+
}}
|
|
594
|
+
/>
|
|
595
|
+
)}
|
|
596
|
+
{catalogError && (
|
|
597
|
+
<span role="status" className="text-xs text-theme-text-secondary">
|
|
598
|
+
Could not load other clusters.{' '}
|
|
599
|
+
<button type="button" className="text-accent-text hover:underline" onClick={() => setCatalogRetry(retry => retry + 1)}>
|
|
600
|
+
Retry
|
|
601
|
+
</button>
|
|
602
|
+
</span>
|
|
603
|
+
)}
|
|
604
|
+
</>
|
|
605
|
+
) : undefined
|
|
606
|
+
const connectionActions = (autoDiscoveryAction || copyAction) && (
|
|
607
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2">
|
|
608
|
+
{autoDiscoveryAction}
|
|
609
|
+
{copyAction}
|
|
610
|
+
</div>
|
|
611
|
+
)
|
|
612
|
+
const previousIdentity = profile.previousIdentity
|
|
613
|
+
const currentIdentity = profile.target.identity
|
|
614
|
+
const trustChanges = previousIdentity ? [
|
|
615
|
+
previousIdentity.trust !== currentIdentity.trust && 'CA trust changed',
|
|
616
|
+
previousIdentity.proxy !== currentIdentity.proxy && 'Proxy changed',
|
|
617
|
+
(previousIdentity.tlsName !== currentIdentity.tlsName ||
|
|
618
|
+
previousIdentity.insecureTls !== currentIdentity.insecureTls) && 'TLS settings changed',
|
|
619
|
+
].filter((change): change is string => !!change) : []
|
|
620
|
+
const removedByDiscovery = [
|
|
621
|
+
profile.url && (kind === 'cost' ? 'Kubecost endpoint' : 'endpoint'),
|
|
622
|
+
(profile.secretSet || profile.headerKeys.length > 0) &&
|
|
623
|
+
(kind === 'metrics' ? 'headers' : kind === 'argocd' ? 'API token' : 'API key'),
|
|
624
|
+
kind === 'cost' && profile.clusterId && 'Kubecost cluster mapping'
|
|
625
|
+
].filter((item): item is string => !!item)
|
|
626
|
+
const removedList: string | undefined = removedByDiscovery.length > 1
|
|
627
|
+
? `${removedByDiscovery.slice(0, -1).join(', ')} and ${removedByDiscovery[removedByDiscovery.length - 1]}`
|
|
628
|
+
: removedByDiscovery[0]
|
|
629
|
+
const removedPhrase = kind === 'cost'
|
|
630
|
+
? removedList ?? 'cost settings'
|
|
631
|
+
: `${names[kind]} ${removedList ?? 'settings'}`
|
|
632
|
+
const discoveryNotice = kind === 'cost'
|
|
633
|
+
? `Saving resets this cluster’s cost source to Automatic and removes its saved ${removedPhrase}. Other clusters are unchanged.`
|
|
634
|
+
: `Saving switches this cluster to auto-discovery and removes its saved ${removedPhrase}. Other clusters are unchanged.`
|
|
635
|
+
const useMetricsForCost =
|
|
636
|
+
pending?.action === 'auto' && pending.mode === 'prometheus'
|
|
637
|
+
// Paused settings have no editor, so an automatic reset there can only come
|
|
638
|
+
// from declining them.
|
|
639
|
+
const decliningPaused =
|
|
640
|
+
pending?.action === 'auto' && !useMetricsForCost && profile.state === 'target_changed'
|
|
641
|
+
const confirmationTitle = useMetricsForCost
|
|
642
|
+
? 'Use metrics for cost data?'
|
|
643
|
+
: decliningPaused
|
|
644
|
+
? kind === 'cost' ? 'Reset cost source to Automatic?' : 'Use auto-discovery instead?'
|
|
645
|
+
: 'Replace saved connection?'
|
|
646
|
+
const confirmationLabel = useMetricsForCost
|
|
647
|
+
? 'Use metrics connection'
|
|
648
|
+
: decliningPaused
|
|
649
|
+
? automaticAction[kind]
|
|
650
|
+
: 'Replace connection'
|
|
651
|
+
const confirmationMessage = useMetricsForCost
|
|
652
|
+
? `Remove the saved Kubecost connection, credentials and cluster mapping for ${removalContext || 'this cluster'} and use its metrics connection for cost data. Other clusters are unchanged.`
|
|
653
|
+
: decliningPaused
|
|
654
|
+
? `Remove the saved ${removedPhrase} for ${removalContext || 'this cluster'} and ${kind === 'cost' ? 'reset its cost source to Automatic' : 'use auto-discovery'}? Other clusters are unchanged.`
|
|
655
|
+
: `Replace the saved ${names[kind]} connection for ${removalContext || 'this cluster'} with your changes? Other clusters are unchanged.`
|
|
656
|
+
if (pending)
|
|
657
|
+
confirmationCopy.current = {
|
|
658
|
+
title: confirmationTitle,
|
|
659
|
+
message: confirmationMessage,
|
|
660
|
+
label: confirmationLabel
|
|
661
|
+
}
|
|
662
|
+
const showFeedback = discoveryDraft ? automaticDraft && hasSavedConfiguration : !!(
|
|
663
|
+
message &&
|
|
664
|
+
task === 'main' &&
|
|
665
|
+
messageRevision === profile.revision &&
|
|
666
|
+
(messageWarning || !dirty)
|
|
667
|
+
)
|
|
668
|
+
const feedback: FormFeedback | undefined = showFeedback ? {
|
|
669
|
+
tone: discoveryDraft ? 'info' : messageWarning ? 'warning' : 'success',
|
|
670
|
+
message: discoveryDraft ? discoveryNotice : message,
|
|
671
|
+
} : undefined
|
|
672
|
+
return (
|
|
673
|
+
<fieldset
|
|
674
|
+
ref={region}
|
|
675
|
+
tabIndex={-1}
|
|
676
|
+
aria-label={`${names[kind]} connection settings`}
|
|
677
|
+
disabled={busy}
|
|
678
|
+
className="min-w-0 space-y-4 outline-none"
|
|
679
|
+
onKeyDown={(event) => {
|
|
680
|
+
if (event.key === 'Escape' && task !== 'main' && !pending) {
|
|
681
|
+
event.preventDefault()
|
|
682
|
+
event.stopPropagation()
|
|
683
|
+
if (!busy) {
|
|
684
|
+
if (confirmBack) {
|
|
685
|
+
setConfirmBack(false)
|
|
686
|
+
heading.current?.focus()
|
|
687
|
+
} else back()
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}}
|
|
691
|
+
>
|
|
692
|
+
{task !== 'main' && (
|
|
693
|
+
<div className="space-y-3 border-b border-theme-border pb-3">
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
onClick={back}
|
|
697
|
+
className="flex items-center gap-1 text-xs text-accent-text"
|
|
698
|
+
>
|
|
699
|
+
<ArrowLeft className="h-3 w-3" />
|
|
700
|
+
Back to {names[kind]}
|
|
701
|
+
</button>
|
|
702
|
+
<h4
|
|
703
|
+
ref={heading}
|
|
704
|
+
tabIndex={-1}
|
|
705
|
+
className="text-base font-semibold text-theme-text-primary outline-none"
|
|
706
|
+
>
|
|
707
|
+
{title}
|
|
708
|
+
</h4>
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
{confirmBack && (
|
|
712
|
+
<div role="alert" className="card-inner-lg space-y-3">
|
|
713
|
+
<p className="text-sm">Discard your unapplied connection changes?</p>
|
|
714
|
+
<div className="flex gap-3 text-xs">
|
|
715
|
+
<button
|
|
716
|
+
type="button"
|
|
717
|
+
autoFocus
|
|
718
|
+
className="btn-brand px-3 py-2"
|
|
719
|
+
onClick={() => {
|
|
720
|
+
setConfirmBack(false)
|
|
721
|
+
heading.current?.focus()
|
|
722
|
+
}}
|
|
723
|
+
>
|
|
724
|
+
Keep editing
|
|
725
|
+
</button>
|
|
726
|
+
<button
|
|
727
|
+
type="button"
|
|
728
|
+
className="text-theme-text-secondary hover:underline"
|
|
729
|
+
onClick={leaveTask}
|
|
730
|
+
>
|
|
731
|
+
Discard draft
|
|
732
|
+
</button>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
)}
|
|
736
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
737
|
+
<p className="min-w-0 break-words text-xs text-theme-text-secondary">
|
|
738
|
+
Cluster:{' '}
|
|
739
|
+
<span className="font-medium text-theme-text-primary">
|
|
740
|
+
{profile.target.context || 'Not selected'}
|
|
741
|
+
</span>
|
|
742
|
+
</p>
|
|
743
|
+
{profile.target.source && (
|
|
744
|
+
<Tooltip content={<span className="block max-w-sm break-words">Settings belong to this kubeconfig entry: {profile.target.source} · {profile.target.inFileName}. Renaming or moving it creates a separate entry.</span>}>
|
|
745
|
+
<button type="button" aria-label="Cluster settings identity" className="text-theme-text-tertiary hover:text-theme-text-primary">
|
|
746
|
+
<Info className="h-3.5 w-3.5" />
|
|
747
|
+
</button>
|
|
748
|
+
</Tooltip>
|
|
749
|
+
)}
|
|
750
|
+
{task === 'main' && status}
|
|
751
|
+
</div>
|
|
752
|
+
{!editor && connectionActions}
|
|
753
|
+
{selected && <div className="text-xs text-theme-text-secondary space-y-1">
|
|
754
|
+
<p>Copied from <span className="font-medium">{selected.context}</span> · Unsaved</p>
|
|
755
|
+
<p>{selected.headerKeys.length > 0 || selected.secretSet ? 'Saved credentials will be copied when you save. ' : ''}Changes stay independent. Check that this backend serves this cluster.</p>
|
|
756
|
+
{selected.envHeaderKeys.length > 0 && <p>Environment-backed headers keep their references; changing those variables affects both clusters.</p>}
|
|
757
|
+
</div>}
|
|
758
|
+
{legacyDraft && <div className="text-xs text-theme-text-secondary space-y-1">
|
|
759
|
+
<p>Copied from <span className="font-medium">previous global settings</span> · Unsaved</p>
|
|
760
|
+
<p>{legacyDraft.headerKeys.length > 0 || legacyDraft.secretSet ? 'Saved credentials will be copied when you save. ' : ''}These settings applied to every cluster before; {legacyDraft.url ? 'check that this backend serves' : 'check that they belong to'} {profile.target.context}.</p>
|
|
761
|
+
{legacyDraft.omitted.length > 0 && <p>Not copied: {legacyDraft.omitted.join(' and ')}, which belonged to another cluster.</p>}
|
|
762
|
+
{legacyDraft.envHeaderKeys.length > 0 && <p>Environment-backed headers keep their references.</p>}
|
|
763
|
+
</div>}
|
|
764
|
+
{task === 'main' && (
|
|
765
|
+
<>
|
|
766
|
+
{profile.state === 'launch' ? (
|
|
767
|
+
<div className="card-inner-lg space-y-2">
|
|
768
|
+
<Badge tone="note">Set for this launch</Badge>
|
|
769
|
+
<p className="text-sm break-all">
|
|
770
|
+
{profile.url || 'Auto-discovery'}
|
|
771
|
+
</p>
|
|
772
|
+
<p className="text-xs text-theme-text-secondary">
|
|
773
|
+
This override stays with this context. Restart without its
|
|
774
|
+
startup flags or environment configuration to edit saved
|
|
775
|
+
settings.
|
|
776
|
+
</p>
|
|
777
|
+
{profile.error && (
|
|
778
|
+
<p role="alert" className="text-xs text-warning-text">
|
|
779
|
+
{profile.error}
|
|
780
|
+
</p>
|
|
781
|
+
)}
|
|
782
|
+
</div>
|
|
783
|
+
) : !selected && (profile.state === 'target_changed' ||
|
|
784
|
+
profile.state === 'error') ? (
|
|
785
|
+
<div className="card-inner-lg space-y-3">
|
|
786
|
+
<Badge severity="warning">
|
|
787
|
+
{profile.state === 'target_changed'
|
|
788
|
+
? 'Cluster changed'
|
|
789
|
+
: 'Settings unavailable'}
|
|
790
|
+
</Badge>
|
|
791
|
+
<p className="text-sm text-theme-text-secondary">
|
|
792
|
+
{profile.state === 'target_changed'
|
|
793
|
+
? `The server, certificate authority, proxy or user for this context changed, so its saved ${names[kind]} settings are paused. Review them to keep using them here, or ${kind === 'cost' ? 'reset this cluster to Automatic' : 'use auto-discovery instead'}.`
|
|
794
|
+
: profile.error}
|
|
795
|
+
</p>
|
|
796
|
+
{profile.state === 'target_changed' && (
|
|
797
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
798
|
+
<button
|
|
799
|
+
type="button"
|
|
800
|
+
className="btn-brand px-3 py-2 text-xs"
|
|
801
|
+
onClick={() => {
|
|
802
|
+
setAccepted([kind])
|
|
803
|
+
openTask('reconfirm')
|
|
804
|
+
}}
|
|
805
|
+
>
|
|
806
|
+
Review changes
|
|
807
|
+
</button>
|
|
808
|
+
<button
|
|
809
|
+
type="button"
|
|
810
|
+
className="text-xs text-accent-text hover:underline"
|
|
811
|
+
onClick={() => confirm({ action: 'auto', ...(kind === 'cost' ? { mode: 'auto' } : {}) })}
|
|
812
|
+
>
|
|
813
|
+
{kind === 'cost' ? automaticAction.cost : 'Use auto-discovery instead'}
|
|
814
|
+
</button>
|
|
815
|
+
</div>
|
|
816
|
+
)}
|
|
817
|
+
{profile.state === 'error' && !error && (
|
|
818
|
+
<button
|
|
819
|
+
type="button"
|
|
820
|
+
className="text-xs text-accent-text hover:underline"
|
|
821
|
+
onClick={() => void loadTask('main')}
|
|
822
|
+
>
|
|
823
|
+
Reload latest settings
|
|
824
|
+
</button>
|
|
825
|
+
)}
|
|
826
|
+
{hasSavedConfiguration && (
|
|
827
|
+
<button
|
|
828
|
+
type="button"
|
|
829
|
+
className="block text-xs text-accent-text"
|
|
830
|
+
onClick={beginReplace}
|
|
831
|
+
>
|
|
832
|
+
Use a different connection
|
|
833
|
+
</button>
|
|
834
|
+
)}
|
|
835
|
+
</div>
|
|
836
|
+
) : null}
|
|
837
|
+
</>
|
|
838
|
+
)}
|
|
839
|
+
<fieldset ref={editorRegion} className="min-w-0">
|
|
840
|
+
{editor &&
|
|
841
|
+
(kind === 'metrics' ? (
|
|
842
|
+
<PrometheusConnectionForm
|
|
843
|
+
key={`${profile.revision}:${task}:${draftGeneration}`}
|
|
844
|
+
local
|
|
845
|
+
value={url}
|
|
846
|
+
onChange={setUrl}
|
|
847
|
+
configuredHeaderKeys={selected ? selected.headerKeys : legacyDraft ? legacyDraft.headerKeys : replacing ? [] : profile.headerKeys}
|
|
848
|
+
environmentHeaderKeys={selected ? selected.envHeaderKeys : legacyDraft ? legacyDraft.envHeaderKeys : replacing ? [] : profile.envHeaderKeys}
|
|
849
|
+
serverManaged={false}
|
|
850
|
+
headersManaged={false}
|
|
851
|
+
urlFromFlag={false}
|
|
852
|
+
onDirtyChange={setSecretDirty}
|
|
853
|
+
dirty={dirty}
|
|
854
|
+
onDiscard={discard}
|
|
855
|
+
feedback={feedback}
|
|
856
|
+
connectionAction={connectionActions}
|
|
857
|
+
onApply={async () => {
|
|
858
|
+
throw new Error('Header operations required')
|
|
859
|
+
}}
|
|
860
|
+
onApplyOperations={async (url, headers) => {
|
|
861
|
+
const result = await apply({ url, headers })
|
|
862
|
+
return result
|
|
863
|
+
? { connected: result.connected, error: result.error }
|
|
864
|
+
: null
|
|
865
|
+
}}
|
|
866
|
+
/>
|
|
867
|
+
) : kind === 'argocd' ? (
|
|
868
|
+
<ArgoCDConnectionForm
|
|
869
|
+
key={`${profile.revision}:${task}:${draftGeneration}`}
|
|
870
|
+
dirty={dirty}
|
|
871
|
+
onDiscard={discard}
|
|
872
|
+
feedback={feedback}
|
|
873
|
+
connectionAction={connectionActions}
|
|
874
|
+
value={{ url, insecureTls }}
|
|
875
|
+
secretSet={selected ? selected.secretSet : legacyDraft ? legacyDraft.secretSet : !replacing && profile.secretSet}
|
|
876
|
+
cliSession={cliSession}
|
|
877
|
+
onChange={(draft: ArgoConnectionDraft) => {
|
|
878
|
+
setUrl(draft.url)
|
|
879
|
+
setInsecureTls(draft.insecureTls)
|
|
880
|
+
}}
|
|
881
|
+
onDirtyChange={setSecretDirty}
|
|
882
|
+
onApply={async (draft) => {
|
|
883
|
+
return !!(await apply(draft))
|
|
884
|
+
}}
|
|
885
|
+
/>
|
|
886
|
+
) : (
|
|
887
|
+
<CostConnectionForm
|
|
888
|
+
key={`${profile.revision}:${task}:${draftGeneration}`}
|
|
889
|
+
dirty={dirty}
|
|
890
|
+
onDiscard={discard}
|
|
891
|
+
feedback={feedback}
|
|
892
|
+
connectionAction={connectionActions}
|
|
893
|
+
value={{ url, mode, clusterId }}
|
|
894
|
+
secretSet={selected ? selected.secretSet : legacyDraft ? legacyDraft.secretSet : !replacing && profile.secretSet}
|
|
895
|
+
onChange={(draft) => {
|
|
896
|
+
setUrl(draft.url)
|
|
897
|
+
setMode(draft.mode)
|
|
898
|
+
setClusterId(draft.clusterId)
|
|
899
|
+
}}
|
|
900
|
+
onDirtyChange={setSecretDirty}
|
|
901
|
+
onApply={async (draft) => {
|
|
902
|
+
return !!(await apply(draft))
|
|
903
|
+
}}
|
|
904
|
+
/>
|
|
905
|
+
))}
|
|
906
|
+
</fieldset>
|
|
907
|
+
{!editor && <Collapse open={showFeedback} className="!mt-0">
|
|
908
|
+
<p role="status" className={`pt-2 text-xs ${messageWarning ? 'text-warning-text' : 'text-theme-text-secondary'}`}>{feedback?.message}</p>
|
|
909
|
+
</Collapse>}
|
|
910
|
+
<ConfirmDialog
|
|
911
|
+
open={!!pending}
|
|
912
|
+
onClose={cancelConfirmation}
|
|
913
|
+
onConfirm={() => void confirmPending()}
|
|
914
|
+
title={confirmationCopy.current.title}
|
|
915
|
+
message={confirmationCopy.current.message}
|
|
916
|
+
confirmLabel={confirmationCopy.current.label}
|
|
917
|
+
variant="warning"
|
|
918
|
+
showWarning={false}
|
|
919
|
+
isLoading={busy}
|
|
920
|
+
>
|
|
921
|
+
{error && pending ? (
|
|
922
|
+
<p
|
|
923
|
+
ref={confirmationError}
|
|
924
|
+
tabIndex={-1}
|
|
925
|
+
role="alert"
|
|
926
|
+
className="text-sm text-warning-text outline-none"
|
|
927
|
+
>
|
|
928
|
+
{error}
|
|
929
|
+
</p>
|
|
930
|
+
) : undefined}
|
|
931
|
+
</ConfirmDialog>
|
|
932
|
+
{task === 'reconfirm' && (
|
|
933
|
+
<div className="space-y-4">
|
|
934
|
+
<p className="text-sm text-theme-text-secondary">
|
|
935
|
+
The server, certificate authority, proxy or user for this context
|
|
936
|
+
changed since these settings were saved. Keep an integration only
|
|
937
|
+
if its backend serves this cluster; otherwise Radar would show
|
|
938
|
+
another cluster’s data.
|
|
939
|
+
</p>
|
|
940
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs">
|
|
941
|
+
{previousIdentity && previousIdentity.server === currentIdentity.server ? (
|
|
942
|
+
<>
|
|
943
|
+
<dt className="text-theme-text-tertiary">Server</dt>
|
|
944
|
+
<dd className="break-all">{currentIdentity.server} (unchanged)</dd>
|
|
945
|
+
</>
|
|
946
|
+
) : (
|
|
947
|
+
<>
|
|
948
|
+
<dt className="text-theme-text-tertiary">Previous server</dt>
|
|
949
|
+
<dd className="break-all">
|
|
950
|
+
{previousIdentity?.server || 'Not recorded'}
|
|
951
|
+
</dd>
|
|
952
|
+
<dt className="text-theme-text-tertiary">Current server</dt>
|
|
953
|
+
<dd className="break-all">{currentIdentity.server}</dd>
|
|
954
|
+
</>
|
|
955
|
+
)}
|
|
956
|
+
<dt className="text-theme-text-tertiary">User reference</dt>
|
|
957
|
+
<dd>
|
|
958
|
+
{previousIdentity && previousIdentity.user === currentIdentity.user
|
|
959
|
+
? `${currentIdentity.user || '—'} (unchanged)`
|
|
960
|
+
: `${previousIdentity?.user || '—'} → ${currentIdentity.user || '—'}`}
|
|
961
|
+
</dd>
|
|
962
|
+
<dt className="text-theme-text-tertiary">Trust / proxy</dt>
|
|
963
|
+
<dd>
|
|
964
|
+
{!previousIdentity
|
|
965
|
+
? 'Not recorded'
|
|
966
|
+
: trustChanges.length
|
|
967
|
+
? trustChanges.join(' · ')
|
|
968
|
+
: 'Unchanged'}
|
|
969
|
+
</dd>
|
|
970
|
+
</dl>
|
|
971
|
+
{(Object.keys(snapshot) as IntegrationKind[])
|
|
972
|
+
.filter((k) => snapshot[k].state === 'target_changed')
|
|
973
|
+
.map((k) => (
|
|
974
|
+
<label key={k} className="flex items-center gap-2 text-sm">
|
|
975
|
+
<input
|
|
976
|
+
type="checkbox"
|
|
977
|
+
checked={accepted.includes(k)}
|
|
978
|
+
onChange={(e) =>
|
|
979
|
+
setAccepted(
|
|
980
|
+
e.target.checked
|
|
981
|
+
? [...accepted, k]
|
|
982
|
+
: accepted.filter((item) => item !== k)
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
/>
|
|
986
|
+
{names[k]} · {snapshot[k].url || 'Auto-discovery'}
|
|
987
|
+
</label>
|
|
988
|
+
))}
|
|
989
|
+
<button
|
|
990
|
+
type="button"
|
|
991
|
+
disabled={!acceptedChanges.length}
|
|
992
|
+
className="btn-brand px-3 py-2 text-xs"
|
|
993
|
+
onClick={() =>
|
|
994
|
+
void act({ action: 'reconfirm', kinds: acceptedChanges })
|
|
995
|
+
}
|
|
996
|
+
>
|
|
997
|
+
Keep selected settings for this cluster
|
|
998
|
+
</button>
|
|
999
|
+
</div>
|
|
1000
|
+
)}
|
|
1001
|
+
{error && !pending && (
|
|
1002
|
+
<p role="alert" className="text-sm text-warning-text">
|
|
1003
|
+
{error}{' '}
|
|
1004
|
+
<button
|
|
1005
|
+
type="button"
|
|
1006
|
+
onClick={() => void loadTask('main')}
|
|
1007
|
+
className="underline"
|
|
1008
|
+
>
|
|
1009
|
+
{dirty ? 'Discard draft & reload' : 'Reload latest settings'}
|
|
1010
|
+
</button>
|
|
1011
|
+
</p>
|
|
1012
|
+
)}
|
|
1013
|
+
</fieldset>
|
|
1014
|
+
)
|
|
1015
|
+
}
|