@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.
- package/package.json +9 -9
- package/src/App.tsx +10 -2
- package/src/api/client.authRedirect.test.ts +194 -0
- package/src/api/client.trace.test.ts +37 -0
- package/src/api/client.ts +315 -1
- package/src/api/config.test.ts +19 -0
- package/src/components/CloudConnectFlow.tsx +605 -0
- package/src/components/CloudFunnelButton.tsx +522 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/UserMenu.tsx +3 -2
- package/src/components/diagnose/DiagnoseSurface.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +10 -5
- package/src/components/home/mcpToolCatalog.ts +7 -5
- package/src/components/nav/PrimaryNavRail.tsx +4 -1
- package/src/components/resources/ImageFilesystemModal.tsx +4 -1
- package/src/components/settings/SettingsDialog.tsx +11 -4
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/workload/WorkloadView.tsx +558 -12
- package/src/main.tsx +7 -1
- package/src/vite-env.d.ts +8 -0
package/src/api/config.test.ts
CHANGED
|
@@ -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
|
+
}
|