@skyhook-io/radar-app 1.9.4 → 1.9.6

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.
@@ -40,6 +40,25 @@ describe('stripBasename', () => {
40
40
  expect(stripBasename('/c/abcdef/resources')).toBe('/c/abcdef/resources')
41
41
  })
42
42
 
43
+ // Standalone Radar sets a basename too once --base-path is configured, so the
44
+ // session-expiry return path round-trips through this in a new shape. Store
45
+ // strips once (client.ts) and restore strips again defensively (App.tsx) before
46
+ // navigate() re-applies the basename — the net effect must be the original
47
+ // route, never a doubled one.
48
+ it('round-trips a return path under a base path without doubling', () => {
49
+ setBasename('/radar')
50
+ const stored = stripBasename('/radar/resources/pods') + '?namespaces=prod'
51
+ expect(stored).toBe('/resources/pods?namespaces=prod')
52
+ // Restore side strips a second time; already-relative values are untouched.
53
+ expect(stripBasename(stored)).toBe('/resources/pods?namespaces=prod')
54
+ })
55
+
56
+ it('leaves a nested base path return path relative', () => {
57
+ setBasename('/tools/radar')
58
+ expect(stripBasename('/tools/radar/topology')).toBe('/topology')
59
+ expect(stripBasename(stripBasename('/tools/radar/topology'))).toBe('/topology')
60
+ })
61
+
43
62
  it('inverts routePath', () => {
44
63
  setBasename('/c/abc')
45
64
  expect(stripBasename(routePath('/auth/login'))).toBe('/auth/login')
@@ -0,0 +1,605 @@
1
+ import { useRef, useState } from 'react'
2
+ import { useMutation } from '@tanstack/react-query'
3
+ import { AlertTriangle, ArrowUpRight, Check, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
+ import {
5
+ ApiError,
6
+ cancelCloudInstall,
7
+ type CloudInstallBlocked,
8
+ type CloudInstallRecoveryGuidance,
9
+ type CloudInstallStatus,
10
+ dismissCloudInstall,
11
+ startCloudInstall,
12
+ } from '../api/client'
13
+ import { showApiError } from './ui/Toast'
14
+
15
+ // The driver-lane connect flow rendered inside the Cloud funnel modal. The
16
+ // flow itself is server-owned (it survives modal close and page reloads);
17
+ // this component only renders the observed status and issues transitions.
18
+ export function CloudConnectFlow({
19
+ status,
20
+ blocked,
21
+ signupUrl,
22
+ onStatus,
23
+ onExit,
24
+ }: {
25
+ status: CloudInstallStatus
26
+ blocked: CloudInstallBlocked | null
27
+ signupUrl: string
28
+ // Push a mutation's status response into the shared query state.
29
+ onStatus: (st: CloudInstallStatus) => void
30
+ // Leave the flow view (back to the pitch, or close after dismiss).
31
+ onExit: () => void
32
+ }) {
33
+ if (blocked) {
34
+ return <BlockedView blocked={blocked} signupUrl={signupUrl} onExit={onExit} />
35
+ }
36
+
37
+ switch (status.state) {
38
+ case 'preparing':
39
+ return (
40
+ <div className="px-8 py-10 flex flex-col items-center gap-3 text-center">
41
+ <Loader2 className="w-5 h-5 animate-spin text-emerald-600 dark:text-emerald-400" />
42
+ <p className="text-[13px] text-theme-text-secondary">
43
+ Checking this cluster and preparing the install — this can take a moment on a slow link.
44
+ </p>
45
+ </div>
46
+ )
47
+ case 'ready':
48
+ return <PlanCard status={status} onStatus={onStatus} onExit={onExit} />
49
+ case 'starting':
50
+ case 'awaiting_approval':
51
+ return <ApprovalCard status={status} onStatus={onStatus} />
52
+ case 'blocked':
53
+ return null
54
+ case 'provisioning':
55
+ case 'waiting_tunnel':
56
+ return <ProgressCard status={status} onStatus={onStatus} />
57
+ case 'connected':
58
+ return <ConnectedCard status={status} onStatus={onStatus} onExit={onExit} />
59
+ case 'failed':
60
+ return <FailedCard status={status} onStatus={onStatus} onExit={onExit} />
61
+ default:
62
+ return null
63
+ }
64
+ }
65
+
66
+ function BlockedView({
67
+ blocked,
68
+ signupUrl,
69
+ onExit,
70
+ }: {
71
+ blocked: CloudInstallBlocked
72
+ signupUrl: string
73
+ onExit: () => void
74
+ }) {
75
+ const icon =
76
+ blocked.reason === 'gitops' ? (
77
+ <GitBranch className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
78
+ ) : blocked.reason === 'preflight' ? (
79
+ <ShieldAlert className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
80
+ ) : (
81
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
82
+ )
83
+ const title =
84
+ blocked.reason === 'gitops'
85
+ ? 'This install is managed by GitOps'
86
+ : blocked.reason === 'preflight'
87
+ ? 'Your Kubernetes identity can’t install this'
88
+ : 'This cluster can’t be connected from here'
89
+ return (
90
+ <div className="px-8 pt-6 pb-5">
91
+ <div className="card-inner-lg flex gap-2.5">
92
+ {icon}
93
+ <div className="min-w-0">
94
+ <div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
95
+ <p className="mt-1 text-[12px] leading-relaxed text-theme-text-secondary">{blocked.message}</p>
96
+ {blocked.blocking && blocked.blocking.length > 0 && (
97
+ <ul className="mt-2 space-y-1 text-[11.5px] text-theme-text-tertiary">
98
+ {blocked.blocking.map((line) => (
99
+ <li key={line} className="flex items-start gap-1.5">
100
+ <span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
101
+ {line}
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ )}
106
+ </div>
107
+ </div>
108
+ <div className="mt-4 flex items-center gap-4">
109
+ {/* Only a preflight denial has a legitimate browser alternative —
110
+ someone with more Kubernetes permission can run the wizard. GitOps
111
+ and unsupported refusals named a specific reason and target that a
112
+ generic signup link cannot carry, so offering it would contradict
113
+ the message directly above. */}
114
+ {blocked.reason === 'preflight' && (
115
+ <a
116
+ href={signupUrl}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ className="text-[12.5px] font-semibold text-emerald-600 dark:text-emerald-400 hover:underline underline-offset-2"
120
+ >
121
+ Connect through the browser wizard instead →
122
+ </a>
123
+ )}
124
+ <button onClick={onExit} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
125
+ Back
126
+ </button>
127
+ </div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ function PlanCard({
133
+ status,
134
+ onStatus,
135
+ onExit,
136
+ }: {
137
+ status: CloudInstallStatus
138
+ onStatus: (st: CloudInstallStatus) => void
139
+ onExit: () => void
140
+ }) {
141
+ const plan = status.plan
142
+ const [clusterName, setClusterName] = useState(plan?.defaultClusterName ?? '')
143
+ const [acceptAdoption, setAcceptAdoption] = useState(false)
144
+ const [ackUncertainty, setAckUncertainty] = useState(false)
145
+ const [ackShared, setAckShared] = useState(false)
146
+
147
+ // The approval tab is opened synchronously by the click below (popup
148
+ // blockers reject window.open from an async callback) and navigated once the
149
+ // Hub returns the URL. A blocked or closed tab degrades to "Open again".
150
+ const approvalTab = useRef<Window | null>(null)
151
+ const start = useMutation({
152
+ mutationFn: () =>
153
+ startCloudInstall({
154
+ flowId: status.flowId ?? '',
155
+ clusterName,
156
+ acceptAdoption,
157
+ acknowledgeIncompleteDiscovery: ackUncertainty,
158
+ acknowledgeSharedListener: ackShared,
159
+ }),
160
+ onSuccess: (st) => {
161
+ // Only navigate to an approval the server still considers live. A cancel
162
+ // from another tab (or a server-side failure) can land while start is in
163
+ // flight, and opening its connectUrl anyway would present an approvable
164
+ // request for a flow the user already stopped.
165
+ if (st.connectUrl && st.state === 'awaiting_approval') {
166
+ if (approvalTab.current && !approvalTab.current.closed) {
167
+ approvalTab.current.location.href = st.connectUrl
168
+ } else {
169
+ window.open(st.connectUrl, '_blank', 'noopener')
170
+ }
171
+ } else {
172
+ approvalTab.current?.close()
173
+ }
174
+ approvalTab.current = null
175
+ onStatus(st)
176
+ },
177
+ onError: () => {
178
+ approvalTab.current?.close()
179
+ approvalTab.current = null
180
+ },
181
+ meta: { errorMessage: 'Could not start the Cloud connection' },
182
+ })
183
+ const discard = useMutation({
184
+ mutationFn: () => cancelCloudInstall(status.flowId ?? ''),
185
+ onSuccess: (st) => {
186
+ onStatus(st)
187
+ onExit()
188
+ },
189
+ meta: { errorMessage: 'Could not discard the connection plan' },
190
+ })
191
+
192
+ if (!plan) return null
193
+ const adopt = plan.mode === 'adopt'
194
+ const startDisabled =
195
+ start.isPending ||
196
+ (adopt && !acceptAdoption) ||
197
+ (!!plan.uncertainty && !ackUncertainty) ||
198
+ (!!plan.sharedListener && !ackShared)
199
+
200
+ return (
201
+ <div className="px-8 pt-6 pb-5">
202
+ <h4 className="text-[15px] font-semibold text-theme-text-primary mb-3">
203
+ {adopt ? 'Adopt and connect this cluster' : 'Connect this cluster'}
204
+ </h4>
205
+ <div className="card-inner-lg space-y-1.5 text-[12px] text-theme-text-secondary">
206
+ <PlanRow label="Kubernetes context" value={plan.contextName} mono />
207
+ <PlanRow label="Target" value={`namespace ${plan.namespace} · release ${plan.release}`} mono />
208
+ {adopt ? (
209
+ <>
210
+ <PlanRow label="Action" value="Atomically upgrade and connect the existing Helm release" />
211
+ <PlanRow label="Chart" value={`${plan.currentChartVersion} → ${plan.targetChartVersion}`} mono />
212
+ <PlanRow
213
+ label="If anything fails"
214
+ value={`Helm rolls back to the pre-adoption release (revision ${plan.currentRevision})`}
215
+ />
216
+ </>
217
+ ) : (
218
+ <>
219
+ <PlanRow label="Action" value="Install a new connected Radar release" />
220
+ <PlanRow label="Chart" value={`${plan.targetChartVersion} (Radar ${plan.targetAppVersion})`} mono />
221
+ </>
222
+ )}
223
+ {plan.currentImageTag && (
224
+ <PlanRow
225
+ label="Note"
226
+ value={`Pinned image.tag "${plan.currentImageTag}" will be cleared so the chart's stable Radar runs`}
227
+ />
228
+ )}
229
+ {plan.preservedImageRepository && (
230
+ <PlanRow label="Image repository" value={`${plan.preservedImageRepository} (preserved)`} mono />
231
+ )}
232
+ </div>
233
+
234
+ {/* Advisories come from the preflight in CLI prose (long, and usually the
235
+ benign "namespace doesn't exist yet" note on a fresh install). Keep
236
+ their presence visible so nobody approves blind, but don't let the
237
+ wall of text bury the decision. */}
238
+ {plan.advisories && plan.advisories.length > 0 && (
239
+ <details className="mt-2.5">
240
+ <summary className="flex items-center gap-1.5 cursor-pointer select-none text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
241
+ <Info className="w-3.5 h-3.5 shrink-0" />
242
+ {plan.advisories.length === 1 ? '1 preflight note' : `${plan.advisories.length} preflight notes`}
243
+ </summary>
244
+ <ul className="mt-1.5 space-y-1.5 pl-5">
245
+ {plan.advisories.map((note) => (
246
+ <li key={note} className="text-[11.5px] leading-snug text-theme-text-tertiary">
247
+ {note}
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ </details>
252
+ )}
253
+
254
+ <label className="mt-3.5 block">
255
+ <span className="text-[11.5px] font-medium text-theme-text-secondary">Cluster name in Radar</span>
256
+ <input
257
+ value={clusterName}
258
+ onChange={(e) => setClusterName(e.target.value)}
259
+ maxLength={80}
260
+ className="mt-1 w-full px-2.5 py-1.5 rounded-md bg-theme-elevated border border-theme-border text-[13px] text-theme-text-primary focus:outline-none focus:border-emerald-500/60"
261
+ />
262
+ </label>
263
+
264
+ {plan.sharedListener && (
265
+ <ConsentRow checked={ackShared} onChange={setAckShared} tone="amber">
266
+ This Radar answers beyond localhost, so anyone who can open this page could connect this cluster to
267
+ their own Radar organization. Continue anyway.
268
+ </ConsentRow>
269
+ )}
270
+
271
+ {plan.uncertainty && (
272
+ <ConsentRow checked={ackUncertainty} onChange={setAckUncertainty} tone="amber">
273
+ {plan.uncertainty} Continue with this target anyway.
274
+ </ConsentRow>
275
+ )}
276
+ {adopt && (
277
+ <ConsentRow checked={acceptAdoption} onChange={setAcceptAdoption} tone="emerald">
278
+ Upgrade and connect the existing Radar installation. Sign-in and final approval happen in the
279
+ browser before anything changes.
280
+ </ConsentRow>
281
+ )}
282
+
283
+ <div className="mt-4 flex items-center gap-4">
284
+ <button
285
+ onClick={() => {
286
+ // 'noopener' in the features string makes window.open return null,
287
+ // which would lose the handle this pre-open exists to keep — sever
288
+ // opener on the handle instead.
289
+ const tab = window.open('about:blank', '_blank')
290
+ if (tab) tab.opener = null
291
+ approvalTab.current = tab
292
+ start.mutate()
293
+ }}
294
+ disabled={startDisabled}
295
+ className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed text-emerald-950 text-[13px] font-bold transition-all"
296
+ >
297
+ {start.isPending ? 'Starting…' : 'Continue in browser'}
298
+ </button>
299
+ {/* Disabled while start is in flight: cancelling there only marks the
300
+ server flow canceling, and start's own success would still land
301
+ afterwards — reopening the approval tab and overwriting the newer
302
+ status with a stale one. */}
303
+ <button
304
+ onClick={() => discard.mutate()}
305
+ disabled={start.isPending || discard.isPending}
306
+ className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
307
+ >
308
+ Cancel
309
+ </button>
310
+ </div>
311
+ {/* Radar has no Hub identity here — that is what the device flow is for —
312
+ * so this cannot assert whether an account already exists. Phrased to
313
+ * read correctly for a returning operator and a first-time one alike. */}
314
+ <p className="mt-2.5 text-[11px] text-theme-text-tertiary">
315
+ Nothing is installed yet — you'll approve this cluster in the browser, creating your account and
316
+ organization first if you don't have one. Then Radar installs the agent.
317
+ </p>
318
+ </div>
319
+ )
320
+ }
321
+
322
+ function PlanRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
323
+ return (
324
+ <div className="flex gap-2">
325
+ <span className="w-[130px] shrink-0 text-theme-text-tertiary">{label}</span>
326
+ <span className={`min-w-0 text-theme-text-primary ${mono ? 'font-mono text-[11.5px]' : ''}`}>{value}</span>
327
+ </div>
328
+ )
329
+ }
330
+
331
+ function ConsentRow({
332
+ checked,
333
+ onChange,
334
+ tone,
335
+ children,
336
+ }: {
337
+ checked: boolean
338
+ onChange: (v: boolean) => void
339
+ tone: 'emerald' | 'amber'
340
+ children: React.ReactNode
341
+ }) {
342
+ return (
343
+ <label className="mt-3 flex items-start gap-2 cursor-pointer">
344
+ <input
345
+ type="checkbox"
346
+ checked={checked}
347
+ onChange={(e) => onChange(e.target.checked)}
348
+ className={tone === 'amber' ? 'mt-0.5 accent-amber-500' : 'mt-0.5 accent-emerald-500'}
349
+ />
350
+ <span className="text-[11.5px] leading-snug text-theme-text-secondary">{children}</span>
351
+ </label>
352
+ )
353
+ }
354
+
355
+ function ApprovalCard({ status, onStatus }: { status: CloudInstallStatus; onStatus: (st: CloudInstallStatus) => void }) {
356
+ const cancel = useCancelButton(status, onStatus)
357
+ const starting = status.state === 'starting'
358
+ return (
359
+ <div className="px-8 pt-6 pb-5">
360
+ <div className="flex items-center gap-2.5 mb-3">
361
+ <Loader2 className="w-4 h-4 animate-spin text-emerald-600 dark:text-emerald-400" />
362
+ <h4 className="text-[15px] font-semibold text-theme-text-primary">
363
+ {starting ? 'Opening the approval page…' : 'Waiting for browser approval'}
364
+ </h4>
365
+ </div>
366
+ <p className="text-[12.5px] leading-relaxed text-theme-text-secondary mb-3.5">
367
+ Approve connecting <b className="text-theme-text-primary">{status.clusterName}</b> in the browser tab.
368
+ Sign-in and org setup happen there too — this screen advances automatically.
369
+ </p>
370
+ {status.connectUrl && (
371
+ <div className="card-inner flex items-center gap-2">
372
+ <span className="flex-1 min-w-0 truncate font-mono text-[11px] text-theme-text-tertiary">
373
+ {status.connectUrl}
374
+ </span>
375
+ <button
376
+ onClick={() => window.open(status.connectUrl, '_blank', 'noopener')}
377
+ className="shrink-0 flex items-center gap-1 text-[11.5px] font-semibold text-emerald-600 dark:text-emerald-400 hover:underline underline-offset-2"
378
+ >
379
+ Open again <ExternalLink className="w-3 h-3" />
380
+ </button>
381
+ </div>
382
+ )}
383
+ <div className="mt-4">{cancel}</div>
384
+ </div>
385
+ )
386
+ }
387
+
388
+ function ProgressCard({ status, onStatus }: { status: CloudInstallStatus; onStatus: (st: CloudInstallStatus) => void }) {
389
+ const provisioning = status.state === 'provisioning'
390
+ const cancel = useCancelButton(status, onStatus)
391
+ const steps: Array<{ label: string; state: 'done' | 'active' | 'todo' }> = [
392
+ { label: 'Approved in browser', state: 'done' },
393
+ { label: `Installing Radar (namespace ${status.plan?.namespace ?? 'radar'})`, state: provisioning ? 'active' : 'done' },
394
+ { label: 'Waiting for the agent to connect', state: provisioning ? 'todo' : 'active' },
395
+ ]
396
+ return (
397
+ <div className="px-8 pt-6 pb-5">
398
+ <h4 className="text-[15px] font-semibold text-theme-text-primary mb-3.5">
399
+ Connecting {status.clusterName}
400
+ </h4>
401
+ <ul className="space-y-2.5">
402
+ {steps.map((step) => (
403
+ <li key={step.label} className="flex items-center gap-2.5 text-[12.5px]">
404
+ {step.state === 'done' ? (
405
+ <Check className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
406
+ ) : step.state === 'active' ? (
407
+ <Loader2 className="w-4 h-4 animate-spin text-emerald-600 dark:text-emerald-400" />
408
+ ) : (
409
+ <span className="w-4 h-4 grid place-items-center">
410
+ <span className="w-1.5 h-1.5 rounded-full bg-theme-border" />
411
+ </span>
412
+ )}
413
+ <span className={step.state === 'todo' ? 'text-theme-text-tertiary' : 'text-theme-text-secondary'}>
414
+ {step.label}
415
+ </span>
416
+ </li>
417
+ ))}
418
+ </ul>
419
+ <div className="mt-4">
420
+ {provisioning ? (
421
+ <p className="text-[11px] text-theme-text-tertiary">
422
+ Installing — this step completes atomically and can’t be canceled midway.
423
+ </p>
424
+ ) : (
425
+ cancel
426
+ )}
427
+ </div>
428
+ </div>
429
+ )
430
+ }
431
+
432
+ function useCancelButton(status: CloudInstallStatus, onStatus: (st: CloudInstallStatus) => void) {
433
+ const cancel = useMutation({
434
+ mutationFn: () => cancelCloudInstall(status.flowId ?? ''),
435
+ onSuccess: onStatus,
436
+ meta: { errorMessage: 'Could not cancel the connection' },
437
+ })
438
+ return (
439
+ <button
440
+ onClick={() => cancel.mutate()}
441
+ disabled={cancel.isPending}
442
+ className="flex items-center gap-1 text-[12px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors disabled:opacity-50"
443
+ >
444
+ <X className="w-3.5 h-3.5" /> Cancel connection
445
+ </button>
446
+ )
447
+ }
448
+
449
+ function ConnectedCard({
450
+ status,
451
+ onStatus,
452
+ onExit,
453
+ }: {
454
+ status: CloudInstallStatus
455
+ onStatus: (st: CloudInstallStatus) => void
456
+ onExit: () => void
457
+ }) {
458
+ const dismiss = useDismiss(status, onStatus, onExit)
459
+ const connected = status.connected
460
+ if (!connected) return null
461
+ return (
462
+ <div className="px-8 pt-6 pb-5">
463
+ <div className="flex items-center gap-2.5 mb-3">
464
+ <span className="w-7 h-7 rounded-full bg-emerald-500/20 grid place-items-center">
465
+ <Check className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
466
+ </span>
467
+ <h4 className="text-[15px] font-semibold text-theme-text-primary">
468
+ {status.clusterName} is connected to Radar Cloud
469
+ </h4>
470
+ </div>
471
+ <p className="text-[12.5px] leading-relaxed text-theme-text-secondary mb-4">
472
+ The in-cluster agent is live and tunneled. This local app keeps working exactly as before — the
473
+ cluster is now also reachable for your team at one URL.
474
+ </p>
475
+ <div className="flex items-center gap-4 mb-4">
476
+ <a
477
+ href={connected.clusterUrl}
478
+ target="_blank"
479
+ rel="noopener noreferrer"
480
+ className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13px] font-bold transition-all inline-flex items-center gap-1.5"
481
+ >
482
+ Open in Radar Cloud <ArrowUpRight className="w-3.5 h-3.5" />
483
+ </a>
484
+ <button onClick={dismiss} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
485
+ Done
486
+ </button>
487
+ </div>
488
+ <p className="text-[11px] text-theme-text-tertiary mb-3">
489
+ Watch the rollout locally:{' '}
490
+ <code className="font-mono text-[10.5px] text-theme-text-secondary">{connected.trackCommand}</code>
491
+ </p>
492
+ {connected.rollback && <GuidanceDetails title="How to undo this later" guidance={connected.rollback} />}
493
+ </div>
494
+ )
495
+ }
496
+
497
+ function FailedCard({
498
+ status,
499
+ onStatus,
500
+ onExit,
501
+ }: {
502
+ status: CloudInstallStatus
503
+ onStatus: (st: CloudInstallStatus) => void
504
+ onExit: () => void
505
+ }) {
506
+ const dismiss = useDismiss(status, onStatus, onExit)
507
+ const failure = status.failure
508
+ if (!failure) return null
509
+ return (
510
+ <div className="px-8 pt-6 pb-5">
511
+ <div className="flex items-start gap-2.5 mb-3">
512
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-1 text-amber-500" />
513
+ <h4 className="text-[14px] font-semibold leading-snug text-theme-text-primary">{failure.message}</h4>
514
+ </div>
515
+ {failure.guidance && (
516
+ <GuidanceBlock
517
+ guidance={failure.guidance}
518
+ showSummary={failure.guidance.summary !== failure.message}
519
+ />
520
+ )}
521
+ <div className="mt-4 flex items-center gap-4">
522
+ <button
523
+ onClick={dismiss}
524
+ className="px-4 py-1.5 rounded-[10px] bg-theme-elevated hover:bg-theme-hover border border-theme-border text-[12.5px] font-semibold text-theme-text-primary transition-colors"
525
+ >
526
+ {failure.retrySafe ? 'Start over' : 'Close'}
527
+ </button>
528
+ </div>
529
+ </div>
530
+ )
531
+ }
532
+
533
+ function useDismiss(status: CloudInstallStatus, onStatus: (st: CloudInstallStatus) => void, onExit: () => void) {
534
+ const dismiss = useMutation({
535
+ mutationFn: () => dismissCloudInstall(status.flowId ?? ''),
536
+ onSuccess: (st) => {
537
+ onStatus(st)
538
+ onExit()
539
+ },
540
+ onError: (err) => {
541
+ // 410: the flow is already gone — another tab dismissed it, or the
542
+ // server restarted. Nothing is left to dismiss, so exiting IS the
543
+ // requested outcome; erroring would leave a card whose only button
544
+ // fails forever. Push idle into the shared status too (the same shape
545
+ // the success path receives from the server): terminal states are not
546
+ // polled, so a cached one would re-attach this dead card on reopen.
547
+ if (err instanceof ApiError && err.status === 410) {
548
+ onStatus({ state: 'idle' })
549
+ onExit()
550
+ return
551
+ }
552
+ showApiError('Could not dismiss the connection flow', err instanceof Error ? err.message : undefined)
553
+ },
554
+ // No meta.errorMessage: the global handler would also toast the 410
555
+ // already-gone case, which this mutation treats as success.
556
+ })
557
+ return () => dismiss.mutate()
558
+ }
559
+
560
+ function GuidanceBlock({
561
+ guidance,
562
+ showSummary,
563
+ }: {
564
+ guidance: CloudInstallRecoveryGuidance
565
+ // Suppressed where the surrounding UI already states the summary — the
566
+ // failure headline, or the rollback disclosure's own title.
567
+ showSummary?: boolean
568
+ }) {
569
+ return (
570
+ <div className="card-inner-lg space-y-2 text-[11.5px] leading-relaxed text-theme-text-secondary">
571
+ {showSummary && guidance.summary && (
572
+ <p className="font-semibold text-theme-text-primary">{guidance.summary}</p>
573
+ )}
574
+ {guidance.lines?.map((line) => <p key={line}>{line}</p>)}
575
+ {guidance.inspect && guidance.inspect.length > 0 && (
576
+ <pre className="p-2 rounded-md bg-theme-elevated overflow-x-auto font-mono text-[10.5px] text-theme-text-primary">
577
+ {guidance.inspect.join('\n')}
578
+ </pre>
579
+ )}
580
+ {guidance.clusterUrl && (
581
+ <a
582
+ href={guidance.clusterUrl}
583
+ target="_blank"
584
+ rel="noopener noreferrer"
585
+ className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline underline-offset-2"
586
+ >
587
+ Open in Radar Cloud <ExternalLink className="w-3 h-3" />
588
+ </a>
589
+ )}
590
+ </div>
591
+ )
592
+ }
593
+
594
+ function GuidanceDetails({ title, guidance }: { title: string; guidance: CloudInstallRecoveryGuidance }) {
595
+ return (
596
+ <details className="group">
597
+ <summary className="cursor-pointer text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors select-none">
598
+ {title}
599
+ </summary>
600
+ <div className="mt-2">
601
+ <GuidanceBlock guidance={guidance} />
602
+ </div>
603
+ </details>
604
+ )
605
+ }