@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.
@@ -0,0 +1,522 @@
1
+ import { useEffect, useId, useState, type ReactNode } from 'react'
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
4
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
5
+ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
6
+ import { Tooltip } from './ui/Tooltip'
7
+ import { CloudConnectFlow } from './CloudConnectFlow'
8
+ import { showApiError } from './ui/Toast'
9
+ import {
10
+ ApiError,
11
+ cloudInstallActive,
12
+ type CloudConnectSelf,
13
+ type CloudInstallBlocked,
14
+ type CloudInstallStatus,
15
+ prepareCloudInstall,
16
+ useCapabilities,
17
+ useCloudConnectInfo,
18
+ useCloudConnectSelf,
19
+ useCloudInstallStatus,
20
+ } from '../api/client'
21
+
22
+ // OSS → Cloud funnel: a quiet globe button in the top bar that opens a modal
23
+ // pitching Radar Cloud. Two lanes (capabilities.cloudConnect): "driver" runs
24
+ // the in-product connect flow against this server; "wizard" links to the Hub's
25
+ // connect wizard.
26
+ //
27
+ // The only outbound call is the Hub's own copy, fetched when the dialog opens
28
+ // (never on a poll or a timer) and falling back per-field to the constants
29
+ // below. Conversion is otherwise measured on the receiving end: utm_content
30
+ // distinguishes which lane sent the user.
31
+ const FALLBACK_APP_URL = 'https://app.radarhq.io'
32
+
33
+ // Rendered until (or unless) the Hub states its own. Keeping the compiled-in
34
+ // copy as the fallback means an unreachable Hub, a self-hosted one, or an
35
+ // offline laptop all render exactly what Radar rendered before this fetch
36
+ // existed — the dialog never waits on the network and never shows a gap.
37
+ const DEFAULT_ASSURANCES = ['Free for 3 clusters', 'No credit card', 'Your cluster data stays in your cluster']
38
+ // A product fact, not funnel copy — appended even when the Hub supplies its
39
+ // own assurances (deduped if the Hub starts sending it).
40
+ const SOC2_ASSURANCE = 'SOC 2 compliant'
41
+ const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
42
+ const ABOUT_URL = 'https://radarhq.io/about'
43
+ const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
44
+ const SEEN_KEY = 'radar.cloudFunnel.seen'
45
+
46
+ // localStorage access can throw (SecurityError) where storage is denied —
47
+ // sandboxed embeds, some privacy modes. This button mounts in the top bar
48
+ // outside the main error boundary, so an uncaught throw would take down the
49
+ // chrome; degrade to "not seen" / no-op persistence instead.
50
+ function readSeen(): boolean {
51
+ if (typeof window === 'undefined') return false
52
+ try {
53
+ return window.localStorage.getItem(SEEN_KEY) === 'true'
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ function markSeen() {
60
+ try {
61
+ window.localStorage.setItem(SEEN_KEY, 'true')
62
+ } catch {
63
+ // Storage denied — the ping dot reappears on next mount; harmless.
64
+ }
65
+ }
66
+
67
+ export function CloudFunnelButton() {
68
+ const [open, setOpen] = useState(false)
69
+ const [seen, setSeen] = useState(readSeen)
70
+ const [inFlowView, setInFlowView] = useState(false)
71
+ const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
72
+
73
+ const capabilities = useCapabilities()
74
+ const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
75
+ const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
76
+ // utm_content distinguishes the lane that opened the Hub — measured Hub-side
77
+ // only when the user actually navigates there; Radar transmits nothing.
78
+ const signupUrlFor = (content: string) => `${appUrl}/signup${SIGNUP_QUERY}&utm_content=${content}`
79
+ const signupUrl = signupUrlFor('funnel-cta')
80
+
81
+ // Only while the dialog is open — never on the capabilities poll. The Hub
82
+ // learns that someone opened it, which is congruent with what the dialog is
83
+ // for; it must not learn that Radar is merely running.
84
+ const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open)
85
+
86
+ // In-cluster Radar can't install its own connection, but it knows exactly
87
+ // which install it is — so the wizard link can carry the real target, and a
88
+ // GitOps-owned install can be told the imperative command isn't for it.
89
+ const inCluster = capabilities.data?.deployment?.mode === 'in-cluster'
90
+ const self = useCloudConnectSelf(open && inCluster)
91
+
92
+ // The flow is server-owned: polling here both drives the live progress view
93
+ // and re-attaches to an ongoing flow after a reload or modal close.
94
+ const flowStatus = useCloudInstallStatus(lane === 'driver')
95
+ const flow = flowStatus.data
96
+ const flowLive = cloudInstallActive(flow?.state) || flow?.state === 'connected' || flow?.state === 'failed'
97
+
98
+ const queryClient = useQueryClient()
99
+ const applyStatus = (st: CloudInstallStatus) => {
100
+ if (st.state !== 'blocked') queryClient.setQueryData(['cloud-install-status'], st)
101
+ flowStatus.invalidate()
102
+ }
103
+
104
+ const prepare = useMutation({
105
+ mutationFn: prepareCloudInstall,
106
+ onSuccess: (st) => {
107
+ if (st.state === 'blocked' && st.blocked) setBlocked(st.blocked)
108
+ else applyStatus(st)
109
+ },
110
+ onError: (err) => {
111
+ // A single-flight 409 is not a failure: its body IS the live flow (one
112
+ // started in another tab, or before this tab's status cache refreshed).
113
+ // Attach to it rather than showing an error over a running install.
114
+ const live = err instanceof ApiError && err.status === 409 ? (err.data as CloudInstallStatus | undefined) : undefined
115
+ if (live?.state) {
116
+ applyStatus(live)
117
+ return
118
+ }
119
+ // Anything else failed before a flow existed. Return to the pitch rather
120
+ // than leaving the flow view armed, where a later status change would
121
+ // pull the user into a screen they did not ask for.
122
+ exitFlow()
123
+ showApiError('Could not inspect this cluster for Cloud connect', err instanceof Error ? err.message : undefined)
124
+ },
125
+ // No meta.errorMessage: the global handler cannot tell a single-flight 409
126
+ // (a successful attach) from a real failure, and would report failure over
127
+ // a running install. Toast explicitly on the paths that are failures.
128
+ })
129
+
130
+ const openModal = () => {
131
+ setOpen(true)
132
+ setSeen(true)
133
+ markSeen()
134
+ // Re-open lands on a live flow if one is running.
135
+ if (lane === 'driver' && flowLive) setInFlowView(true)
136
+ }
137
+
138
+ const startConnect = () => {
139
+ setBlocked(null)
140
+ setInFlowView(true)
141
+ prepare.mutate()
142
+ }
143
+
144
+ const exitFlow = () => {
145
+ setInFlowView(false)
146
+ setBlocked(null)
147
+ }
148
+
149
+ // Re-attach to a server-owned flow whenever one is observed while the modal
150
+ // is open — the status query may resolve after openModal ran.
151
+ useEffect(() => {
152
+ if (open && lane === 'driver' && flowLive) setInFlowView(true)
153
+ }, [open, lane, flowLive])
154
+
155
+ // The server owns the "nothing to pitch" decision: an already-tunneled
156
+ // deployment gets no cloudConnect capability at all. Waiting for
157
+ // capabilities (rather than defaulting to visible) keeps the funnel from
158
+ // flashing at a connected cluster's operator before that answer arrives.
159
+ if (!capabilities.data?.cloudConnect) return null
160
+
161
+ const showFlow = inFlowView && (blocked !== null || prepare.isPending || flowLive)
162
+ // The prepare POST can take tens of seconds (chart download + preflight);
163
+ // until the status poll observes the server-side flow, synthesize the
164
+ // preparing state so the modal never renders empty. A blocked result gets
165
+ // the same treatment: it lives only in local state (never seeded into the
166
+ // status query), so a slow or failed first /status fetch must not drop the
167
+ // explanation back to the pitch.
168
+ const flowForView: CloudInstallStatus | undefined =
169
+ prepare.isPending && !flowLive
170
+ ? { state: 'preparing' }
171
+ : (flow ?? (blocked ? { state: 'blocked', blocked } : undefined))
172
+
173
+ return (
174
+ <>
175
+ {/* Tooltip is suppressed while the modal is open — it portals above the
176
+ modal backdrop and would otherwise paint on top of the dialog. */}
177
+ <Tooltip content="Radar Cloud — all your clusters, one URL" delay={100} position="bottom" disabled={open}>
178
+ <button
179
+ onClick={openModal}
180
+ aria-label="Radar Cloud"
181
+ aria-haspopup="dialog"
182
+ 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"
183
+ >
184
+ <Globe className="w-4 h-4" />
185
+ {cloudInstallActive(flow?.state) ? (
186
+ <span className="absolute top-0.5 right-0.5 w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse motion-reduce:animate-none" />
187
+ ) : (
188
+ !seen && (
189
+ <span className="absolute top-0.5 right-0.5 w-[7px] h-[7px] rounded-full bg-emerald-500">
190
+ <span className="absolute -inset-[3px] rounded-full border border-emerald-500/70 animate-ping motion-reduce:animate-none" />
191
+ </span>
192
+ )
193
+ )}
194
+ </button>
195
+ </Tooltip>
196
+
197
+ <DialogPortal
198
+ open={open}
199
+ onClose={() => setOpen(false)}
200
+ className="w-[580px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
201
+ >
202
+ <button
203
+ onClick={() => setOpen(false)}
204
+ aria-label="Close"
205
+ 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"
206
+ >
207
+ <X className="w-4 h-4" />
208
+ </button>
209
+
210
+ {/* Only the body scrolls on short viewports — the close control and
211
+ the footer CTA stay pinned so they never scroll away. This matters
212
+ more with the connect flow, whose plan card is the tallest state. */}
213
+ {showFlow && flowForView ? (
214
+ <div className="min-h-0 overflow-y-auto">
215
+ <div className="px-8 pt-7">
216
+ <Eyebrow />
217
+ </div>
218
+ <CloudConnectFlow
219
+ status={flowForView}
220
+ blocked={blocked}
221
+ signupUrl={signupUrlFor('flow-escape')}
222
+ onStatus={applyStatus}
223
+ onExit={exitFlow}
224
+ />
225
+ </div>
226
+ ) : (
227
+ <>
228
+ <div className="min-h-0 overflow-y-auto">
229
+ <ModalBody />
230
+ </div>
231
+ <ModalFooter
232
+ lane={lane}
233
+ signupUrl={signupUrl}
234
+ driverEscapeUrl={signupUrlFor('driver-escape')}
235
+ assurances={connectInfo.data?.assurances}
236
+ notice={connectInfo.data?.notice}
237
+ self={inCluster ? self.data : undefined}
238
+ // Also covers the capabilities query: until it resolves, lane
239
+ // defaults to wizard and Radar does not yet know it is
240
+ // in-cluster, so the CTA would escape before classification.
241
+ selfLoading={inCluster && self.isPending}
242
+ onConnect={startConnect}
243
+ onLater={() => setOpen(false)}
244
+ />
245
+ </>
246
+ )}
247
+ </DialogPortal>
248
+ </>
249
+ )
250
+ }
251
+
252
+ // Secondary copy the pitch shouldn't spend vertical space on until asked for.
253
+ function Fold({ summary, className = '', children }: { summary: string; className?: string; children: ReactNode }) {
254
+ const [open, setOpen] = useState(false)
255
+ const bodyId = useId()
256
+ return (
257
+ <div className={className}>
258
+ <button
259
+ type="button"
260
+ onClick={() => setOpen((v) => !v)}
261
+ aria-expanded={open}
262
+ aria-controls={bodyId}
263
+ className="flex items-center gap-1.5 text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
264
+ >
265
+ <CollapseChevron open={open} className="w-3 h-3" />
266
+ {summary}
267
+ </button>
268
+ <Collapse open={open}>
269
+ <p id={bodyId} className="mt-2 pl-[18px] text-[11.5px] leading-relaxed text-theme-text-tertiary">
270
+ {children}
271
+ </p>
272
+ </Collapse>
273
+ </div>
274
+ )
275
+ }
276
+
277
+ function assuranceItems(fromHub?: string[]): string[] {
278
+ const items = fromHub?.length ? fromHub : DEFAULT_ASSURANCES
279
+ if (items.some((item) => item.toLowerCase().includes('soc 2'))) return items
280
+ // Before the last item: the closer ("data stays in your cluster") is the
281
+ // longest line and wraps most naturally when it comes last.
282
+ return [...items.slice(0, -1), SOC2_ASSURANCE, items[items.length - 1]]
283
+ }
284
+
285
+ function RadarSweep() {
286
+ return (
287
+ <div
288
+ aria-hidden
289
+ 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)]"
290
+ style={{ background: 'radial-gradient(circle at 50% 50%, #072920 0%, #03180f 70%, #010a06 100%)' }}
291
+ >
292
+ <div className="absolute inset-[16%] rounded-full border border-emerald-600/50" />
293
+ <div
294
+ className="absolute inset-0 rounded-full animate-[spin_4s_linear_infinite] motion-reduce:animate-none"
295
+ style={{ background: 'conic-gradient(from 0deg, rgba(167,243,208,0.85) 0deg, rgba(16,185,129,0.25) 40deg, transparent 90deg)' }}
296
+ />
297
+ </div>
298
+ )
299
+ }
300
+
301
+ function Eyebrow() {
302
+ return (
303
+ <div className="flex items-center gap-3 mb-5">
304
+ <RadarSweep />
305
+ <span className="font-mono text-[10.5px] tracking-[0.16em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
306
+ </div>
307
+ )
308
+ }
309
+
310
+ function ModalFooter({
311
+ lane,
312
+ signupUrl,
313
+ driverEscapeUrl,
314
+ assurances,
315
+ notice,
316
+ self,
317
+ selfLoading,
318
+ onConnect,
319
+ onLater,
320
+ }: {
321
+ lane: 'driver' | 'wizard'
322
+ signupUrl: string
323
+ // The driver branch's "start in the browser" link — same destination as
324
+ // signupUrl, distinct utm_content so the Hub can tell an escape from an
325
+ // in-product flow apart from a pitch CTA click.
326
+ driverEscapeUrl: string
327
+ // Live copy from the Hub; undefined until (or unless) it arrives.
328
+ assurances?: string[]
329
+ notice?: string
330
+ // Present only in-cluster: what this Radar knows about its own install.
331
+ self?: CloudConnectSelf
332
+ // True while in-cluster self-classification is still in flight.
333
+ selfLoading?: boolean
334
+ onConnect: () => void
335
+ onLater: () => void
336
+ }) {
337
+ const gitops = self?.ownership === 'gitops'
338
+ const ambiguous = self?.ownership === 'ambiguous'
339
+ // The server decides who gets a link: it withholds wizardUrl whenever the
340
+ // handoff must inspect before it acts (ambiguous ownership, or GitOps
341
+ // evidence it could not verify). A GitOps install with a link goes to the
342
+ // wizard's Argo/Flux tab, which generates a values patch for the repo rather
343
+ // than an imperative command the controller would revert.
344
+ const cliOnly = (gitops || ambiguous) && !self?.wizardUrl
345
+ // Until classification resolves we cannot know which lane applies, and a
346
+ // fast click would escape to signup before we could route this install.
347
+ const selfPending = selfLoading === true
348
+ return (
349
+ <div className="shrink-0 px-8 py-5 bg-theme-base border-t border-theme-border">
350
+ {self && self.ownership !== 'unknown' && (
351
+ <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">
352
+ {ambiguous ? (
353
+ <>
354
+ Radar found conflicting management metadata on this install, so it can't say whether a Helm
355
+ upgrade or a repository change is the right move. Run{' '}
356
+ <code className="font-mono text-[11px]">radar cloud install</code> from a machine with kubectl —
357
+ it inspects the release and refuses rather than guessing.
358
+ </>
359
+ ) : gitops ? (
360
+ <>
361
+ This Radar is managed by{' '}
362
+ <b className="text-theme-text-primary">{self.controller || 'a GitOps controller'}</b>, so
363
+ connecting it is a values change in your repository — an imperative upgrade would be reverted.{' '}
364
+ {cliOnly ? (
365
+ <>
366
+ Radar found that evidence but couldn't confirm it against the live object, so run{' '}
367
+ <code className="font-mono text-[11px]">radar cloud install</code> from a machine with
368
+ kubectl — it inspects the release before generating anything.
369
+ </>
370
+ ) : (
371
+ <>
372
+ The wizard generates the values patch for that controller, plus the one command that
373
+ creates the token Secret — the token never goes into your repository.
374
+ </>
375
+ )}
376
+ </>
377
+ ) : (
378
+ <>
379
+ Detected this install: namespace{' '}
380
+ <code className="font-mono text-[11px] text-theme-text-primary">{self.namespace}</code>, release{' '}
381
+ <code className="font-mono text-[11px] text-theme-text-primary">{self.release}</code>. The
382
+ wizard will target it directly.
383
+ </>
384
+ )}
385
+ </div>
386
+ )}
387
+ {notice && (
388
+ <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">{notice}</div>
389
+ )}
390
+ {lane === 'driver' && (
391
+ <p className="mb-3.5 text-[12px] leading-relaxed text-theme-text-secondary">
392
+ The guided setup runs right here in the app — Radar installs the Cloud agent on this cluster, and
393
+ you approve the connection in your browser.
394
+ </p>
395
+ )}
396
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2.5">
397
+ {lane === 'driver' ? (
398
+ <>
399
+ <button
400
+ onClick={onConnect}
401
+ className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] 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"
402
+ >
403
+ Connect this cluster
404
+ </button>
405
+ <a
406
+ href={driverEscapeUrl}
407
+ target="_blank"
408
+ rel="noopener noreferrer"
409
+ className="whitespace-nowrap text-[13px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
410
+ >
411
+ or set up in the browser
412
+ </a>
413
+ </>
414
+ ) : cliOnly ? null : (
415
+ <a
416
+ href={self?.wizardUrl || signupUrl}
417
+ aria-disabled={selfPending}
418
+ target="_blank"
419
+ rel="noopener noreferrer"
420
+ onClick={(e) => { if (selfPending) e.preventDefault() }}
421
+ 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' : ''}`}
422
+ >
423
+ {self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
424
+ </a>
425
+ )}
426
+ <button onClick={onLater} className="whitespace-nowrap text-[13px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
427
+ Maybe later
428
+ </button>
429
+ </div>
430
+ <div className="mt-4 grid grid-cols-2 gap-x-5 gap-y-2 text-[11.5px] text-theme-text-tertiary">
431
+ {assuranceItems(assurances).map((item) => (
432
+ <span key={item} className="flex items-start gap-1.5">
433
+ <Check className="w-3 h-3 mt-[3px] shrink-0 text-emerald-600 dark:text-emerald-400" />
434
+ {item}
435
+ </span>
436
+ ))}
437
+ </div>
438
+ <Fold summary="Prefer to run the control plane in your own VPC?" className="mt-3.5">
439
+ Self-hosting is fully self-serve — set it up yourself, whenever you're ready.{' '}
440
+ <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">
441
+ Read the docs
442
+ </a>
443
+ .
444
+ </Fold>
445
+ </div>
446
+ )
447
+ }
448
+
449
+ // Headline defuses the paywall fear before anything is pitched; the grid
450
+ // carries the concrete capabilities; the anti-sell closes — the credibility
451
+ // beat that makes the sell land.
452
+ function ModalBody() {
453
+ const features = [
454
+ {
455
+ icon: Globe,
456
+ title: 'All your clusters, one URL',
457
+ body: 'Fleet-wide issues, checks and search — instead of five browser tabs.',
458
+ },
459
+ {
460
+ icon: Users,
461
+ title: 'Bring the team',
462
+ body: "SSO, invites and roles — your cluster's RBAC still has the final say.",
463
+ },
464
+ {
465
+ icon: Bell,
466
+ title: 'Alerts that find you',
467
+ body: 'Slack or webhook the moment something breaks — even at 3am.',
468
+ },
469
+ {
470
+ icon: History,
471
+ title: 'History that sticks around',
472
+ body: 'A timeline that survives restarts — and keeps getting longer.',
473
+ },
474
+ {
475
+ icon: Sparkles,
476
+ title: 'An AI agent on your fleet',
477
+ body: 'Analyzes issues, pinpoints the root cause, and proposes the fix.',
478
+ wide: true,
479
+ },
480
+ ]
481
+ return (
482
+ <div className="px-8 pt-7 pb-2">
483
+ <Eyebrow />
484
+ <h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3 text-balance">
485
+ First things first: Radar stays free.
486
+ </h3>
487
+ <p className="text-[14px] leading-relaxed text-theme-text-secondary mb-5">
488
+ The app you're looking at is Apache&nbsp;2.0 — every feature, forever, no rug pulls.{' '}
489
+ <b className="text-theme-text-primary font-semibold">Radar Cloud is how we keep the lights on:</b> the
490
+ same Radar, plus the parts that are genuinely hard to run on your own.
491
+ </p>
492
+ <div className="grid grid-cols-2 gap-3 mb-5">
493
+ {features.map(({ icon: Icon, title, body, wide }) => (
494
+ <div
495
+ key={title}
496
+ className={`card-inner-lg p-3.5 flex gap-3 ${
497
+ wide ? 'col-span-2 bg-emerald-500/[0.06] border-emerald-500/25 dark:bg-emerald-500/[0.08]' : ''
498
+ }`}
499
+ >
500
+ <Icon className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
501
+ <div>
502
+ <div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
503
+ <p className="mt-1 text-[12px] leading-relaxed text-theme-text-tertiary">{body}</p>
504
+ </div>
505
+ </div>
506
+ ))}
507
+ </div>
508
+ <div className="mb-5 border-l-2 border-emerald-500/40 pl-3.5">
509
+ <p className="text-[12.5px] leading-relaxed text-theme-text-secondary">
510
+ If it's just you and one cluster — honestly, stay right here. This app is the product, not a demo.
511
+ </p>
512
+ <p className="mt-2 text-[11.5px] leading-relaxed text-theme-text-tertiary">
513
+ Radar is built in the open by many hands, and overseen by a small team of humans — the kind you
514
+ can actually talk to.{' '}
515
+ <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">
516
+ Meet us →
517
+ </a>
518
+ </p>
519
+ </div>
520
+ </div>
521
+ )
522
+ }
@@ -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.
@@ -109,14 +109,16 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
109
109
  },
