@skyhook-io/radar-app 1.14.6 → 1.15.0

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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +51 -6
  3. package/src/api/client.admission.test.ts +34 -0
  4. package/src/api/client.ts +37 -3
  5. package/src/api/usage-data.fixtures.ts +33 -0
  6. package/src/api/usage-data.ts +165 -0
  7. package/src/components/CloudFunnelButton.test.tsx +2 -2
  8. package/src/components/CloudFunnelButton.tsx +99 -55
  9. package/src/components/SelfManagedStart.tsx +96 -0
  10. package/src/components/cloudConnectHandoff.test.ts +1 -1
  11. package/src/components/cloudConnectHandoff.ts +1 -1
  12. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  13. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  14. package/src/components/dialog-names.test.ts +69 -0
  15. package/src/components/execution/BatchExecutionView.tsx +1 -1
  16. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  17. package/src/components/execution/JobSetAdmission.tsx +16 -7
  18. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  19. package/src/components/home/HomeView.tsx +6 -2
  20. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  21. package/src/components/home/RadarVersionLine.tsx +28 -3
  22. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  23. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  24. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  25. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  26. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  27. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  28. package/src/components/settings/PrivacySection.test.tsx +84 -0
  29. package/src/components/settings/PrivacySection.tsx +140 -0
  30. package/src/components/settings/SettingsDialog.tsx +13 -52
  31. package/src/components/settings/controls.tsx +55 -0
  32. package/src/components/settings/settings-state.ts +1 -0
  33. package/src/components/ui/ErrorBoundary.tsx +5 -0
  34. package/src/components/ui/Omnibar.tsx +16 -0
  35. package/src/components/ui/UpdateNotification.tsx +13 -9
  36. package/src/components/ui/command-items.ts +14 -0
  37. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  38. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  39. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  40. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  41. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  42. package/src/components/whats-new/WhatsNew.tsx +423 -0
  43. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  44. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  45. package/src/components/whats-new/releaseNotes.ts +121 -0
  46. package/src/components/workload/WorkloadView.tsx +11 -1
  47. package/src/k8s-ui-exports.test.ts +73 -0
  48. package/src/utils/navigation.test.ts +17 -1
  49. package/src/utils/navigation.ts +16 -0
  50. package/src/utils/version.ts +11 -0
@@ -1,13 +1,13 @@
1
1
  import { useEffect, useId, useState } from 'react'
2
2
  import { useMutation, useQueryClient } from '@tanstack/react-query'
3
- import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
3
+ import { Bell, Check, Cloud, Globe, History, Sparkles, Users, X } from 'lucide-react'
4
4
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
5
5
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
6
6
  import { Tooltip } from './ui/Tooltip'
7
7
  import { CloudConnectFlow } from './CloudConnectFlow'
