@skyhook-io/radar-app 1.9.4 → 1.9.5

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.
@@ -0,0 +1,499 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
4
+ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
5
+ import { Tooltip } from './ui/Tooltip'
6
+ import { CloudConnectFlow } from './CloudConnectFlow'
7
+ import { showApiError } from './ui/Toast'
8
+ import {
9
+ ApiError,
10
+ cloudInstallActive,
11
+ type CloudConnectSelf,
12
+ type CloudInstallBlocked,
13
+ type CloudInstallStatus,
14
+ prepareCloudInstall,
15
+ useCapabilities,
16
+ useCloudConnectInfo,
17
+ useCloudConnectSelf,
18
+ useCloudInstallStatus,
19
+ } from '../api/client'
20
+
21
+ // OSS → Cloud funnel: a quiet globe button in the top bar that opens a modal
22
+ // pitching Radar Cloud. Two lanes (capabilities.cloudConnect): "driver" runs
23
+ // the in-product connect flow against this server; "wizard" links to the Hub's
24
+ // connect wizard.
25
+ //
26
+ // The only outbound call is the Hub's own copy, fetched when the dialog opens
27
+ // (never on a poll or a timer) and falling back per-field to the constants
28
+ // below. Conversion is otherwise measured on the receiving end: utm_content
29
+ // distinguishes which lane sent the user.
30
+ const FALLBACK_APP_URL = 'https://app.radarhq.io'
31
+
32
+ // Rendered until (or unless) the Hub states its own. Keeping the compiled-in
33
+ // copy as the fallback means an unreachable Hub, a self-hosted one, or an
34
+ // offline laptop all render exactly what Radar rendered before this fetch
35
+ // existed — the dialog never waits on the network and never shows a gap.
36
+ const DEFAULT_ASSURANCES = ['Free for 3 clusters', 'No credit card', 'Your cluster data stays in your cluster']
37
+ const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
38
+ const ABOUT_URL = 'https://radarhq.io/about'
39
+ const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
40
+ const SEEN_KEY = 'radar.cloudFunnel.seen'
41
+
42
+ // localStorage access can throw (SecurityError) where storage is denied —
43
+ // sandboxed embeds, some privacy modes. This button mounts in the top bar
44
+ // outside the main error boundary, so an uncaught throw would take down the
45
+ // chrome; degrade to "not seen" / no-op persistence instead.
46
+ function readSeen(): boolean {
47
+ if (typeof window === 'undefined') return false
48
+ try {
49
+ return window.localStorage.getItem(SEEN_KEY) === 'true'
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ function markSeen() {
56
+ try {
57
+ window.localStorage.setItem(SEEN_KEY, 'true')
58
+ } catch {
59
+ // Storage denied — the ping dot reappears on next mount; harmless.
60
+ }
61
+ }
62
+
63
+ export function CloudFunnelButton() {
64
+ const [open, setOpen] = useState(false)
65
+ const [seen, setSeen] = useState(readSeen)
66
+ const [inFlowView, setInFlowView] = useState(false)
67
+ const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
68
+
69
+ const capabilities = useCapabilities()
70
+ const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
71
+ const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
72
+ // utm_content distinguishes the lane that opened the Hub — measured Hub-side
73
+ // only when the user actually navigates there; Radar transmits nothing.
74
+ const signupUrlFor = (content: string) => `${appUrl}/signup${SIGNUP_QUERY}&utm_content=${content}`
75
+ const signupUrl = signupUrlFor('funnel-cta')
76
+
77
+ // Only while the dialog is open — never on the capabilities poll. The Hub
78
+ // learns that someone opened it, which is congruent with what the dialog is
79
+ // for; it must not learn that Radar is merely running.
80
+ const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open)
81
+
82
+ // In-cluster Radar can't install its own connection, but it knows exactly
83
+ // which install it is — so the wizard link can carry the real target, and a
84
+ // GitOps-owned install can be told the imperative command isn't for it.
85
+ const inCluster = capabilities.data?.deployment?.mode === 'in-cluster'
86
+ const self = useCloudConnectSelf(open && inCluster)
87
+
88
+ // The flow is server-owned: polling here both drives the live progress view
89
+ // and re-attaches to an ongoing flow after a reload or modal close.
90
+ const flowStatus = useCloudInstallStatus(lane === 'driver')
91
+ const flow = flowStatus.data
92
+ const flowLive = cloudInstallActive(flow?.state) || flow?.state === 'connected' || flow?.state === 'failed'
93
+
94
+ const queryClient = useQueryClient()
95
+ const applyStatus = (st: CloudInstallStatus) => {
96
+ if (st.state !== 'blocked') queryClient.setQueryData(['cloud-install-status'], st)
97
+ flowStatus.invalidate()
98
+ }
99
+
100
+ const prepare = useMutation({
101
+ mutationFn: prepareCloudInstall,
102
+ onSuccess: (st) => {
103
+ if (st.state === 'blocked' && st.blocked) setBlocked(st.blocked)
104
+ else applyStatus(st)
105
+ },
106
+ onError: (err) => {
107
+ // A single-flight 409 is not a failure: its body IS the live flow (one
108
+ // started in another tab, or before this tab's status cache refreshed).
109
+ // Attach to it rather than showing an error over a running install.
110
+ const live = err instanceof ApiError && err.status === 409 ? (err.data as CloudInstallStatus | undefined) : undefined
111
+ if (live?.state) {
112
+ applyStatus(live)
113
+ return
114
+ }
115
+ // Anything else failed before a flow existed. Return to the pitch rather
116
+ // than leaving the flow view armed, where a later status change would
117
+ // pull the user into a screen they did not ask for.
118
+ exitFlow()
119
+ showApiError('Could not inspect this cluster for Cloud connect', err instanceof Error ? err.message : undefined)
120
+ },
121
+ // No meta.errorMessage: the global handler cannot tell a single-flight 409
122
+ // (a successful attach) from a real failure, and would report failure over
123
+ // a running install. Toast explicitly on the paths that are failures.
124
+ })
125
+
126
+ const openModal = () => {
127
+ setOpen(true)
128
+ setSeen(true)
129
+ markSeen()
130
+ // Re-open lands on a live flow if one is running.
131
+ if (lane === 'driver' && flowLive) setInFlowView(true)
132
+ }
133
+
134
+ const startConnect = () => {
135
+ setBlocked(null)
136
+ setInFlowView(true)
137
+ prepare.mutate()
138
+ }
139
+
140
+ const exitFlow = () => {
141
+ setInFlowView(false)
142
+ setBlocked(null)
143
+ }
144
+
145
+ // Re-attach to a server-owned flow whenever one is observed while the modal
146
+ // is open — the status query may resolve after openModal ran.
147
+ useEffect(() => {
148
+ if (open && lane === 'driver' && flowLive) setInFlowView(true)
149
+ }, [open, lane, flowLive])
150
+
151
+ // The server owns the "nothing to pitch" decision: an already-tunneled
152
+ // deployment gets no cloudConnect capability at all. Waiting for
153
+ // capabilities (rather than defaulting to visible) keeps the funnel from
154
+ // flashing at a connected cluster's operator before that answer arrives.
155
+ if (!capabilities.data?.cloudConnect) return null
156
+
157
+ const showFlow = inFlowView && (blocked !== null || prepare.isPending || flowLive)
158
+ // The prepare POST can take tens of seconds (chart download + preflight);
159
+ // until the status poll observes the server-side flow, synthesize the
160
+ // preparing state so the modal never renders empty. A blocked result gets
161
+ // the same treatment: it lives only in local state (never seeded into the
162
+ // status query), so a slow or failed first /status fetch must not drop the
163
+ // explanation back to the pitch.
164
+ const flowForView: CloudInstallStatus | undefined =
165
+ prepare.isPending && !flowLive
166
+ ? { state: 'preparing' }
167
+ : (flow ?? (blocked ? { state: 'blocked', blocked } : undefined))
168
+
169
+ return (
170
+ <>
171
+ {/* Tooltip is suppressed while the modal is open — it portals above the
172
+ modal backdrop and would otherwise paint on top of the dialog. */}
173
+ <Tooltip content="Radar Cloud — all your clusters, one URL" delay={100} position="bottom" disabled={open}>
174
+ <button
175
+ onClick={openModal}
176
+ aria-label="Radar Cloud"
177
+ aria-haspopup="dialog"
178
+ className="relative p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
179
+ >
180
+ <Globe className="w-4 h-4" />
181
+ {cloudInstallActive(flow?.state) ? (
182
+ <span className="absolute top-0.5 right-0.5 w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse motion-reduce:animate-none" />
183
+ ) : (
184
+ !seen && (
185
+ <span className="absolute top-0.5 right-0.5 w-[7px] h-[7px] rounded-full bg-emerald-500">
186
+ <span className="absolute -inset-[3px] rounded-full border border-emerald-500/70 animate-ping motion-reduce:animate-none" />
187
+ </span>
188
+ )
189
+ )}
190
+ </button>
191
+ </Tooltip>
192
+
193
+ <DialogPortal
194
+ open={open}
195
+ onClose={() => setOpen(false)}
196
+ className="w-[500px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
197
+ >
198
+ <button
199
+ onClick={() => setOpen(false)}
200
+ aria-label="Close"
201
+ className="absolute top-3.5 right-3.5 z-10 p-1.5 rounded-md text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover transition-colors"
202
+ >
203
+ <X className="w-4 h-4" />
204
+ </button>
205
+
206
+ {/* Only the body scrolls on short viewports — the close control and
207
+ the footer CTA stay pinned so they never scroll away. This matters
208
+ more with the connect flow, whose plan card is the tallest state. */}
209
+ {showFlow && flowForView ? (
210
+ <div className="min-h-0 overflow-y-auto">
211
+ <div className="px-7 pt-6">
212
+ <Eyebrow />
213
+ </div>
214
+ <CloudConnectFlow
215
+ status={flowForView}
216
+ blocked={blocked}
217
+ signupUrl={signupUrlFor('flow-escape')}
218
+ onStatus={applyStatus}
219
+ onExit={exitFlow}
220
+ />
221
+ </div>
222
+ ) : (
223
+ <>
224
+ <div className="min-h-0 overflow-y-auto">
225
+ <ModalBody />
226
+ </div>
227
+ <ModalFooter
228
+ lane={lane}
229
+ signupUrl={signupUrl}
230
+ driverEscapeUrl={signupUrlFor('driver-escape')}
231
+ assurances={connectInfo.data?.assurances}
232
+ notice={connectInfo.data?.notice}
233
+ self={inCluster ? self.data : undefined}
234
+ // Also covers the capabilities query: until it resolves, lane
235
+ // defaults to wizard and Radar does not yet know it is
236
+ // in-cluster, so the CTA would escape before classification.
237
+ selfLoading={inCluster && self.isPending}
238
+ onConnect={startConnect}
239
+ onLater={() => setOpen(false)}
240
+ />
241
+ </>
242
+ )}
243
+ </DialogPortal>
244
+ </>
245
+ )
246
+ }
247
+
248
+ function RadarSweep() {
249
+ return (
250
+ <div
251
+ aria-hidden
252
+ className="relative w-[30px] h-[30px] rounded-full overflow-hidden shrink-0 border border-emerald-400/60 shadow-[0_0_12px_rgba(16,185,129,0.35)]"
253
+ style={{ background: 'radial-gradient(circle at 50% 50%, #072920 0%, #03180f 70%, #010a06 100%)' }}
254
+ >
255
+ <div className="absolute inset-[16%] rounded-full border border-emerald-600/50" />
256
+ <div
257
+ className="absolute inset-0 rounded-full animate-[spin_4s_linear_infinite] motion-reduce:animate-none"
258
+ style={{ background: 'conic-gradient(from 0deg, rgba(167,243,208,0.85) 0deg, rgba(16,185,129,0.25) 40deg, transparent 90deg)' }}
259
+ />
260
+ </div>
261
+ )
262
+ }
263
+
264
+ function Eyebrow() {
265
+ return (
266
+ <div className="flex items-center gap-2.5 mb-3.5">
267
+ <RadarSweep />
268
+ <span className="font-mono text-[10.5px] tracking-[0.16em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ function Faces() {
274
+ return (
275
+ <div className="flex shrink-0" aria-hidden>
276
+ {[
277
+ ['R', 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'],
278
+ ['N', 'bg-sky-500/20 text-sky-700 dark:text-sky-300'],
279
+ ['E', 'bg-amber-500/20 text-amber-700 dark:text-amber-300'],
280
+ ].map(([initial, color], i) => (
281
+ <div
282
+ key={initial}
283
+ className={`w-6 h-6 text-[10px] ${color} rounded-full grid place-items-center font-bold border-2 border-theme-surface ${i > 0 ? '-ml-1.5' : ''}`}
284
+ >
285
+ {initial}
286
+ </div>
287
+ ))}
288
+ </div>
289
+ )
290
+ }
291
+
292
+ function ModalFooter({
293
+ lane,
294
+ signupUrl,
295
+ driverEscapeUrl,
296
+ assurances,
297
+ notice,
298
+ self,
299
+ selfLoading,
300
+ onConnect,
301
+ onLater,
302
+ }: {
303
+ lane: 'driver' | 'wizard'
304
+ signupUrl: string
305
+ // The driver branch's "start in the browser" link — same destination as
306
+ // signupUrl, distinct utm_content so the Hub can tell an escape from an
307
+ // in-product flow apart from a pitch CTA click.
308
+ driverEscapeUrl: string
309
+ // Live copy from the Hub; undefined until (or unless) it arrives.
310
+ assurances?: string[]
311
+ notice?: string
312
+ // Present only in-cluster: what this Radar knows about its own install.
313
+ self?: CloudConnectSelf
314
+ // True while in-cluster self-classification is still in flight.
315
+ selfLoading?: boolean
316
+ onConnect: () => void
317
+ onLater: () => void
318
+ }) {
319
+ const gitops = self?.ownership === 'gitops'
320
+ const ambiguous = self?.ownership === 'ambiguous'
321
+ // The server decides who gets a link: it withholds wizardUrl whenever the
322
+ // handoff must inspect before it acts (ambiguous ownership, or GitOps
323
+ // evidence it could not verify). A GitOps install with a link goes to the
324
+ // wizard's Argo/Flux tab, which generates a values patch for the repo rather
325
+ // than an imperative command the controller would revert.
326
+ const cliOnly = (gitops || ambiguous) && !self?.wizardUrl
327
+ // Until classification resolves we cannot know which lane applies, and a
328
+ // fast click would escape to signup before we could route this install.
329
+ const selfPending = selfLoading === true
330
+ return (
331
+ <div className="px-7 py-4 bg-theme-base border-t border-theme-border">
332
+ {self && self.ownership !== 'unknown' && (
333
+ <div className="mb-3 card-inner text-[11.5px] leading-snug text-theme-text-secondary">
334
+ {ambiguous ? (
335
+ <>
336
+ Radar found conflicting management metadata on this install, so it can't say whether a Helm
337
+ upgrade or a repository change is the right move. Run{' '}
338
+ <code className="font-mono text-[11px]">radar cloud install</code> from a machine with kubectl —
339
+ it inspects the release and refuses rather than guessing.
340
+ </>
341
+ ) : gitops ? (
342
+ <>
343
+ This Radar is managed by{' '}
344
+ <b className="text-theme-text-primary">{self.controller || 'a GitOps controller'}</b>, so
345
+ connecting it is a values change in your repository — an imperative upgrade would be reverted.{' '}
346
+ {cliOnly ? (
347
+ <>
348
+ Radar found that evidence but couldn't confirm it against the live object, so run{' '}
349
+ <code className="font-mono text-[11px]">radar cloud install</code> from a machine with
350
+ kubectl — it inspects the release before generating anything.
351
+ </>
352
+ ) : (
353
+ <>
354
+ The wizard generates the values patch for that controller, plus the one command that
355
+ creates the token Secret — the token never goes into your repository.
356
+ </>
357
+ )}
358
+ </>
359
+ ) : (
360
+ <>
361
+ Detected this install: namespace{' '}
362
+ <code className="font-mono text-[11px] text-theme-text-primary">{self.namespace}</code>, release{' '}
363
+ <code className="font-mono text-[11px] text-theme-text-primary">{self.release}</code>. The
364
+ wizard will target it directly.
365
+ </>
366
+ )}
367
+ </div>
368
+ )}
369
+ {notice && (
370
+ <div className="mb-3 card-inner text-[11.5px] leading-snug text-theme-text-secondary">{notice}</div>
371
+ )}
372
+ <div className="flex items-center gap-4">
373
+ {lane === 'driver' ? (
374
+ <>
375
+ <button
376
+ onClick={onConnect}
377
+ className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
378
+ >
379
+ Connect this cluster
380
+ </button>
381
+ <a
382
+ href={driverEscapeUrl}
383
+ target="_blank"
384
+ rel="noopener noreferrer"
385
+ className="text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
386
+ >
387
+ or start in the browser
388
+ </a>
389
+ </>
390
+ ) : cliOnly ? null : (
391
+ <a
392
+ href={self?.wizardUrl || signupUrl}
393
+ aria-disabled={selfPending}
394
+ target="_blank"
395
+ rel="noopener noreferrer"
396
+ onClick={(e) => { if (selfPending) e.preventDefault() }}
397
+ className={`px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all ${selfPending ? 'opacity-60 pointer-events-none' : ''}`}
398
+ >
399
+ {self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
400
+ </a>
401
+ )}
402
+ <button onClick={onLater} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
403
+ Maybe later
404
+ </button>
405
+ </div>
406
+ <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-theme-text-tertiary">
407
+ {(assurances?.length ? assurances : DEFAULT_ASSURANCES).map((item) => (
408
+ <span key={item} className="flex items-center gap-1">
409
+ <Check className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
410
+ {item}
411
+ </span>
412
+ ))}
413
+ </div>
414
+ <p className="mt-3.5 text-[11px] text-theme-text-tertiary">
415
+ Prefer to run the control plane in your own VPC? Self-hosting is self-serve — 30-day trial, no sales
416
+ call.{' '}
417
+ <a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
418
+ Read the docs
419
+ </a>
420
+ .
421
+ </p>
422
+ </div>
423
+ )
424
+ }
425
+
426
+ // Headline defuses the paywall fear before anything is pitched; the grid
427
+ // carries the concrete capabilities; the humans strip closes with the
428
+ // anti-sell — the credibility beat that makes the sell land.
429
+ function ModalBody() {
430
+ const features = [
431
+ {
432
+ icon: Globe,
433
+ title: 'All your clusters, one URL',
434
+ body: 'Fleet-wide issues, checks and search — instead of five browser tabs.',
435
+ },
436
+ {
437
+ icon: Users,
438
+ title: 'Bring the team',
439
+ body: "SSO, invites and roles — your cluster's RBAC still has the final say.",
440
+ },
441
+ {
442
+ icon: Bell,
443
+ title: 'Alerts that find you',
444
+ body: 'Slack or webhook the moment something breaks — even at 3am.',
445
+ },
446
+ {
447
+ icon: History,
448
+ title: 'History that sticks around',
449
+ body: 'A timeline that survives restarts — and keeps getting longer.',
450
+ },
451
+ {
452
+ icon: Sparkles,
453
+ title: 'An AI agent on your fleet',
454
+ body: 'Analyzes issues, pinpoints the root cause, and proposes the fix.',
455
+ wide: true,
456
+ },
457
+ ]
458
+ return (
459
+ <div className="px-7 pt-6 pb-1">
460
+ <Eyebrow />
461
+ <h3 className="text-[21px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-2.5 text-balance">
462
+ First things first: Radar stays free.
463
+ </h3>
464
+ <p className="text-[13.5px] leading-relaxed text-theme-text-secondary mb-4">
465
+ The app you're looking at is Apache&nbsp;2.0 — every feature, forever, no rug pulls.{' '}
466
+ <b className="text-theme-text-primary font-semibold">Radar Cloud is how we keep the lights on:</b> the
467
+ same Radar, plus the parts that are genuinely hard to run on your own.
468
+ </p>
469
+ <div className="grid grid-cols-2 gap-2 mb-4">
470
+ {features.map(({ icon: Icon, title, body, wide }) => (
471
+ <div key={title} className={`card-inner-lg flex gap-2.5 ${wide ? 'col-span-2' : ''}`}>
472
+ <Icon className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
473
+ <div>
474
+ <div className="text-[12.5px] font-semibold text-theme-text-primary">{title}</div>
475
+ <p className="mt-0.5 text-[11.5px] leading-snug text-theme-text-tertiary">{body}</p>
476
+ </div>
477
+ </div>
478
+ ))}
479
+ </div>
480
+ <div className="flex items-start gap-2.5 mb-4">
481
+ <div className="mt-0.5">
482
+ <Faces />
483
+ </div>
484
+ <div className="min-w-0">
485
+ <p className="text-[12px] leading-snug text-theme-text-secondary">
486
+ If it's just you and one cluster — honestly, stay right here. This app is the product, not a demo.
487
+ </p>
488
+ <p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
489
+ Radar is built in the open by many hands, and overseen by a small team of humans — the kind you can
490
+ actually talk to.{' '}
491
+ <a href={ABOUT_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
492
+ Meet us →
493
+ </a>
494
+ </p>
495
+ </div>
496
+ </div>
497
+ </div>
498
+ )
499
+ }
@@ -7,6 +7,7 @@ import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
9
  import { allShellSafe } from '../utils/shell-safe'
10
+ import { apiUrl } from '../api/config'
10
11
 
11
12
  interface ConnectionErrorViewProps {
12
13
  connection: ConnectionState
@@ -357,7 +358,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
357
358
  // mode — but the chained retry curl carries no session cookie, so it 401s
358
359
  // once /api/connection is auth-gated. Only chain it when auth is *known*
359
360
  // disabled (authMe still loading → don't chain a doomed call).
360
- const retryCmd = `curl -s -X POST http://${window.location.host}/api/connection/retry > /dev/null`
361
+ const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
361
362
 
362
363
  const handleAuthInTerminal = () => {
363
364
  if (!commandInfo?.authCommand) return
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { User, LogOut } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { useAuthMe } from '../api/client'
5
+ import { routePath } from '../api/config'
5
6
  import { Tooltip } from './ui/Tooltip'
6
7
  import { useQueryClient } from '@tanstack/react-query'
7
8
 
@@ -33,9 +34,9 @@ export function UserMenu({ variant = 'topbar', pinned = true }: UserMenuProps =
33
34
  }, [isOpen])
34
35
 
35
36
  const handleLogout = useCallback(async () => {
36
- let redirectTo = '/'
37
+ let redirectTo = routePath('/')
37
38
  try {
38
- const res = await fetch('/auth/logout', { credentials: 'same-origin' })
39
+ const res = await fetch(routePath('/auth/logout'), { credentials: 'same-origin' })
39
40
  const data = await res.json()
40
41
  if (data.redirectTo) {
41
42
  redirectTo = data.redirectTo
@@ -30,6 +30,7 @@ import { AgentSetupNotice } from "./AgentSetupNotice";
30
30
  import { ConsentCard } from "./parts";
31
31
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
32
32
  import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
33
+ import { routePath } from "../../api/config";
33
34
 
34
35
  function capWord(s: string): string {
35
36
  return s ? s[0].toUpperCase() + s.slice(1) : s;
@@ -66,7 +67,7 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
66
67
  const [open, setOpen] = useState(false);
67
68
  const [copied, setCopied] = useState(false);
68
69
  const label = launchAgentLabel(run);
69
- const command = buildLaunchCommand(run, `${window.location.origin}/mcp`);
70
+ const command = buildLaunchCommand(run, `${window.location.origin}${routePath('/mcp')}`);
70
71
  // No resumable session yet (or stale run) → nothing to hand off.
71
72
  if (!command) return null;
72
73
 
@@ -10,12 +10,17 @@ import { clsx } from 'clsx'
10
10
  import { formatCPUMillicores, formatMemoryMiB } from '../../utils/format'
11
11
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
12
12
  import { MCPSetupDialog } from './MCPSetupDialog'
13
- import { pluralize, parseContextName } from '@skyhook-io/k8s-ui'
13
+ import { assetUrl, pluralize, parseContextName } from '@skyhook-io/k8s-ui'
14
14
  import { Tooltip } from '../ui/Tooltip'
15
+ import { routePath } from '../../api/config'
15
16
  import gkeIcon from '../../assets/platform-icons/google_kubernetes_engine.png'
16
17
  import eksIcon from '../../assets/platform-icons/aws_eks.png'
17
18
  import aksIcon from '../../assets/platform-icons/azure-aks.svg'
18
19
 
20
+ const gkeIconUrl = assetUrl(gkeIcon)
21
+ const eksIconUrl = assetUrl(eksIcon)
22
+ const aksIconUrl = assetUrl(aksIcon)
23
+
19
24
  interface ClusterHealthCardProps {
20
25
  health: DashboardResponse['health']
21
26
  counts: DashboardResponse['resourceCounts']
@@ -74,13 +79,13 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
74
79
  function getPlatformInfo(platform: string): { name: string; icon: string | null } {
75
80
  const platformLower = platform.toLowerCase()
76
81
  if (platformLower.includes('gke') || platformLower.includes('google')) {
77
- return { name: 'Google Kubernetes Engine', icon: gkeIcon }
82
+ return { name: 'Google Kubernetes Engine', icon: gkeIconUrl }
78
83
  }
79
84
  if (platformLower.includes('eks') || platformLower.includes('amazon') || platformLower.includes('aws')) {
80
- return { name: 'Amazon EKS', icon: eksIcon }
85
+ return { name: 'Amazon EKS', icon: eksIconUrl }
81
86
  }
82
87
  if (platformLower.includes('aks') || platformLower.includes('azure')) {
83
- return { name: 'Azure Kubernetes Service', icon: aksIcon }
88
+ return { name: 'Azure Kubernetes Service', icon: aksIconUrl }
84
89
  }
85
90
  if (platformLower.includes('openshift')) {
86
91
  return { name: 'OpenShift', icon: null }
@@ -138,7 +143,7 @@ export function ClusterHealthCard({
138
143
  const mcpEnabled = caps.mcpEnabled
139
144
  const isCloud = deployment.mode === 'cloud'
140
145
  const isInCluster = deployment.mode === 'in-cluster' || deployment.mode === 'cloud'
141
- const mcpUrl = `${window.location.origin}/mcp`
146
+ const mcpUrl = `${window.location.origin}${routePath('/mcp')}`
142
147
  // In Cloud, MCP is org-wide and PAT-authed (api.radarhq.io/mcp). The OSS
143
148
  // "this binary is your local MCP server" framing is wrong there — Cloud
144
149
  // surfaces MCP from the hub Home dashboard instead.
@@ -20,6 +20,9 @@ import {
20
20
  import { clsx } from "clsx";
21
21
  import type { MainView } from "../../types";
22
22
  import { Tooltip } from "../ui/Tooltip";
23
+ import { assetUrl } from "@skyhook-io/k8s-ui";
24
+
25
+ const radarLogoUrl = assetUrl("/images/radar/radar-icon.svg");
23
26
 
24
27
  // The views the rail can navigate to. Broader than k8s-ui's ExtendedMainView
25
28
  // (which omits 'applications') — it mirrors the navigable subset of App.tsx's
@@ -233,7 +236,7 @@ function BrandRow({
233
236
  <span className="flex w-14 shrink-0 items-center justify-center">
234
237
  <span className="relative w-7 h-7 rounded-lg overflow-hidden bg-emerald-500/10 border border-emerald-500/20">
235
238
  <img
236
- src="/images/radar/radar-icon.svg"
239
+ src={radarLogoUrl}
237
240
  alt=""
238
241
  aria-hidden
239
242
  className="w-full h-full p-0.5"
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, Folder, File, Link2, ChevronRight, ChevronDown, AlertTriangle, Loader2, Search, Download, HardDrive, Shield, ShieldCheck, Terminal, Copy, Check, RefreshCw } from 'lucide-react'
4
4
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
5
+ import { assetUrl } from '@skyhook-io/k8s-ui'
5
6
  import { clsx } from 'clsx'
6
7
  import { useImageMetadata, ApiError } from '../../api/client'
7
8
  import type { FileNode, ImageFilesystem } from '../../types'
@@ -11,6 +12,8 @@ import { Tooltip } from '../ui/Tooltip'
11
12
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
12
13
  import { Input } from '@skyhook-io/k8s-ui'
13
14
 
15
+ const radarLoadingIconUrl = assetUrl(radarLoadingIcon)
16
+
14
17
  // Manual fetch function for filesystem (not a hook - gives us full control)
15
18
  async function fetchImageFilesystem(
16
19
  image: string,
@@ -182,7 +185,7 @@ export function ImageFilesystemModal({
182
185
  {/* Loading state */}
183
186
  {isLoading && (
184
187
  <div className="flex flex-col items-center justify-center gap-3 h-64">
185
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
188
+ <img src={radarLoadingIconUrl} alt="" aria-hidden className="w-11 h-11" />
186
189
  <span className="text-sm text-theme-text-secondary">
187
190
  {isLoadingMetadata ? 'Checking image…' : 'Downloading image layers…'}
188
191
  </span>