110
110
  {
111
111
  name: 'diagnose',
112
- desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues.',
112
+ desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues; network entry kinds (Service / Ingress / HTTPRoute / GRPCRoute / Gateway) get a path-shaped trace naming the first broken hop, with an optional one-shot reachability test.',
113
113
  params: [
114
- { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or Flux HelmRelease' },
114
+ { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, Flux HelmRelease, service, ingress, httproute, grpcroute, or gateway' },
115
115
  { arg: 'namespace', required: true, desc: 'resource namespace' },
116
116
  { arg: 'name', required: true, desc: 'resource name' },
117
- { arg: 'container', desc: 'specific container (defaults to all)' },
118
- { arg: 'tail_lines', desc: 'lines per pod/stream (default 100)' },
119
- { arg: 'since', desc: 'only logs newer than this duration' },
117
+ { arg: 'probe', desc: 'network kinds only: add active DNS/TCP/TLS/HTTP probes against the declared path (0-3s wall time)' },
118
+ { arg: 'in_cluster', desc: 'network kinds: run the probe from inside the cluster via short-lived self-destructing pods (real dataplane) - confirms a route the apiserver proxy could only reach indirectly. Creates up to 5 short-lived probe pods (one per dialed target) under your RBAC' },
119
+ { arg: 'container', desc: 'workload kinds: specific container (defaults to all)' },
120
+ { arg: 'tail_lines', desc: 'workload kinds: lines per pod/stream (default 100)' },
121
+ { arg: 'since', desc: 'workload kinds: only logs newer than this duration' },
120
122
  ],
121
123
  },
122
124
  {
@@ -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"