@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.
- package/package.json +1 -1
- package/src/App.tsx +10 -2
- package/src/api/client.ts +184 -0
- package/src/api/config.test.ts +19 -0
- package/src/components/CloudConnectFlow.tsx +605 -0
- package/src/components/CloudFunnelButton.tsx +499 -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/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/main.tsx +7 -1
- package/src/vite-env.d.ts +8 -0
|
@@ -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 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}/
|
|
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:
|
|
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:
|
|
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:
|
|
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=
|
|
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={
|
|
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>
|