@skyhook-io/radar-app 1.14.6 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -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
+ }