@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,389 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Badge, ConfirmDialog, Disclosure } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import {
|
|
4
|
+
apiUrl,
|
|
5
|
+
getApiBase,
|
|
6
|
+
getAuthHeaders,
|
|
7
|
+
getCredentialsMode,
|
|
8
|
+
} from '../../api/config'
|
|
9
|
+
import {
|
|
10
|
+
integrationNames,
|
|
11
|
+
type ConnectionResponse,
|
|
12
|
+
type IntegrationProfiles,
|
|
13
|
+
type StoredConnection,
|
|
14
|
+
} from './LocalConnectionSettings'
|
|
15
|
+
import type { SettingsSectionId } from './settings-state'
|
|
16
|
+
|
|
17
|
+
// Dismissal reports only that the global notice flag is set; replacing the
|
|
18
|
+
// profiles from a late response could overwrite a newer context's settings.
|
|
19
|
+
export function PreviousIntegrationSettingsNotice({ profiles, onNavigate, onDismissed }: {
|
|
20
|
+
profiles: IntegrationProfiles
|
|
21
|
+
onNavigate: (section: SettingsSectionId) => void
|
|
22
|
+
onDismissed: () => void
|
|
23
|
+
}) {
|
|
24
|
+
const [busy, setBusy] = useState(false)
|
|
25
|
+
const [error, setError] = useState('')
|
|
26
|
+
const available = (['metrics', 'argocd', 'cost'] as const).filter(kind => {
|
|
27
|
+
const legacy = profiles[kind].legacy
|
|
28
|
+
return profiles[kind].state === 'auto' && legacy && !legacy.error && !legacy.noticeDismissed
|
|
29
|
+
})
|
|
30
|
+
if (available.length === 0) return null
|
|
31
|
+
const dismiss = async () => {
|
|
32
|
+
setBusy(true)
|
|
33
|
+
setError('')
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(apiUrl('/integrations/connections'), {
|
|
36
|
+
method: 'PUT',
|
|
37
|
+
credentials: getCredentialsMode(),
|
|
38
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
39
|
+
body: JSON.stringify({ action: 'dismiss_previous_notice' }),
|
|
40
|
+
})
|
|
41
|
+
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
42
|
+
if (!response.ok) throw new Error(data.error || `Settings request failed (${response.status})`)
|
|
43
|
+
onDismissed()
|
|
44
|
+
} catch (e) {
|
|
45
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
46
|
+
} finally {
|
|
47
|
+
setBusy(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<section aria-label="Previous integration settings" className="mt-3 rounded-lg border border-theme-border bg-accent-muted p-3">
|
|
52
|
+
<h4 className="text-sm font-medium text-theme-text-primary">Integration settings are now saved per cluster</h4>
|
|
53
|
+
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
54
|
+
Your previous global settings no longer apply automatically. To use them for this cluster, choose{' '}
|
|
55
|
+
<span className="font-medium">Copy settings from… → Previous global settings</span> in its tab.
|
|
56
|
+
</p>
|
|
57
|
+
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
58
|
+
{available.map(kind => (
|
|
59
|
+
<button key={kind} type="button" className="text-xs text-accent-text hover:underline"
|
|
60
|
+
onClick={() => onNavigate(kind === 'metrics' ? 'prometheus' : kind)}>
|
|
61
|
+
Review {integrationNames[kind]}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
<button type="button" disabled={busy} className="text-xs text-theme-text-secondary hover:underline disabled:opacity-50"
|
|
65
|
+
onClick={() => void dismiss()}>
|
|
66
|
+
Don’t show again
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
{error && <p role="alert" className="mt-2 text-xs text-warning-text">{error}</p>}
|
|
70
|
+
</section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const integrationSettingsByClusterId = 'integration-settings-by-cluster'
|
|
75
|
+
|
|
76
|
+
export function LocalConfigurationDetails({ onNavigate }: { onNavigate: (section: SettingsSectionId) => void }) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="mt-5 border-t border-theme-border pt-4">
|
|
79
|
+
<Disclosure
|
|
80
|
+
summary="Configuration files"
|
|
81
|
+
summaryClassName="text-sm font-medium text-theme-text-primary"
|
|
82
|
+
>
|
|
83
|
+
<div className="space-y-3 pt-2 text-xs text-theme-text-secondary">
|
|
84
|
+
<p>
|
|
85
|
+
CLI and Desktop share these files in <code>~/.radar/</code> on this
|
|
86
|
+
machine.
|
|
87
|
+
</p>
|
|
88
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
|
89
|
+
<dt className="font-mono text-theme-text-primary">config.json</dt>
|
|
90
|
+
<dd>
|
|
91
|
+
Startup defaults, such as browser, port and timeline storage.
|
|
92
|
+
Flags take precedence.
|
|
93
|
+
</dd>
|
|
94
|
+
<dt className="font-mono text-theme-text-primary">settings.json</dt>
|
|
95
|
+
<dd>Local preferences, audit settings and OCI registry sources.</dd>
|
|
96
|
+
<dt className="font-mono text-theme-text-primary">clusters.json</dt>
|
|
97
|
+
<dd>
|
|
98
|
+
Metrics, Argo CD and Cost connections and credentials, saved per
|
|
99
|
+
kubeconfig entry.{' '}
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className="text-accent-text hover:underline"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
onNavigate('connection')
|
|
105
|
+
requestAnimationFrame(() => document.getElementById(integrationSettingsByClusterId)?.focus())
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
Review integration settings by cluster
|
|
109
|
+
</button>
|
|
110
|
+
</dd>
|
|
111
|
+
</dl>
|
|
112
|
+
<p className="text-theme-text-tertiary">
|
|
113
|
+
Credentials are permission-protected plaintext, not encrypted.
|
|
114
|
+
Connection edits from another process apply on the next integration
|
|
115
|
+
operation.
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
</Disclosure>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function SavedClusterConnections({
|
|
124
|
+
active,
|
|
125
|
+
profiles,
|
|
126
|
+
onChange,
|
|
127
|
+
onBusyChange,
|
|
128
|
+
}: {
|
|
129
|
+
active: boolean
|
|
130
|
+
profiles: IntegrationProfiles
|
|
131
|
+
onChange: (profiles: IntegrationProfiles) => void
|
|
132
|
+
onBusyChange: (busy: boolean) => void
|
|
133
|
+
}) {
|
|
134
|
+
const [uses, setUses] = useState<StoredConnection[]>([])
|
|
135
|
+
const [loading, setLoading] = useState(true)
|
|
136
|
+
const [error, setError] = useState('')
|
|
137
|
+
const [version, setVersion] = useState(0)
|
|
138
|
+
const [selected, setSelected] = useState<StoredConnection | null>(null)
|
|
139
|
+
const [busy, setBusy] = useState(false)
|
|
140
|
+
const [message, setMessage] = useState('')
|
|
141
|
+
const request = useRef<AbortController | null>(null)
|
|
142
|
+
const summary = useRef<HTMLHeadingElement>(null)
|
|
143
|
+
const confirmationError = useRef<HTMLParagraphElement>(null)
|
|
144
|
+
// The removed entry's button is gone, so the dialog cannot return focus to it.
|
|
145
|
+
const focusSummary = useRef(false)
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (selected || busy || !focusSummary.current) return
|
|
149
|
+
focusSummary.current = false
|
|
150
|
+
summary.current?.focus()
|
|
151
|
+
}, [selected, busy])
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (selected && error && !busy) confirmationError.current?.focus()
|
|
155
|
+
}, [selected, error, busy])
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!active) return
|
|
159
|
+
const controller = new AbortController()
|
|
160
|
+
const base = getApiBase()
|
|
161
|
+
setLoading(true)
|
|
162
|
+
setError('')
|
|
163
|
+
void fetch(apiUrl('/integrations/connections'), {
|
|
164
|
+
signal: controller.signal,
|
|
165
|
+
credentials: getCredentialsMode(),
|
|
166
|
+
headers: getAuthHeaders(),
|
|
167
|
+
})
|
|
168
|
+
.then(async (response) => {
|
|
169
|
+
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
170
|
+
if (!response.ok)
|
|
171
|
+
throw new Error(data.error || 'Could not load saved connections.')
|
|
172
|
+
if (controller.signal.aborted || getApiBase() !== base) return
|
|
173
|
+
setUses(data.connections)
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
if (!controller.signal.aborted)
|
|
177
|
+
setError(error instanceof Error ? error.message : String(error))
|
|
178
|
+
})
|
|
179
|
+
.finally(() => {
|
|
180
|
+
if (!controller.signal.aborted) setLoading(false)
|
|
181
|
+
})
|
|
182
|
+
return () => controller.abort()
|
|
183
|
+
}, [active, version])
|
|
184
|
+
|
|
185
|
+
useEffect(
|
|
186
|
+
() => () => {
|
|
187
|
+
request.current?.abort()
|
|
188
|
+
onBusyChange(false)
|
|
189
|
+
},
|
|
190
|
+
[onBusyChange],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const remove = async () => {
|
|
194
|
+
if (!selected || request.current) return
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
request.current = controller
|
|
197
|
+
const base = getApiBase()
|
|
198
|
+
setBusy(true)
|
|
199
|
+
onBusyChange(true)
|
|
200
|
+
setError('')
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(apiUrl('/integrations/connections'), {
|
|
203
|
+
method: 'PUT',
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
credentials: getCredentialsMode(),
|
|
206
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
207
|
+
body: JSON.stringify({
|
|
208
|
+
action: 'forget',
|
|
209
|
+
kind: selected.integration,
|
|
210
|
+
binding: selected.binding,
|
|
211
|
+
sourceRevision: selected.revision,
|
|
212
|
+
confirmRemoval: true,
|
|
213
|
+
}),
|
|
214
|
+
})
|
|
215
|
+
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
216
|
+
if (controller.signal.aborted || getApiBase() !== base) return
|
|
217
|
+
if (!response.ok)
|
|
218
|
+
throw new Error(data.error || 'Could not remove the saved connection.')
|
|
219
|
+
setUses(data.connections)
|
|
220
|
+
setMessage(
|
|
221
|
+
`Removed the saved ${integrationNames[selected.integration]} connection for ${selected.context}.`,
|
|
222
|
+
)
|
|
223
|
+
focusSummary.current = true
|
|
224
|
+
setSelected(null)
|
|
225
|
+
onChange(data.profiles)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (!controller.signal.aborted)
|
|
228
|
+
setError(error instanceof Error ? error.message : String(error))
|
|
229
|
+
} finally {
|
|
230
|
+
if (!controller.signal.aborted) {
|
|
231
|
+
setBusy(false)
|
|
232
|
+
onBusyChange(false)
|
|
233
|
+
}
|
|
234
|
+
if (request.current === controller) request.current = null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const clusters = new Map<string, StoredConnection[]>()
|
|
239
|
+
for (const use of uses) {
|
|
240
|
+
const entries = clusters.get(use.binding) ?? []
|
|
241
|
+
entries.push(use)
|
|
242
|
+
clusters.set(use.binding, entries)
|
|
243
|
+
}
|
|
244
|
+
return (
|
|
245
|
+
<section
|
|
246
|
+
className="mt-5 space-y-3 border-t border-theme-border pt-4"
|
|
247
|
+
>
|
|
248
|
+
<h4
|
|
249
|
+
ref={summary}
|
|
250
|
+
id={integrationSettingsByClusterId}
|
|
251
|
+
tabIndex={-1}
|
|
252
|
+
className="text-sm font-semibold text-theme-text-primary outline-none"
|
|
253
|
+
>
|
|
254
|
+
Integration settings by cluster
|
|
255
|
+
</h4>
|
|
256
|
+
<div className="space-y-3 pt-2">
|
|
257
|
+
<p className="text-xs text-theme-text-secondary">
|
|
258
|
+
Metrics, Argo CD and Cost settings saved for each kubeconfig entry.
|
|
259
|
+
Edit the current cluster’s in its own tab. Removing an entry applies
|
|
260
|
+
immediately and changes nothing in Kubernetes or the backend.
|
|
261
|
+
</p>
|
|
262
|
+
{loading && (
|
|
263
|
+
<p role="status" className="text-xs text-theme-text-tertiary">
|
|
264
|
+
Loading saved connections…
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
{!loading && !error && clusters.size === 0 && (
|
|
268
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
269
|
+
No saved connections yet.
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
{!loading &&
|
|
273
|
+
Array.from(clusters, ([binding, entries]) => {
|
|
274
|
+
const entry = entries[0]
|
|
275
|
+
const current = Object.values(profiles).some(
|
|
276
|
+
(profile) => profile.target.binding === binding,
|
|
277
|
+
)
|
|
278
|
+
return (
|
|
279
|
+
<div key={binding} className="card-inner-lg space-y-2 text-xs">
|
|
280
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
281
|
+
<span className="font-medium text-theme-text-primary">
|
|
282
|
+
{entry.context}
|
|
283
|
+
</span>
|
|
284
|
+
{current ? (
|
|
285
|
+
<Badge tone="note" size="sm">
|
|
286
|
+
Current cluster
|
|
287
|
+
</Badge>
|
|
288
|
+
) : entry.availability === 'removed' ? (
|
|
289
|
+
<Badge tone="note" size="sm">
|
|
290
|
+
Not in kubeconfig
|
|
291
|
+
</Badge>
|
|
292
|
+
) : entry.availability === 'unavailable' ? (
|
|
293
|
+
<Badge tone="note" size="sm">
|
|
294
|
+
Kubeconfig not loaded
|
|
295
|
+
</Badge>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
<p className="break-all text-theme-text-tertiary">
|
|
299
|
+
{entry.source} · {entry.inFileName}
|
|
300
|
+
</p>
|
|
301
|
+
{!current && entry.availability === 'removed' && (
|
|
302
|
+
<p className="text-theme-text-secondary">
|
|
303
|
+
This kubeconfig entry is gone, but its connections are still
|
|
304
|
+
saved. To reuse one, select the new context and choose “Copy
|
|
305
|
+
settings from…” in that integration’s settings.
|
|
306
|
+
</p>
|
|
307
|
+
)}
|
|
308
|
+
{(current ||
|
|
309
|
+
entries.every((use) => use.availability === 'available')) && (
|
|
310
|
+
<p className="text-theme-text-secondary">
|
|
311
|
+
Saved:{' '}
|
|
312
|
+
{entries
|
|
313
|
+
.map((use) => integrationNames[use.integration])
|
|
314
|
+
.join(' · ')}
|
|
315
|
+
</p>
|
|
316
|
+
)}
|
|
317
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
318
|
+
{entries
|
|
319
|
+
.filter(
|
|
320
|
+
(use) => !current && use.availability !== 'available',
|
|
321
|
+
)
|
|
322
|
+
.map((use) => (
|
|
323
|
+
<div key={use.integration} className="flex items-center">
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
disabled={busy}
|
|
327
|
+
className="text-accent-text hover:underline disabled:opacity-50"
|
|
328
|
+
aria-label={`Remove saved ${integrationNames[use.integration]} connection for ${use.context}`}
|
|
329
|
+
onClick={() => {
|
|
330
|
+
setSelected(use)
|
|
331
|
+
setError('')
|
|
332
|
+
setMessage('')
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
Remove {integrationNames[use.integration]}
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
))}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
})}
|
|
343
|
+
{message && (
|
|
344
|
+
<p role="status" className="text-xs text-theme-text-secondary">
|
|
345
|
+
{message}
|
|
346
|
+
</p>
|
|
347
|
+
)}
|
|
348
|
+
{error && !selected && (
|
|
349
|
+
<p role="alert" className="text-xs text-warning-text">
|
|
350
|
+
{error}{' '}
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
className="underline"
|
|
354
|
+
onClick={() => setVersion((value) => value + 1)}
|
|
355
|
+
>
|
|
356
|
+
Reload saved connections
|
|
357
|
+
</button>
|
|
358
|
+
</p>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
<ConfirmDialog
|
|
362
|
+
open={!!selected}
|
|
363
|
+
onClose={() => setSelected(null)}
|
|
364
|
+
onConfirm={() => void remove()}
|
|
365
|
+
title="Remove saved connection?"
|
|
366
|
+
message={
|
|
367
|
+
selected
|
|
368
|
+
? `Remove the saved ${integrationNames[selected.integration]} connection and credentials for ${selected.context}? Other saved connections are unchanged. Nothing is deleted from Kubernetes or the backend.${selected.availability === 'unavailable' ? ' This entry may still exist in another kubeconfig; removal also affects any CLI or Desktop using that entry.' : ''}`
|
|
369
|
+
: ''
|
|
370
|
+
}
|
|
371
|
+
confirmLabel="Remove connection"
|
|
372
|
+
isLoading={busy}
|
|
373
|
+
confirmDisabled={!!error}
|
|
374
|
+
showWarning={false}
|
|
375
|
+
>
|
|
376
|
+
{error && (
|
|
377
|
+
<p
|
|
378
|
+
ref={confirmationError}
|
|
379
|
+
tabIndex={-1}
|
|
380
|
+
role="alert"
|
|
381
|
+
className="text-sm text-warning-text"
|
|
382
|
+
>
|
|
383
|
+
{error} Cancel and reload saved connections before trying again.
|
|
384
|
+
</p>
|
|
385
|
+
)}
|
|
386
|
+
</ConfirmDialog>
|
|
387
|
+
</section>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
LocalConnectionSettings,
|
|
5
|
+
type IntegrationProfile,
|
|
6
|
+
type IntegrationProfiles
|
|
7
|
+
} from './LocalConnectionSettings'
|
|
8
|
+
import { PreviousIntegrationSettingsNotice } from './LocalConfigurationDetails'
|
|
9
|
+
|
|
10
|
+
const profile: IntegrationProfile = {
|
|
11
|
+
target: {
|
|
12
|
+
binding: 'opaque',
|
|
13
|
+
context: 'development',
|
|
14
|
+
source: '/configs/team',
|
|
15
|
+
inFileName: 'dev',
|
|
16
|
+
fingerprint: 'target',
|
|
17
|
+
clientGeneration: 1,
|
|
18
|
+
operationGeneration: 1,
|
|
19
|
+
identity: {
|
|
20
|
+
server: 'https://kubernetes',
|
|
21
|
+
user: 'developer',
|
|
22
|
+
trust: 'trust',
|
|
23
|
+
insecureTls: false
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
revision: 'revision',
|
|
27
|
+
state: 'auto',
|
|
28
|
+
mode: 'auto',
|
|
29
|
+
url: '',
|
|
30
|
+
headerKeys: [],
|
|
31
|
+
envHeaderKeys: [],
|
|
32
|
+
headersManaged: false,
|
|
33
|
+
secretSet: false,
|
|
34
|
+
insecureTls: false,
|
|
35
|
+
clusterId: ''
|
|
36
|
+
}
|
|
37
|
+
const render = (
|
|
38
|
+
changes: Partial<IntegrationProfile>,
|
|
39
|
+
kind: keyof IntegrationProfiles = 'metrics'
|
|
40
|
+
) => {
|
|
41
|
+
const profiles: IntegrationProfiles = {
|
|
42
|
+
metrics: profile,
|
|
43
|
+
argocd: profile,
|
|
44
|
+
cost: profile,
|
|
45
|
+
[kind]: { ...profile, ...changes }
|
|
46
|
+
}
|
|
47
|
+
return renderToStaticMarkup(
|
|
48
|
+
<LocalConnectionSettings
|
|
49
|
+
kind={kind}
|
|
50
|
+
profiles={profiles}
|
|
51
|
+
onChange={vi.fn()}
|
|
52
|
+
onDirtyChange={vi.fn()}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
const savedProfile: Partial<IntegrationProfile> = {
|
|
57
|
+
state: 'saved',
|
|
58
|
+
url: 'https://metrics',
|
|
59
|
+
headerKeys: ['Authorization', 'X-Scope-OrgID'],
|
|
60
|
+
target: { ...profile.target, binding: 'development' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Local saved connections', () => {
|
|
64
|
+
it('only advertises previous settings for unconfigured integrations', () => {
|
|
65
|
+
const legacy = { url: 'https://previous', headerKeys: [], envHeaderKeys: [], secretSet: false, insecureTls: false, mode: 'auto', clusterId: '', revision: 'previous', omitted: [], noticeDismissed: false }
|
|
66
|
+
const profiles: IntegrationProfiles = {
|
|
67
|
+
metrics: { ...profile, legacy },
|
|
68
|
+
argocd: { ...profile, state: 'saved', legacy },
|
|
69
|
+
cost: { ...profile, state: 'launch', legacy },
|
|
70
|
+
}
|
|
71
|
+
const notice = () => renderToStaticMarkup(<PreviousIntegrationSettingsNotice profiles={profiles} onNavigate={vi.fn()} onDismissed={vi.fn()} />)
|
|
72
|
+
const html = notice()
|
|
73
|
+
expect(html).toContain('now saved per cluster')
|
|
74
|
+
expect(html).toContain('Review Metrics')
|
|
75
|
+
expect(html).not.toContain('Review Argo CD')
|
|
76
|
+
expect(html).not.toContain('Review Cost')
|
|
77
|
+
profiles.metrics.legacy = { ...legacy, noticeDismissed: true }
|
|
78
|
+
expect(notice()).toBe('')
|
|
79
|
+
delete profiles.metrics.legacy
|
|
80
|
+
expect(notice()).toBe('')
|
|
81
|
+
})
|
|
82
|
+
it.each(['metrics', 'argocd', 'cost'] as const)(
|
|
83
|
+
'does not show a routine reload action for %s',
|
|
84
|
+
(kind) => {
|
|
85
|
+
for (const state of [
|
|
86
|
+
'auto',
|
|
87
|
+
'saved',
|
|
88
|
+
'launch',
|
|
89
|
+
'target_changed'
|
|
90
|
+
] as const) {
|
|
91
|
+
expect(render({ state }, kind)).not.toContain('Reload')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
it('keeps first setup a form, with no mandatory name or assignment step', () => {
|
|
96
|
+
const html = render({})
|
|
97
|
+
expect(html).toContain('Save changes')
|
|
98
|
+
expect(html).toContain('Add header')
|
|
99
|
+
expect(html).not.toContain('Copy settings from')
|
|
100
|
+
expect(html).toContain('Cluster settings identity')
|
|
101
|
+
})
|
|
102
|
+
it('names common compatible backends without claiming an exhaustive list', () => {
|
|
103
|
+
const html = render({ headerKeys: ['Authorization', 'X-Scope-OrgID'] })
|
|
104
|
+
expect(html).toContain(
|
|
105
|
+
'such as Prometheus, VictoriaMetrics, Thanos or Grafana Mimir'
|
|
106
|
+
)
|
|
107
|
+
expect(html).toContain('Authentication headers')
|
|
108
|
+
expect(html).toContain('Authorization value')
|
|
109
|
+
expect(html).toContain('X-Scope-OrgID value')
|
|
110
|
+
expect(html).toContain('Saved value')
|
|
111
|
+
expect(html).not.toContain('Edit headers')
|
|
112
|
+
expect(html).not.toContain('<select')
|
|
113
|
+
})
|
|
114
|
+
it('offers previous settings as a copy source and never activates them by default', () => {
|
|
115
|
+
const html = render({
|
|
116
|
+
legacy: {
|
|
117
|
+
url: 'https://legacy',
|
|
118
|
+
headerKeys: ['Authorization'],
|
|
119
|
+
envHeaderKeys: [],
|
|
120
|
+
insecureTls: false,
|
|
121
|
+
mode: 'connection',
|
|
122
|
+
clusterId: '',
|
|
123
|
+
secretSet: true,
|
|
124
|
+
revision: 'legacy',
|
|
125
|
+
omitted: [],
|
|
126
|
+
noticeDismissed: true
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
expect(html).toContain('Copy settings from…')
|
|
130
|
+
expect(html).not.toContain('https://legacy')
|
|
131
|
+
})
|
|
132
|
+
it('keeps launch overrides read-only with a way to change their source', () => {
|
|
133
|
+
const html = render({
|
|
134
|
+
state: 'launch',
|
|
135
|
+
url: 'https://temporary',
|
|
136
|
+
headerKeys: ['Authorization']
|
|
137
|
+
})
|
|
138
|
+
expect(html).toContain('Set for this launch')
|
|
139
|
+
expect(html).toContain(
|
|
140
|
+
'Restart without its startup flags or environment configuration'
|
|
141
|
+
)
|
|
142
|
+
expect(html).not.toContain('Save changes')
|
|
143
|
+
})
|
|
144
|
+
it('requires review for changed cluster identity', () => {
|
|
145
|
+
const html = render({
|
|
146
|
+
state: 'target_changed',
|
|
147
|
+
url: 'https://saved',
|
|
148
|
+
error: 'Review the changed cluster connection'
|
|
149
|
+
})
|
|
150
|
+
expect(html).toContain('Review changes')
|
|
151
|
+
expect(html).not.toContain('Save changes')
|
|
152
|
+
})
|
|
153
|
+
it.each(['argocd', 'cost'] as const)(
|
|
154
|
+
'lets paused discovery credentials be replaced without accepting them for %s',
|
|
155
|
+
(kind) => {
|
|
156
|
+
const html = render(
|
|
157
|
+
{ state: 'target_changed', url: '', secretSet: true, error: 'The cluster behind this context changed' },
|
|
158
|
+
kind
|
|
159
|
+
)
|
|
160
|
+
expect(html).toContain('Review changes')
|
|
161
|
+
expect(html).toContain('Use a different connection')
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
it.each(['metrics', 'argocd', 'cost'] as const)(
|
|
165
|
+
'offers contextual recovery rather than overwriting a malformed file for %s',
|
|
166
|
+
(kind) => {
|
|
167
|
+
const html = render(
|
|
168
|
+
{ state: 'error', error: 'Repair clusters.json' },
|
|
169
|
+
kind
|
|
170
|
+
)
|
|
171
|
+
expect(html).toContain('Repair clusters.json')
|
|
172
|
+
expect(html).toContain('Reload latest settings')
|
|
173
|
+
expect(html).not.toContain('Save changes')
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
it.each(['metrics', 'argocd', 'cost'] as const)(
|
|
177
|
+
'edits only this cluster for %s',
|
|
178
|
+
(kind) => {
|
|
179
|
+
const html = render({ ...savedProfile, secretSet: true }, kind)
|
|
180
|
+
expect(html).toContain('Save changes')
|
|
181
|
+
expect(html).not.toContain('Copy settings from')
|
|
182
|
+
expect(html).not.toContain('Reload')
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
it('keeps file details and saved-entry cleanup out of integration tabs', () => {
|
|
186
|
+
const html = render(savedProfile)
|
|
187
|
+
expect(html).not.toContain('clusters.json')
|
|
188
|
+
})
|
|
189
|
+
})
|