@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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +34 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +208 -33
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  16. package/src/components/gitops/GitOpsView.tsx +8 -3
  17. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +10 -2
  19. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  20. package/src/components/home/ClusterHealthCard.tsx +60 -1
  21. package/src/components/home/HomeView.tsx +36 -11
  22. package/src/components/home/MCPSetupDialog.tsx +5 -4
  23. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  24. package/src/components/home/RadarVersionLine.tsx +137 -0
  25. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  26. package/src/components/resources/ResourcesView.tsx +31 -8
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  28. package/src/components/settings/SettingsDialog.tsx +21 -4
  29. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  30. package/src/components/ui/ErrorBoundary.tsx +17 -2
  31. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  32. package/src/components/ui/UpdateNotification.tsx +6 -2
  33. package/src/components/workload/WorkloadView.test.ts +60 -0
  34. package/src/components/workload/WorkloadView.tsx +270 -29
  35. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  36. package/src/contexts/CapabilitiesContext.tsx +7 -3
  37. package/src/utils/navigation.test.ts +45 -0
  38. package/src/utils/navigation.ts +5 -5
  39. package/src/utils/topology-selection.ts +3 -2
  40. package/src/utils/version.test.ts +37 -0
  41. package/src/utils/version.ts +56 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useState, type ReactNode } from 'react'
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
- 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'
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
- const exitFlow = () => {
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 all your clusters, one URL" delay={100} position="bottom" disabled={open}>
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
- <ModalBody />
259
+ <PitchBody lane={lane} freeTier={connectInfo.data?.freeTier} />
230
260
  </div>
231
261
  <ModalFooter
232
262
  lane={lane}
233
263
  signupUrl={signupUrl}
234
- driverEscapeUrl={signupUrlFor('driver-escape')}
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
- // 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
-
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
- 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]]
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
- // 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.
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 kubectl —
357
- it inspects the release and refuses rather than guessing.
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 an imperative upgrade would be reverted.{' '}
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 it inspects the release before generating anything.
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 the token never goes into your repository.
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
- Connect this cluster
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-[13px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
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-[13px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
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
- <div className="mt-4 grid grid-cols-2 gap-x-5 gap-y-2 text-[11.5px] text-theme-text-tertiary">
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-start gap-1.5">
433
- <Check className="w-3 h-3 mt-[3px] shrink-0 text-emerald-600 dark:text-emerald-400" />
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
- // 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
- },
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 text-balance">
485
- First things first: Radar stays free.
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-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.
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
- <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>
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
- </div>
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&nbsp;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-[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>
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
- // Auto-retry after successful auth. The terminal shell runs on the server
360
- // host, so the auth command itself fixes the server's credentials in every
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, kindToPlural } from "../../utils/navigation";
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
- `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`,
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
- if (kindToPlural(resource.kind).toLowerCase() !== "pods") {
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: kindToPlural(resource.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={kindToPlural(workload.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: kindToPlural(ref.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 fa = a.first_seen ?? "";
892
- const fb = b.first_seen ?? "";
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);