@skyhook-io/radar-app 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  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.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +247 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +223 -0
  17. package/src/api/radarFeatures.test.ts +142 -0
  18. package/src/api/radarFeatures.ts +148 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +44 -6
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. 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
+ })