8
+ import { SelfManagedStart } from './SelfManagedStart'
8
9
  import {
9
10
  type Handoff,
10
- SELF_HOSTED_DOCS_URL,
11
11
  exitFor,
12
12
  handoffForBlocked,
13
13
  handoffForPrepareError,
@@ -33,7 +33,9 @@ import {
33
33
  } from '../api/client'
34
34
 
35
35
  // OSS → Cloud funnel: a quiet globe button in the top bar that opens a modal
36
- // pitching Radar Cloud. Two lanes (capabilities.cloudConnect): "driver" runs
36
+ // pitching Radar Cloud: Radar for you and your team, every cluster, around the
37
+ // clock. Alerts and hosted investigations lead because they are the two things
38
+ // the OSS binary cannot do at all. Two lanes (capabilities.cloudConnect): "driver" runs
37
39
  // the in-product connect flow against this server; "wizard" links to the Hub's
38
40
  // connect wizard.
39
41
  //
@@ -58,7 +60,7 @@ const FALLBACK_APP_URL = 'https://app.radarhq.io'
58
60
  const DEFAULT_ASSURANCES = [
59
61
  'Secure outbound-only tunnel',
60
62
  'Disconnect and delete your data anytime',
61
- 'SOC 2 Type II',
63
+ 'SOC 2 Type II · SSO & SCIM on Enterprise',
62
64
  '3 clusters free, no card required',
63
65
  ]
64
66
  // Other OSS surfaces (a GitOps app that deploys to another cluster, say)
@@ -71,6 +73,7 @@ export function openCloudFunnel() {
71
73
  window.dispatchEvent(new Event(OPEN_EVENT))
72
74
  }
73
75
  const ABOUT_URL = 'https://radarhq.io/about'
76
+ const BENCHMARK_URL = 'https://radarhq.io/benchmark'
74
77
  const PRICING_URL = 'https://radarhq.io/pricing'
75
78
  const SEEN_KEY = 'radar.cloudFunnel.seen'
76
79
 
@@ -99,6 +102,7 @@ export function CloudFunnelButton() {
99
102
  const [open, setOpen] = useState(false)
100
103
  const [seen, setSeen] = useState(readSeen)
101
104
  const [inFlowView, setInFlowView] = useState(false)
105
+ const [selfManaged, setSelfManaged] = useState(false)
102
106
  const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
103
107
  // Set once an in-app attempt has ended without connecting, naming what
104
108
  // happened (a flow failure kind, a blocked plan, a canceled plan, or the
@@ -191,6 +195,7 @@ export function CloudFunnelButton() {
191
195
 
192
196
  const openModal = () => {
193
197
  setOpen(true)
198
+ setSelfManaged(false)
194
199
  setSeen(true)
195
200
  markSeen()
196
201
  // Re-open lands on a live flow if one is running.
@@ -282,14 +287,24 @@ export function CloudFunnelButton() {
282
287
  <>
283
288
  {/* Tooltip is suppressed while the modal is open — it portals above the
284
289
  modal backdrop and would otherwise paint on top of the dialog. */}
285
- <Tooltip content="Radar Cloud: all your clusters, one URL" delay={100} position="bottom" disabled={open}>
290
+ <Tooltip
291
+ content={
292
+ <>
293
+ <span className="block font-semibold">Meet Radar Cloud</span>
294
+ See all your clusters in one place, share with your team, and get alerts and automatic AI investigations in Slack.
295
+ </>
296
+ }
297
+ delay={100}
298
+ position="bottom"
299
+ disabled={open}
300
+ >
286
301
  <button
287
302
  onClick={openModal}
288
303
  aria-label="Radar Cloud"
289
304
  aria-haspopup="dialog"
290
305
  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"
291
306
  >
292
- <Globe className="w-4 h-4" />
307
+ <Cloud className="w-4 h-4" />
293
308
  {cloudInstallActive(flow?.state) ? (
294
309
  <span className="absolute top-0.5 right-0.5 w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse motion-reduce:animate-none" />
295
310
  ) : (
@@ -305,6 +320,7 @@ export function CloudFunnelButton() {
305
320
  <DialogPortal
306
321
  open={open}
307
322
  onClose={() => setOpen(false)}
323
+ ariaLabel="Radar Cloud"
308
324
  className="w-[580px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
309
325
  >
310
326
  <button
@@ -332,6 +348,13 @@ export function CloudFunnelButton() {
332
348
  onExit={() => exitFlow(outcomeOf(flowForView))}
333
349
  />
334
350
  </div>
351
+ ) : selfManaged ? (
352
+ <>
353
+ <div className="shrink-0 px-8 pt-7">
354
+ <Eyebrow />
355
+ </div>
356
+ <SelfManagedStart appUrl={appUrl} onBack={() => setSelfManaged(false)} />
357
+ </>
335
358
  ) : (
336
359
  <>
337
360
  <div className="min-h-0 overflow-y-auto">
@@ -360,6 +383,7 @@ export function CloudFunnelButton() {
360
383
  // in-cluster, so the CTA would escape before classification.
361
384
  selfLoading={inCluster && self.isPending}
362
385
  onConnect={startConnect}
386
+ onSelfManaged={() => setSelfManaged(true)}
363
387
  onLater={() => setOpen(false)}
364
388
  />
365
389
  </>
@@ -395,7 +419,7 @@ function Eyebrow() {
395
419
  return (
396
420
  <div className="flex items-center gap-3 mb-5">
397
421
  <RadarSweep />
398
- <span className="font-mono text-[10.5px] tracking-[0.16em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
422
+ <span className="font-mono text-[16px] tracking-[0.12em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
399
423
  </div>
400
424
  )
401
425
  }
@@ -417,6 +441,7 @@ function ModalFooter({
417
441
  discoverPending = false,
418
442
  clustersUrl,
419
443
  onConnect,
444
+ onSelfManaged,
420
445
  onLater,
421
446
  }: {
422
447
  lane: 'driver' | 'wizard'
@@ -449,6 +474,7 @@ function ModalFooter({
449
474
  // settings say it is connected but not where.
450
475
  clustersUrl?: string
451
476
  onConnect: () => void
477
+ onSelfManaged: () => void
452
478
  onLater: () => void
453
479
  }) {
454
480
  const gitops = self?.ownership === 'gitops'
@@ -463,7 +489,7 @@ function ModalFooter({
463
489
  // fast click would escape to signup before we could route this install.
464
490
  const selfPending = selfLoading === true
465
491
  return (
466
- <div className="shrink-0 px-8 py-5 bg-theme-base border-t border-theme-border">
492
+ <div className="shrink-0 px-8 py-6 bg-theme-base border-t border-theme-border">
467
493
  {self && self.ownership !== 'unknown' && (
468
494
  <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">
469
495
  {ambiguous ? (
@@ -551,17 +577,6 @@ function ModalFooter({
551
577
  inspect step and a plan the user approves in the browser. */}
552
578
  {prepareFailed ? 'Try again' : 'Connect this cluster…'}
553
579
  </button>
554
- {/* Always visible: the browser wizard is a different workflow, not
555
- a recovery path — install-averse operators need the door before
556
- anything fails, or they close the modal instead. */}
557
- <a
558
- href={driverBrowserUrl}
559
- target="_blank"
560
- rel="noopener noreferrer"
561
- className="whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary hover:underline underline-offset-2 transition-colors"
562
- >
563
- or set up in the browser
564
- </a>
565
580
  </>
566
581
  ) : cliOnly ? null : (
567
582
  <a
@@ -575,6 +590,19 @@ function ModalFooter({
575
590
  {lane === 'driver' ? 'Continue in Radar Cloud' : self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
576
591
  </a>
577
592
  )}
593
+ {/* Self-Managed is a paid product with its own trial, so it gets a real
594
+ button beside the hosted CTA rather than a link near the exit, the
595
+ way GitLab and n8n show their self-managed tiers. Outlined, so the
596
+ hosted CTA stays the default. */}
597
+ {!(lane === 'driver' && alreadyConnected) && (
598
+ <button
599
+ type="button"
600
+ onClick={onSelfManaged}
601
+ className="whitespace-nowrap px-4 py-2 rounded-[10px] border border-theme-border bg-theme-surface hover:bg-theme-hover text-theme-text-primary text-[13px] font-medium transition-colors"
602
+ >
603
+ Run Radar Cloud yourself
604
+ </button>
605
+ )}
578
606
  <button onClick={onLater} className="ml-auto whitespace-nowrap text-[12px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
579
607
  {lane === 'driver' && alreadyConnected ? 'Close' : 'Maybe later'}
580
608
  </button>
@@ -592,16 +620,27 @@ function ModalFooter({
592
620
  fulfills. Sits next to the button whose click it de-risks. */}
593
621
  {lane === 'driver' && clusterConnected && !alreadyConnected && (
594
622
  <p className="mt-2.5 text-[11px] leading-relaxed text-theme-text-tertiary">
595
- Nothing installs on click. Radar inspects{' '}
596
- {clusterName ? <span className="text-theme-text-secondary">{clusterName}</span> : 'the cluster'} and shows
597
- you a plan; you approve it in the browser before anything changes.
623
+ Nothing installs on click. You&apos;ll see what gets installed in{' '}
624
+ {clusterName ? <span className="text-theme-text-secondary">{clusterName}</span> : 'this cluster'} and approve
625
+ it before anything changes.{' '}
626
+ {/* Always offered: the browser wizard is a different workflow, not a
627
+ recovery path. Install-averse operators need this door before
628
+ anything fails, or they close the dialog instead. */}
629
+ <a
630
+ href={driverBrowserUrl}
631
+ target="_blank"
632
+ rel="noopener noreferrer"
633
+ className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary"
634
+ >
635
+ Or set up in the browser →
636
+ </a>
598
637
  </p>
599
638
  )}
600
639
  {/* A 2-column grid, not flex-wrap: the long data-locality chip cannot
601
640
  share a single row with the other three at this width, and flex
602
641
  wrapping strands it as a 3+1 orphan. Two balanced columns read as a
603
642
  designed layout at any chip length the Hub sends. */}
604
- <div className="mt-4 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px] text-theme-text-tertiary">
643
+ <div className="mt-5 grid grid-cols-2 gap-x-3 gap-y-2 text-[11px] text-theme-text-tertiary">
605
644
  {assuranceItems(assurances).map((item) => (
606
645
  <span key={item} className="flex items-center gap-1">
607
646
  <Check className="w-3 h-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
@@ -622,26 +661,31 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
622
661
  // unreachable or predates the field).
623
662
  const freeLine = freeTier || 'free for 3 clusters'
624
663
  // lead is the scannable anchor (medium, primary); rest stays secondary.
664
+ // Each bullet is one line at the dialog's width, so the first screen stays
665
+ // skimmable. Alerts and investigations lead: they are the two things the
666
+ // OSS binary cannot do at all. Team, fleet and history follow.
625
667
  const highlights = [
626
- { icon: Globe, lead: 'Your whole fleet in one URL', rest: ': issues, checks and search across every cluster' },
627
- { icon: Users, lead: 'Bring the team', rest: ": SSO, invites and roles. Your cluster's RBAC has the final say" },
628
- { icon: Bell, lead: 'Alerts', rest: ' that reach you the moment something breaks' },
629
- { icon: History, lead: 'Long-term retention', rest: ': history that survives restarts and keeps growing' },
630
- { icon: Sparkles, lead: 'An AI agent', rest: ' that digs into issues and pinpoints the root cause' },
668
+ { icon: Bell, lead: 'Alerts', rest: ' in Slack when something breaks. Once, not for every crash-looping pod.' },
669
+ { icon: Sparkles, lead: 'AI investigations', rest: ': root cause with evidence, automatic on alerts or on demand' },
670
+ { icon: Users, lead: 'Your team', rest: ': share any view with just a link, scoped by your existing RBAC' },
671
+ { icon: Globe, lead: 'Every cluster', rest: ': the whole fleet in one view and one MCP endpoint' },
672
+ { icon: History, lead: 'History', rest: ': events and changes kept long after Kubernetes deletes them' },
631
673
  ]
632
674
  return (
633
- <div className="px-8 pt-7 pb-2">
675
+ <div className="px-8 pt-7 pb-5">
634
676
  <Eyebrow />
635
- <h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3">
636
- Meet Radar Cloud
677
+ <h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3 text-balance">
678
+ Close the laptop. Radar keeps watching.
637
679
  </h3>
680
+ <p className="text-[14px] leading-relaxed text-theme-text-secondary mb-1.5">
681
+ Radar Cloud is the hosted side of Radar: Slack alerts, AI root cause and every cluster in one place, for
682
+ you and your team.
683
+ </p>
638
684
  <p className="text-[14px] leading-relaxed text-theme-text-secondary mb-6">
639
- The hosted side of Radar: your clusters in one place, run by us.
640
- <br />
641
685
  The Radar you're running{' '}
642
686
  <b className="text-theme-text-primary font-semibold">stays free and open source, always.</b>
643
687
  </p>
644
- <ul className="space-y-2.5 mb-4">
688
+ <ul className="space-y-2.5 mb-3">
645
689
  {highlights.map(({ icon: Icon, lead, rest }) => (
646
690
  <li key={lead} className="flex items-start gap-2.5 text-[13px] leading-relaxed text-theme-text-secondary">
647
691
  <Icon className="w-4 h-4 shrink-0 mt-[3px] text-emerald-600 dark:text-emerald-400" />
@@ -670,46 +714,46 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
670
714
  {/* No heading: the disclosure's own label already names this one. */}
671
715
  <p className="text-[12px] leading-relaxed text-theme-text-secondary">
672
716
  {lane === 'driver'
673
- ? '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.'
674
- : 'Radar runs in your cluster and connects outward to Radar Cloud. You approve the connection before anything is installed.'}
717
+ ? "Setup runs here in the app: Radar is installed in your cluster and tunnels outward to Radar Cloud, so there's no ingress to open."
718
+ : "Radar runs in your cluster and tunnels outward to Radar Cloud, so there's no ingress to open."}{' '}
719
+ Live views are fetched from your cluster when someone opens them, within their permissions. Radar
720
+ Cloud stores event history, changes and investigation results, so you can go back in time to
721
+ debug.
722
+ </p>
723
+ </section>
724
+ <section>
725
+ <h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">Investigations</h4>
726
+ <p className="text-[12px] leading-relaxed text-theme-text-secondary">
727
+ In our public benchmark, Radar's agent found the root cause 3× faster than the same model on plain
728
+ kubectl, and 2× faster than the other AI SRE tools we tested. Run it automatically on every alert, or
729
+ on demand from any issue, with no CLI or API key.{' '}
730
+ <a href={BENCHMARK_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
731
+ See the benchmarks →
732
+ </a>
675
733
  </p>
676
734
  </section>
677
735
  <section>
678
736
  <h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">What it costs</h4>
679
737
  <p className="text-[12px] leading-relaxed text-theme-text-secondary">
680
- Radar Cloud is {freeLine}. The paid plans beyond that are what keep the lights on. The
681
- Radar you're running stays
682
- Apache&nbsp;2.0 either way: every feature, forever.{' '}
738
+ Radar Cloud is {freeLine}, with 100 investigations a month included, then $1 per investigation.
739
+ Paid plans add more clusters, more included investigations, and enterprise features like SSO, SCIM
740
+ and audit logs.{' '}
683
741
  <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">
684
742
  See pricing →
685
743
  </a>
686
744
  </p>
687
745
  </section>
688
746
  <section>
689
- <h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">Who's behind it</h4>
747
+ {/* No heading: "built by Skyhook" already answers who is behind it. */}
690
748
  <p className="text-[12px] leading-relaxed text-theme-text-secondary">
691
- Radar is built in the open and run by Skyhook, a CNCF Silver member and a small team of
692
- humans, the kind you can actually talk to.{' '}
749
+ Built in the open by Skyhook, a CNCF Silver member and a small team you can actually talk to.{' '}
693
750
  <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">
694
751
  Meet us →
695
752
  </a>
696
753
  </p>
697
754
  </section>
698
- <p className="text-[12px] leading-relaxed text-theme-text-secondary">
699
- Prefer your own VPC? You can run the Radar Cloud control plane yourself.{' '}
700
- <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">
701
- Read the docs
702
- </a>
703
- .
704
- </p>
705
755
  </div>
706
756
  </Collapse>
707
- <div className="mb-5 border-l-2 border-emerald-500/40 pl-3.5">
708
- <p className="text-[12px] leading-relaxed text-theme-text-secondary">
709
- Don't need Radar Cloud right now? That's fine. What you're running is already a full product,
710
- not a demo. We're here if you ever do.
711
- </p>
712
- </div>
713
757
  </div>
714
758
  )
715
759
  }
@@ -0,0 +1,96 @@
1
+ import { ArrowLeft, ArrowUpRight } from 'lucide-react'
2
+ import { SELF_HOSTED_DOCS_URL, signupUrlFor } from './cloudConnectHandoff'
3
+
4
+ // What the Cloud dialog shows after "Run Radar Cloud yourself…". Running your
5
+ // own control plane starts with a Radar Cloud account, because the Hub signs
6
+ // the Self-Managed license, so this view names that as step 1 of 3 before the
7
+ // click instead of letting a sign-up page surprise someone who came to host
8
+ // it themselves. The link carries intent=self-hosting: the Hub's sign-in page
9
+ // repeats the steps and, after sign-up or sign-in, continues on its
10
+ // Self-hosting page, where the trial, the license and the install command are.
11
+ export function SelfManagedStart({ appUrl, onBack }: { appUrl: string; onBack: () => void }) {
12
+ const signupUrl = `${signupUrlFor(appUrl, 'self-managed-signup')}&intent=self-hosting`
13
+ const steps = [
14
+ {
15
+ title: 'Create your Radar Cloud account',
16
+ body: 'Your license is issued from it. An existing account works too.',
17
+ },
18
+ {
19
+ title: 'Generate your install command',
20
+ body: 'Self-Managed is part of Enterprise, with a 14-day free trial.',
21
+ },
22
+ {
23
+ title: 'Install the control plane, then connect each cluster',
24
+ body: 'One Helm command installs it. Then one command per cluster to connect it.',
25
+ },
26
+ ]
27
+ return (
28
+ <>
29
+ <div className="min-h-0 overflow-y-auto">
30
+ <div className="px-8 pb-5">
31
+ <h3 className="text-[20px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-1.5">
32
+ Run Radar Cloud yourself
33
+ </h3>
34
+ <p className="text-[13px] leading-relaxed text-theme-text-secondary mb-5">
35
+ Run the Radar Cloud control plane in your own infrastructure: the same fleet view, alerts, history
36
+ and team access, and your cluster data never leaves your network.
37
+ </p>
38
+ <ol className="space-y-3.5">
39
+ {steps.map((step, i) => (
40
+ <li key={step.title} className="flex gap-3">
41
+ <span
42
+ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10.5px] font-semibold ${
43
+ i === 0 ? 'bg-emerald-500 text-emerald-950' : 'border border-theme-border text-theme-text-tertiary'
44
+ }`}
45
+ >
46
+ {i + 1}
47
+ </span>
48
+ <div className="min-w-0">
49
+ <p className={`text-[13px] font-medium ${i === 0 ? 'text-theme-text-primary' : 'text-theme-text-secondary'}`}>
50
+ {step.title}
51
+ </p>
52
+ <p className="text-[12px] leading-relaxed text-theme-text-tertiary">{step.body}</p>
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ol>
57
+ {/* What a platform engineer has to bring, split by stage: a trial
58
+ needs only an address clusters can reach (a cloud load balancer
59
+ is the install page's default; Postgres and a built-in admin come
60
+ bundled); production adds their own Postgres and IdP. */}
61
+ <div className="mt-5 space-y-1 text-[12px] leading-relaxed text-theme-text-tertiary">
62
+ <p>
63
+ <b className="font-medium text-theme-text-secondary">For a trial</b>, you just need a hostname your
64
+ clusters can reach the hub on.
65
+ </p>
66
+ <p>
67
+ <b className="font-medium text-theme-text-secondary">For production</b>, connect your own Postgres and
68
+ your IdP to manage users.
69
+ </p>
70
+ <a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="inline-block underline underline-offset-2 hover:text-theme-text-primary">
71
+ Read the Self-Managed guide →
72
+ </a>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ <div className="shrink-0 px-8 py-4 bg-theme-base border-t border-theme-border flex flex-wrap items-center gap-x-3 gap-y-2.5">
77
+ <button
78
+ onClick={onBack}
79
+ className="inline-flex items-center gap-1.5 whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
80
+ >
81
+ <ArrowLeft className="w-3.5 h-3.5" />
82
+ Back to Radar Cloud
83
+ </button>
84
+ <a
85
+ href={signupUrl}
86
+ target="_blank"
87
+ rel="noopener noreferrer"
88
+ className="ml-auto inline-flex items-center gap-1.5 whitespace-nowrap 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)] transition-all"
89
+ >
90
+ Create your Radar Cloud account
91
+ <ArrowUpRight className="w-4 h-4" />
92
+ </a>
93
+ </div>
94
+ </>
95
+ )
96
+ }
@@ -171,7 +171,7 @@ describe('composeAdminNote — an ask, then the link, then the evidence', () =>
171
171
  expect(note).toContain('Please confirm nothing is already installed before a fresh install.')
172
172
  // The object of the request and the answer to "no SaaS", before the evidence.
173
173
  expect(note.indexOf('What it is: Radar, the open-source Kubernetes tool')).toBeLessThan(note.indexOf('Details:'))
174
- expect(note).toContain(`can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`)
174
+ expect(note).toContain(`can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`)
175
175
  // Never the card's second person.
176
176
  expect(note).not.toMatch(/\byour\b/i)
177
177
  })
@@ -144,7 +144,7 @@ export interface AdminNoteContext {
144
144
  export const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
145
145
  const WHAT_IT_IS = [
146
146
  'What it is: Radar, the open-source Kubernetes tool, installed in the cluster as a Helm chart. It opens an outbound connection to a hosted control plane (nothing listens inbound); Radar Cloud users reach this Radar only through that connection, with the permissions of the ServiceAccount it runs as.',
147
- `If a hosted service is not an option, the same control plane can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`,
147
+ `If a hosted service is not an option, the same control plane can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`,
148
148
  ].join(' ')
149
149
 
150
150
  export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit, where: AdminNoteContext = {}): string {
@@ -1099,6 +1099,7 @@ function ToolResultDialog({
1099
1099
  text: string;
1100
1100
  truncated?: boolean;
1101
1101
  }) {
1102
+ const titleId = useId();
1102
1103
  const { theme } = useTheme();
1103
1104
  const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
1104
1105
  const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
@@ -1136,10 +1137,10 @@ function ToolResultDialog({
1136
1137
  };
1137
1138
  }, [open, display, language, theme]);
1138
1139
  return (
1139
- <DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
1141
  <div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
1141
1142
  <div className="min-w-0">
1142
- <div className="truncate font-mono text-sm text-theme-text-primary">
1143
+ <div id={titleId} className="truncate font-mono text-sm text-theme-text-primary">
1143
1144
  {title}
1144
1145
  </div>
1145
1146
  {truncated && (
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, useId } from "react";
2
2
  import {
3
3
  CheckCircle2,
4
4
  AlertTriangle,
@@ -41,6 +41,7 @@ export function ApplyDialog({
41
41
  managedBy?: string; // GitOps/Helm owner of the resource, if any
42
42
  confidence?: number;
43
43
  }) {
44
+ const titleId = useId();
44
45
  const fixText = fix?.trim();
45
46
  const lowConfidence = confidence != null && confidence < 0.5;
46
47
  // A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
@@ -56,6 +57,7 @@ export function ApplyDialog({
56
57
  const applyBlocked = !!managedBy && !acked;
57
58
  return (
58
59
  <DialogPortal
60
+ ariaLabelledBy={titleId}
59
61
  open={open}
60
62
  onClose={onClose}
61
63
  className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
@@ -65,7 +67,7 @@ export function ApplyDialog({
65
67
  <AlertTriangle className="h-5 w-5 text-amber-500" />
66
68
  </div>
67
69
  <div className="min-w-0 flex-1">
68
- <h3 className="text-lg font-semibold text-theme-text-primary">
70
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
69
71
  Apply this fix?
70
72
  </h3>
71
73
  <p className="mt-1 text-sm text-theme-text-secondary">
@@ -0,0 +1,69 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // Screen readers announce a dialog by its name; an unnamed one is read as just
6
+ // "dialog". Every DialogPortal must pass ariaLabelledBy (its visible title) or
7
+ // ariaLabel.
8
+ const repoRoot = join(__dirname, '..', '..', '..')
9
+ const roots = ['web/src', 'packages/k8s-ui/src'].map(r => join(repoRoot, r))
10
+
11
+ function sourceFiles(dir: string): string[] {
12
+ return readdirSync(dir).flatMap(name => {
13
+ const path = join(dir, name)
14
+ if (statSync(path).isDirectory()) return name === 'node_modules' ? [] : sourceFiles(path)
15
+ return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [path] : []
16
+ })
17
+ }
18
+
19
+ // The opening tag ends at the first '>' outside a {…} expression or a string.
20
+ function openingTags(source: string, component: string): string[] {
21
+ const tags: string[] = []
22
+ let from = 0
23
+ for (;;) {
24
+ const start = source.indexOf(`<${component}`, from)
25
+ if (start < 0) return tags
26
+ let depth = 0
27
+ let i = start + component.length + 1
28
+ for (; i < source.length; i++) {
29
+ const c = source[i]
30
+ if (c === '"' || c === "'" || c === '`') {
31
+ i++
32
+ while (i < source.length && source[i] !== c) i += source[i] === '\\' ? 2 : 1
33
+ } else if (c === '{') depth++
34
+ else if (c === '}') depth--
35
+ else if (c === '>' && depth === 0) break
36
+ }
37
+ tags.push(source.slice(start, i + 1))
38
+ from = i
39
+ }
40
+ }
41
+
42
+ function isNamed(tag: string): boolean {
43
+ const withoutStrings = tag.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/gs, '""')
44
+ return /\saria(Label|LabelledBy)\s*=/.test(withoutStrings)
45
+ }
46
+
47
+ describe('dialogs', () => {
48
+ it('every DialogPortal has an accessible name', () => {
49
+ const unnamed = roots.flatMap(sourceFiles).flatMap(file => {
50
+ if (file.endsWith('DialogPortal.tsx')) return []
51
+ return openingTags(readFileSync(file, 'utf8'), 'DialogPortal')
52
+ .filter(tag => /^<DialogPortal[\s>]/.test(tag) && !isNamed(tag))
53
+ .map(() => relative(repoRoot, file))
54
+ })
55
+ expect(unnamed).toEqual([])
56
+ })
57
+
58
+ it('reads attributes, not text that looks like them', () => {
59
+ const [unnamed, spaced, braceInString] = openingTags(
60
+ `<DialogPortal onClose={() => log("ariaLabel=unused")}>` +
61
+ `<DialogPortal ariaLabel = "Named">` +
62
+ `<DialogPortal title={"}"} open>`,
63
+ 'DialogPortal',
64
+ )
65
+ expect(isNamed(unnamed)).toBe(false)
66
+ expect(isNamed(spaced)).toBe(true)
67
+ expect(braceInString).toBe('<DialogPortal title={"}"} open>')
68
+ })
69
+ })
@@ -344,7 +344,7 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou
344
344
  <JobSetRenderer data={resource} mode="overview" shownMemberCounts={shownMemberCounts} onSelectRole={memberCollection ? selectRole : undefined} />
345
345
  </section>
346
346
  )}
347
- {jobSetRoot && <JobSetAdmission resource={resource} namespace={namespace} name={name} onNavigate={onNavigateToResource} />}
347
+ {(jobSetRoot || (resource.apiVersion === 'batch/v1' && resource.kind === 'Job')) && <JobSetAdmission resource={resource} namespace={namespace} name={name} onNavigate={onNavigateToResource} />}
348
348
  {memberShell && <JobSetMemberComparison
349
349
  runs={runs} total={runsQuery.data?.total ?? 0} filteredTotal={runsQuery.data?.filteredTotal ?? runs.length}
350
350
  truncated={runsQuery.data?.truncated ?? false} loading={runsQuery.isLoading} error={runsQuery.error}