@skyhook-io/radar-app 1.12.2 → 1.12.3
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 +2 -2
- package/src/App.tsx +34 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +208 -33
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +21 -1
- package/src/components/ConnectionErrorView.tsx +7 -8
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +19 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +60 -1
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/settings/SettingsDialog.tsx +21 -4
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +270 -29
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- package/src/utils/version.ts +56 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useId, useState
|
|
1
|
+
import { useEffect, useId, useState } from 'react'
|
|
2
2
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
3
|
import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
|
|
4
4
|
import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
useCloudConnectInfo,
|
|
18
18
|
useCloudConnectSelf,
|
|
19
19
|
useCloudInstallStatus,
|
|
20
|
+
useClusterInfo,
|
|
20
21
|
} from '../api/client'
|
|
21
22
|
|
|
22
23
|
// OSS → Cloud funnel: a quiet globe button in the top bar that opens a modal
|
|
@@ -34,12 +35,21 @@ const FALLBACK_APP_URL = 'https://app.radarhq.io'
|
|
|
34
35
|
// copy as the fallback means an unreachable Hub, a self-hosted one, or an
|
|
35
36
|
// offline laptop all render exactly what Radar rendered before this fetch
|
|
36
37
|
// existed — the dialog never waits on the network and never shows a gap.
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
38
|
+
// Ordered by what matters most when deciding to connect a cluster: network
|
|
39
|
+
// posture, then reversibility, then attestation, then billing. Deliberately
|
|
40
|
+
// no data-locality claim — retention and alerts work because cluster data
|
|
41
|
+
// flows out through the relay, so the list describes the connection model
|
|
42
|
+
// instead. The SOC 2 line comes from the Hub's live list, which owns its
|
|
43
|
+
// wording; here it is only the offline fallback.
|
|
44
|
+
const DEFAULT_ASSURANCES = [
|
|
45
|
+
'Secure outbound-only tunnel',
|
|
46
|
+
'Disconnect and delete your data anytime',
|
|
47
|
+
'SOC 2 Type II',
|
|
48
|
+
'3 clusters free, no card required',
|
|
49
|
+
]
|
|
41
50
|
const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
|
|
42
51
|
const ABOUT_URL = 'https://radarhq.io/about'
|
|
52
|
+
const PRICING_URL = 'https://radarhq.io/pricing'
|
|
43
53
|
const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
|
|
44
54
|
const SEEN_KEY = 'radar.cloudFunnel.seen'
|
|
45
55
|
|
|
@@ -69,6 +79,12 @@ export function CloudFunnelButton() {
|
|
|
69
79
|
const [seen, setSeen] = useState(readSeen)
|
|
70
80
|
const [inFlowView, setInFlowView] = useState(false)
|
|
71
81
|
const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
|
|
82
|
+
// Set once an in-app attempt has actually failed, so the CTA reads "Try
|
|
83
|
+
// again" instead of implying a first attempt. Survives closing the modal
|
|
84
|
+
// (the failure belongs to the cluster, not the dialog session) but not a
|
|
85
|
+
// reload, and is reset below when the kubeconfig context changes — it must
|
|
86
|
+
// never describe a cluster the user has switched away from.
|
|
87
|
+
const [prepareFailed, setPrepareFailed] = useState(false)
|
|
72
88
|
|
|
73
89
|
const capabilities = useCapabilities()
|
|
74
90
|
const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
|
|
@@ -119,7 +135,7 @@ export function CloudFunnelButton() {
|
|
|
119
135
|
// Anything else failed before a flow existed. Return to the pitch rather
|
|
120
136
|
// than leaving the flow view armed, where a later status change would
|
|
121
137
|
// pull the user into a screen they did not ask for.
|
|
122
|
-
exitFlow()
|
|
138
|
+
exitFlow(true)
|
|
123
139
|
showApiError('Could not inspect this cluster for Cloud connect', err instanceof Error ? err.message : undefined)
|
|
124
140
|
},
|
|
125
141
|
// No meta.errorMessage: the global handler cannot tell a single-flight 409
|
|
@@ -137,11 +153,16 @@ export function CloudFunnelButton() {
|
|
|
137
153
|
|
|
138
154
|
const startConnect = () => {
|
|
139
155
|
setBlocked(null)
|
|
156
|
+
setPrepareFailed(false)
|
|
140
157
|
setInFlowView(true)
|
|
141
158
|
prepare.mutate()
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
|
|
161
|
+
// A flow that ended in failure returns to a pitch whose CTA must not read
|
|
162
|
+
// like a first attempt. The caller passes the outcome because dismissing
|
|
163
|
+
// already overwrote the status by this point.
|
|
164
|
+
const exitFlow = (failed = false) => {
|
|
165
|
+
if (failed) setPrepareFailed(true)
|
|
145
166
|
setInFlowView(false)
|
|
146
167
|
setBlocked(null)
|
|
147
168
|
}
|
|
@@ -152,6 +173,15 @@ export function CloudFunnelButton() {
|
|
|
152
173
|
if (open && lane === 'driver' && flowLive) setInFlowView(true)
|
|
153
174
|
}, [open, lane, flowLive])
|
|
154
175
|
|
|
176
|
+
// prepareFailed outlives the dialog but must not outlive the cluster it
|
|
177
|
+
// describes: a context switch swaps every query cache, yet this component
|
|
178
|
+
// stays mounted, so without the reset cluster A's failure would relabel the
|
|
179
|
+
// CTA for cluster B.
|
|
180
|
+
const contextName = useClusterInfo().data?.context
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
setPrepareFailed(false)
|
|
183
|
+
}, [contextName])
|
|
184
|
+
|
|
155
185
|
// The server owns the "nothing to pitch" decision: an already-tunneled
|
|
156
186
|
// deployment gets no cloudConnect capability at all. Waiting for
|
|
157
187
|
// capabilities (rather than defaulting to visible) keeps the funnel from
|
|
@@ -174,7 +204,7 @@ export function CloudFunnelButton() {
|
|
|
174
204
|
<>
|
|
175
205
|
{/* Tooltip is suppressed while the modal is open — it portals above the
|
|
176
206
|
modal backdrop and would otherwise paint on top of the dialog. */}
|
|
177
|
-
<Tooltip content="Radar Cloud
|
|
207
|
+
<Tooltip content="Radar Cloud: all your clusters, one URL" delay={100} position="bottom" disabled={open}>
|
|
178
208
|
<button
|
|
179
209
|
onClick={openModal}
|
|
180
210
|
aria-label="Radar Cloud"
|
|
@@ -220,18 +250,21 @@ export function CloudFunnelButton() {
|
|
|
220
250
|
blocked={blocked}
|
|
221
251
|
signupUrl={signupUrlFor('flow-escape')}
|
|
222
252
|
onStatus={applyStatus}
|
|
223
|
-
onExit={exitFlow}
|
|
253
|
+
onExit={() => exitFlow(flowForView.state === 'failed')}
|
|
224
254
|
/>
|
|
225
255
|
</div>
|
|
226
256
|
) : (
|
|
227
257
|
<>
|
|
228
258
|
<div className="min-h-0 overflow-y-auto">
|
|
229
|
-
<
|
|
259
|
+
<PitchBody lane={lane} freeTier={connectInfo.data?.freeTier} />
|
|
230
260
|
</div>
|
|
231
261
|
<ModalFooter
|
|
232
262
|
lane={lane}
|
|
233
263
|
signupUrl={signupUrl}
|
|
234
|
-
|
|
264
|
+
// driver-escape after a failed attempt, driver-alt before one, so
|
|
265
|
+
// the Hub can tell "prefers the browser" from "app path broke".
|
|
266
|
+
driverEscapeUrl={signupUrlFor(prepareFailed ? 'driver-escape' : 'driver-alt')}
|
|
267
|
+
prepareFailed={prepareFailed}
|
|
235
268
|
assurances={connectInfo.data?.assurances}
|
|
236
269
|
notice={connectInfo.data?.notice}
|
|
237
270
|
self={inCluster ? self.data : undefined}
|
|
@@ -249,37 +282,10 @@ export function CloudFunnelButton() {
|
|
|
249
282
|
)
|
|
250
283
|
}
|
|
251
284
|
|
|
252
|
-
//
|
|
253
|
-
|
|
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
|
-
|
|
285
|
+
// The Hub's list renders verbatim — no client-side additions, so the Hub owns
|
|
286
|
+
// the wording and can update it without a binary release.
|
|
277
287
|
function assuranceItems(fromHub?: string[]): string[] {
|
|
278
|
-
|
|
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]]
|
|
288
|
+
return fromHub?.length ? fromHub : DEFAULT_ASSURANCES
|
|
283
289
|
}
|
|
284
290
|
|
|
285
291
|
function RadarSweep() {
|
|
@@ -311,6 +317,7 @@ function ModalFooter({
|
|
|
311
317
|
lane,
|
|
312
318
|
signupUrl,
|
|
313
319
|
driverEscapeUrl,
|
|
320
|
+
prepareFailed,
|
|
314
321
|
assurances,
|
|
315
322
|
notice,
|
|
316
323
|
self,
|
|
@@ -320,10 +327,10 @@ function ModalFooter({
|
|
|
320
327
|
}: {
|
|
321
328
|
lane: 'driver' | 'wizard'
|
|
322
329
|
signupUrl: string
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// in-product flow apart from a pitch CTA click.
|
|
330
|
+
// Same destination as signupUrl, distinct utm_content: the caller encodes
|
|
331
|
+
// whether this render follows a failed in-app attempt.
|
|
326
332
|
driverEscapeUrl: string
|
|
333
|
+
prepareFailed: boolean
|
|
327
334
|
// Live copy from the Hub; undefined until (or unless) it arrives.
|
|
328
335
|
assurances?: string[]
|
|
329
336
|
notice?: string
|
|
@@ -353,24 +360,24 @@ function ModalFooter({
|
|
|
353
360
|
<>
|
|
354
361
|
Radar found conflicting management metadata on this install, so it can't say whether a Helm
|
|
355
362
|
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
|
|
357
|
-
|
|
363
|
+
<code className="font-mono text-[11px]">radar cloud install</code> from a machine with
|
|
364
|
+
kubectl. It inspects the release and refuses rather than guessing.
|
|
358
365
|
</>
|
|
359
366
|
) : gitops ? (
|
|
360
367
|
<>
|
|
361
368
|
This Radar is managed by{' '}
|
|
362
369
|
<b className="text-theme-text-primary">{self.controller || 'a GitOps controller'}</b>, so
|
|
363
|
-
connecting it is a values change in your repository
|
|
370
|
+
connecting it is a values change in your repository; an imperative upgrade would be reverted.{' '}
|
|
364
371
|
{cliOnly ? (
|
|
365
372
|
<>
|
|
366
373
|
Radar found that evidence but couldn't confirm it against the live object, so run{' '}
|
|
367
374
|
<code className="font-mono text-[11px]">radar cloud install</code> from a machine with
|
|
368
|
-
kubectl
|
|
375
|
+
kubectl. It inspects the release before generating anything.
|
|
369
376
|
</>
|
|
370
377
|
) : (
|
|
371
378
|
<>
|
|
372
379
|
The wizard generates the values patch for that controller, plus the one command that
|
|
373
|
-
creates the token Secret
|
|
380
|
+
creates the token Secret. The token never goes into your repository.
|
|
374
381
|
</>
|
|
375
382
|
)}
|
|
376
383
|
</>
|
|
@@ -387,12 +394,6 @@ function ModalFooter({
|
|
|
387
394
|
{notice && (
|
|
388
395
|
<div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">{notice}</div>
|
|
389
396
|
)}
|
|
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
397
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5">
|
|
397
398
|
{lane === 'driver' ? (
|
|
398
399
|
<>
|
|
@@ -400,13 +401,18 @@ function ModalFooter({
|
|
|
400
401
|
onClick={onConnect}
|
|
401
402
|
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
|
>
|
|
403
|
-
|
|
404
|
+
{/* Trailing ellipsis: further input follows the click — the
|
|
405
|
+
inspect step and a plan the user approves in the browser. */}
|
|
406
|
+
{prepareFailed ? 'Try again' : 'Connect this cluster…'}
|
|
404
407
|
</button>
|
|
408
|
+
{/* Always visible: the browser wizard is a different workflow, not
|
|
409
|
+
a recovery path — install-averse operators need the door before
|
|
410
|
+
anything fails, or they close the modal instead. */}
|
|
405
411
|
<a
|
|
406
412
|
href={driverEscapeUrl}
|
|
407
413
|
target="_blank"
|
|
408
414
|
rel="noopener noreferrer"
|
|
409
|
-
className="whitespace-nowrap text-[
|
|
415
|
+
className="whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary hover:underline underline-offset-2 transition-colors"
|
|
410
416
|
>
|
|
411
417
|
or set up in the browser
|
|
412
418
|
</a>
|
|
@@ -423,98 +429,129 @@ function ModalFooter({
|
|
|
423
429
|
{self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
|
|
424
430
|
</a>
|
|
425
431
|
)}
|
|
426
|
-
<button onClick={onLater} className="whitespace-nowrap text-[
|
|
432
|
+
<button onClick={onLater} className="ml-auto whitespace-nowrap text-[12px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
|
|
427
433
|
Maybe later
|
|
428
434
|
</button>
|
|
429
435
|
</div>
|
|
430
|
-
|
|
436
|
+
{/* Mechanics, not marketing: a falsifiable claim the plan card then
|
|
437
|
+
fulfills. Sits next to the button whose click it de-risks. */}
|
|
438
|
+
{lane === 'driver' && (
|
|
439
|
+
<p className="mt-2.5 text-[11px] leading-relaxed text-theme-text-tertiary">
|
|
440
|
+
Nothing installs on click. Radar inspects the cluster and shows you a plan; you approve it in
|
|
441
|
+
the browser before anything changes.
|
|
442
|
+
</p>
|
|
443
|
+
)}
|
|
444
|
+
{/* A 2-column grid, not flex-wrap: the long data-locality chip cannot
|
|
445
|
+
share a single row with the other three at this width, and flex
|
|
446
|
+
wrapping strands it as a 3+1 orphan. Two balanced columns read as a
|
|
447
|
+
designed layout at any chip length the Hub sends. */}
|
|
448
|
+
<div className="mt-4 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px] text-theme-text-tertiary">
|
|
431
449
|
{assuranceItems(assurances).map((item) => (
|
|
432
|
-
<span key={item} className="flex items-
|
|
433
|
-
<Check className="w-3 h-3
|
|
450
|
+
<span key={item} className="flex items-center gap-1">
|
|
451
|
+
<Check className="w-3 h-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
434
452
|
{item}
|
|
435
453
|
</span>
|
|
436
454
|
))}
|
|
437
455
|
</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
456
|
</div>
|
|
446
457
|
)
|
|
447
458
|
}
|
|
448
459
|
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
},
|
|
460
|
+
// The detail sits behind a disclosure so the first screen stays short.
|
|
461
|
+
function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: string }) {
|
|
462
|
+
const [moreOpen, setMoreOpen] = useState(false)
|
|
463
|
+
const moreId = useId()
|
|
464
|
+
// Hub-served prose fragment; the compiled fallback carries the same
|
|
465
|
+
// staleness trade as DEFAULT_ASSURANCES (rendered only when the Hub is
|
|
466
|
+
// unreachable or predates the field).
|
|
467
|
+
const freeLine = freeTier || 'free for 3 clusters'
|
|
468
|
+
// lead is the scannable anchor (medium, primary); rest stays secondary.
|
|
469
|
+
const highlights = [
|
|
470
|
+
{ icon: Globe, lead: 'Your whole fleet in one URL', rest: ': issues, checks and search across every cluster' },
|
|
471
|
+
{ icon: Users, lead: 'Bring the team', rest: ": SSO, invites and roles. Your cluster's RBAC has the final say" },
|
|
472
|
+
{ icon: Bell, lead: 'Alerts', rest: ' that reach you the moment something breaks' },
|
|
473
|
+
{ icon: History, lead: 'Long-term retention', rest: ': history that survives restarts and keeps growing' },
|
|
474
|
+
{ icon: Sparkles, lead: 'An AI agent', rest: ' that digs into issues and pinpoints the root cause' },
|
|
480
475
|
]
|
|
481
476
|
return (
|
|
482
477
|
<div className="px-8 pt-7 pb-2">
|
|
483
478
|
<Eyebrow />
|
|
484
|
-
<h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3
|
|
485
|
-
|
|
479
|
+
<h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3">
|
|
480
|
+
Meet Radar Cloud
|
|
486
481
|
</h3>
|
|
487
|
-
<p className="text-[14px] leading-relaxed text-theme-text-secondary mb-
|
|
488
|
-
The
|
|
489
|
-
<
|
|
490
|
-
|
|
482
|
+
<p className="text-[14px] leading-relaxed text-theme-text-secondary mb-6">
|
|
483
|
+
The hosted side of Radar: your clusters in one place, run by us.
|
|
484
|
+
<br />
|
|
485
|
+
The Radar you're running{' '}
|
|
486
|
+
<b className="text-theme-text-primary font-semibold">stays free and open source, always.</b>
|
|
491
487
|
</p>
|
|
492
|
-
<
|
|
493
|
-
{
|
|
494
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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>
|
|
488
|
+
<ul className="space-y-2.5 mb-4">
|
|
489
|
+
{highlights.map(({ icon: Icon, lead, rest }) => (
|
|
490
|
+
<li key={lead} className="flex items-start gap-2.5 text-[13px] leading-relaxed text-theme-text-secondary">
|
|
491
|
+
<Icon className="w-4 h-4 shrink-0 mt-[3px] text-emerald-600 dark:text-emerald-400" />
|
|
492
|
+
<span>
|
|
493
|
+
<span className="font-medium text-theme-text-primary">{lead}</span>
|
|
494
|
+
{rest}
|
|
495
|
+
</span>
|
|
496
|
+
</li>
|
|
506
497
|
))}
|
|
507
|
-
</
|
|
498
|
+
</ul>
|
|
499
|
+
{/* Underlined on purpose: at the bullet list's own color and size, and
|
|
500
|
+
with a leading glyph, it otherwise reads as one more bullet. */}
|
|
501
|
+
<button
|
|
502
|
+
type="button"
|
|
503
|
+
onClick={() => setMoreOpen((v) => !v)}
|
|
504
|
+
aria-expanded={moreOpen}
|
|
505
|
+
aria-controls={moreId}
|
|
506
|
+
className="flex items-center gap-1.5 mt-5 mb-3 text-[12.5px] text-theme-text-secondary underline underline-offset-2 decoration-theme-border hover:text-theme-text-primary transition-colors"
|
|
507
|
+
>
|
|
508
|
+
<CollapseChevron open={moreOpen} className="w-3.5 h-3.5" />
|
|
509
|
+
How it works and what it costs
|
|
510
|
+
</button>
|
|
511
|
+
<Collapse open={moreOpen}>
|
|
512
|
+
<div id={moreId} className="pt-1 pb-3 pl-[18px] space-y-3.5">
|
|
513
|
+
<section>
|
|
514
|
+
{/* No heading: the disclosure's own label already names this one. */}
|
|
515
|
+
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
516
|
+
{lane === 'driver'
|
|
517
|
+
? 'Setup runs here in the app: Radar is installed in your cluster and connects outward to Radar Cloud. You review the plan and approve in your browser before anything is installed.'
|
|
518
|
+
: 'Radar runs in your cluster and connects outward to Radar Cloud. You approve the connection before anything is installed.'}
|
|
519
|
+
</p>
|
|
520
|
+
</section>
|
|
521
|
+
<section>
|
|
522
|
+
<h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">What it costs</h4>
|
|
523
|
+
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
524
|
+
Radar Cloud is {freeLine}. The paid plans beyond that are what keep the lights on. The
|
|
525
|
+
Radar you're running stays
|
|
526
|
+
Apache 2.0 either way: every feature, forever.{' '}
|
|
527
|
+
<a href={PRICING_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
528
|
+
See pricing →
|
|
529
|
+
</a>
|
|
530
|
+
</p>
|
|
531
|
+
</section>
|
|
532
|
+
<section>
|
|
533
|
+
<h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">Who's behind it</h4>
|
|
534
|
+
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
535
|
+
Radar is built in the open and run by Skyhook, a CNCF Silver member and a small team of
|
|
536
|
+
humans, the kind you can actually talk to.{' '}
|
|
537
|
+
<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">
|
|
538
|
+
Meet us →
|
|
539
|
+
</a>
|
|
540
|
+
</p>
|
|
541
|
+
</section>
|
|
542
|
+
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
543
|
+
Prefer your own VPC? You can run the Radar Cloud control plane yourself.{' '}
|
|
544
|
+
<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">
|
|
545
|
+
Read the docs
|
|
546
|
+
</a>
|
|
547
|
+
.
|
|
548
|
+
</p>
|
|
549
|
+
</div>
|
|
550
|
+
</Collapse>
|
|
508
551
|
<div className="mb-5 border-l-2 border-emerald-500/40 pl-3.5">
|
|
509
|
-
<p className="text-[
|
|
510
|
-
|
|
511
|
-
|
|
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>
|
|
552
|
+
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
553
|
+
Don't need Radar Cloud right now? That's fine. What you're running is already a full product,
|
|
554
|
+
not a demo. We're here if you ever do.
|
|
518
555
|
</p>
|
|
519
556
|
</div>
|
|
520
557
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import type { ContextInfo } from '../types'
|
|
5
5
|
|
|
6
6
|
vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
@@ -8,6 +8,7 @@ vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
|
8
8
|
const useContextsMock = vi.hoisted(() => vi.fn(
|
|
9
9
|
(): { data: ContextInfo[] | undefined } => ({ data: undefined }),
|
|
10
10
|
))
|
|
11
|
+
const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
|
|
11
12
|
|
|
12
13
|
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
13
14
|
ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
|
|
@@ -17,6 +18,9 @@ vi.mock('../api/client', () => ({
|
|
|
17
18
|
useAuthMe: () => ({ data: { authEnabled: false } }),
|
|
18
19
|
useContexts: useContextsMock,
|
|
19
20
|
}))
|
|
21
|
+
vi.mock('../contexts/CapabilitiesContext', () => ({
|
|
22
|
+
useCapabilitiesContext: () => capabilitiesMock,
|
|
23
|
+
}))
|
|
20
24
|
vi.mock('./ContextSwitcher', () => ({
|
|
21
25
|
ContextSwitcher: () => <button>Switch context</button>,
|
|
22
26
|
}))
|
|
@@ -41,6 +45,10 @@ function renderError(errorType: string, context: string): string {
|
|
|
41
45
|
)
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
capabilitiesMock.localTerminal = true
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
describe('ConnectionErrorView authentication guidance', () => {
|
|
45
53
|
it('builds an honest EKS diagnostic without presenting it as authentication', () => {
|
|
46
54
|
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
@@ -119,6 +127,18 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
119
127
|
|
|
120
128
|
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
121
129
|
})
|
|
130
|
+
|
|
131
|
+
it('keeps recovery commands copyable without offering an unavailable local terminal', () => {
|
|
132
|
+
capabilitiesMock.localTerminal = false
|
|
133
|
+
|
|
134
|
+
const markup = renderError('auth', 'gke_project_us-east1_prod')
|
|
135
|
+
|
|
136
|
+
expect(markup).toContain('Refresh Google Cloud credentials')
|
|
137
|
+
expect(markup).toContain('>gcloud</span>')
|
|
138
|
+
expect(markup).toContain('aria-label="Copy command to clipboard"')
|
|
139
|
+
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
140
|
+
expect(markup).not.toContain('Authenticate in terminal')
|
|
141
|
+
})
|
|
122
142
|
})
|
|
123
143
|
|
|
124
144
|
describe('ConnectionErrorView kubeconfig guidance', () => {
|
|
@@ -8,6 +8,7 @@ import { useAuthMe, useContexts } from '../api/client'
|
|
|
8
8
|
import { Tooltip } from './ui/Tooltip'
|
|
9
9
|
import { allShellSafe } from '../utils/shell-safe'
|
|
10
10
|
import { apiUrl } from '../api/config'
|
|
11
|
+
import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
|
|
11
12
|
|
|
12
13
|
interface ConnectionErrorViewProps {
|
|
13
14
|
connection: ConnectionState
|
|
@@ -349,6 +350,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
349
350
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
350
351
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
351
352
|
const { data: authMe } = useAuthMe()
|
|
353
|
+
const { localTerminal } = useCapabilitiesContext()
|
|
352
354
|
const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
|
|
353
355
|
const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
|
|
354
356
|
|
|
@@ -356,11 +358,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
356
358
|
setShowRawError(rawErrorDefaultOpen)
|
|
357
359
|
}, [connection.error, rawErrorDefaultOpen])
|
|
358
360
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// mode — but the chained retry curl carries no session cookie, so it 401s
|
|
362
|
-
// once /api/connection is auth-gated. Only chain it when auth is *known*
|
|
363
|
-
// disabled (authMe still loading → don't chain a doomed call).
|
|
361
|
+
// The local terminal is only available in unauthenticated local mode, but
|
|
362
|
+
// authMe may still be loading when the capability response arrives.
|
|
364
363
|
const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
|
|
365
364
|
|
|
366
365
|
const handleAuthInTerminal = () => {
|
|
@@ -421,8 +420,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
421
420
|
{commandInfo?.authCommand && (
|
|
422
421
|
<div className="mt-3">
|
|
423
422
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
|
|
424
|
-
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
425
|
-
{isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
|
|
423
|
+
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={!localTerminal || commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
424
|
+
{localTerminal && isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
|
|
426
425
|
<button
|
|
427
426
|
onClick={handleAuthInTerminal}
|
|
428
427
|
className="mt-3 w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium btn-brand rounded-md"
|
|
@@ -436,7 +435,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
436
435
|
{commandInfo?.fallbackCommand && (
|
|
437
436
|
<div className="mt-4 pt-3 border-t border-theme-border/50">
|
|
438
437
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
|
|
439
|
-
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
438
|
+
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={!localTerminal || commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
440
439
|
</div>
|
|
441
440
|
)}
|
|
442
441
|
{connection.error && (
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
eventsForApplication,
|
|
23
23
|
memberRef,
|
|
24
24
|
subjectRef,
|
|
25
|
+
compareIssueSortAnchors,
|
|
25
26
|
type AppRow,
|
|
26
27
|
type AppWorkload,
|
|
27
28
|
type AppIdentityInstance,
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
} from "../../api/client";
|
|
47
48
|
import { useConnection } from "../../context/ConnectionContext";
|
|
48
49
|
import { useTimelineSource } from "../../context/TimelineSource";
|
|
49
|
-
import { buildWorkloadPath,
|
|
50
|
+
import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
|
|
50
51
|
import { WorkloadView } from "../workload/WorkloadView";
|
|
51
52
|
import { ApplicationCostTab } from "../cost/ApplicationCostTab";
|
|
52
53
|
import { isOpenCostWorkloadKind } from "../cost/kinds";
|
|
@@ -457,7 +458,7 @@ function AppDetailRoute({
|
|
|
457
458
|
params.set("workload", workloadKey(workload));
|
|
458
459
|
params.set(
|
|
459
460
|
"run",
|
|
460
|
-
`${
|
|
461
|
+
`${kindToPluralWithGroup(run.kind, apiVersionToGroup(run.data?.apiVersion as string | undefined))}/${runNamespace}/${run.name}`,
|
|
461
462
|
);
|
|
462
463
|
setSearchParams(params);
|
|
463
464
|
},
|
|
@@ -465,14 +466,15 @@ function AppDetailRoute({
|
|
|
465
466
|
);
|
|
466
467
|
const openWorkloadResource = useCallback(
|
|
467
468
|
(resource: SelectedResource) => {
|
|
468
|
-
|
|
469
|
+
const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? "")
|
|
470
|
+
if (pluralKind.toLowerCase() !== "pods") {
|
|
469
471
|
onOpenResource(resource);
|
|
470
472
|
return;
|
|
471
473
|
}
|
|
472
474
|
|
|
473
475
|
const [pathname, rawSearch = ""] = buildWorkloadPath({
|
|
474
476
|
...resource,
|
|
475
|
-
kind:
|
|
477
|
+
kind: pluralKind,
|
|
476
478
|
}).split("?");
|
|
477
479
|
const params = new URLSearchParams(rawSearch);
|
|
478
480
|
const activeNamespaces = searchParams.get("namespaces");
|
|
@@ -682,7 +684,7 @@ function AppDetailRoute({
|
|
|
682
684
|
renderWorkload={(workload: SelectedAppWorkload) => (
|
|
683
685
|
<div className="h-full overflow-hidden">
|
|
684
686
|
<WorkloadView
|
|
685
|
-
kind={
|
|
687
|
+
kind={kindToPluralWithGroup(workload.kind, workload.group ?? "")}
|
|
686
688
|
group={workload.group}
|
|
687
689
|
namespace={workload.namespace}
|
|
688
690
|
name={workload.name}
|
|
@@ -837,7 +839,7 @@ function AppOverviewIssueRows({
|
|
|
837
839
|
}) {
|
|
838
840
|
const navigate = (ref: IssueResourceRef) => {
|
|
839
841
|
onOpenResource({
|
|
840
|
-
kind:
|
|
842
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ""),
|
|
841
843
|
namespace: ref.namespace ?? "",
|
|
842
844
|
name: ref.name,
|
|
843
845
|
group: ref.group ?? "",
|
|
@@ -888,9 +890,8 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
|
|
|
888
890
|
const severity =
|
|
889
891
|
ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
|
|
890
892
|
if (severity !== 0) return severity;
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
if (fa !== fb) return fb.localeCompare(fa);
|
|
893
|
+
const onset = compareIssueSortAnchors(a, b);
|
|
894
|
+
if (onset !== 0) return onset;
|
|
894
895
|
const ns = (a.namespace ?? "").localeCompare(b.namespace ?? "");
|
|
895
896
|
if (ns !== 0) return ns;
|
|
896
897
|
const name = a.name.localeCompare(b.name);
|