@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
@@ -0,0 +1,84 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import type { UsageDataStatus } from '../../api/usage-data'
5
+ import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
6
+
7
+ let current: UsageDataStatus
8
+
9
+ vi.mock('../../api/usage-data', async (orig) => ({
10
+ ...(await orig<typeof import('../../api/usage-data')>()),
11
+ useUsageData: () => ({ data: current, isLoading: false, error: null }),
12
+ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
13
+ }))
14
+
15
+ import { PrivacySection } from './PrivacySection'
16
+
17
+ function make(patch: Partial<UsageDataStatus>): UsageDataStatus {
18
+ return exampleStatus({ preview: exampleReport({ views: { topology: 3 } }), ...patch })
19
+ }
20
+
21
+ function render(status: UsageDataStatus) {
22
+ current = status
23
+ return renderToString(
24
+ <QueryClientProvider client={new QueryClient()}>
25
+ <PrivacySection active />
26
+ </QueryClientProvider>,
27
+ )
28
+ }
29
+
30
+ describe('PrivacySection', () => {
31
+ it('shows an example report and promises nothing is sent while off', () => {
32
+ const html = render(make({}))
33
+ expect(html).toContain('See an example report')
34
+ expect(html).toContain('What isn')
35
+ expect(html).not.toContain('usage-report.json')
36
+ expect(html).toContain('aria-checked="false"')
37
+ })
38
+
39
+ it('shows the pending report and where it lives once on', () => {
40
+ const html = render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-30T12:00:00Z' }))
41
+ expect(html).toContain('See the next report')
42
+ expect(html).toContain('usage-report.json')
43
+ expect(html).toContain('&quot;topology&quot;: 3')
44
+ })
45
+
46
+ it('names the control that fixed the choice', () => {
47
+ expect(render(make({ state: 'off', source: 'deployment', canChange: false }))).toContain('Radar Cloud manages')
48
+ const helm = make({ state: 'on', source: 'env', canChange: false, shared: true })
49
+ expect(render({ ...helm, preview: { ...helm.preview, mode: 'in-cluster' } })).toContain('Helm value usageReporting.enabled')
50
+ expect(render(make({ state: 'off', source: 'env', canChange: false }))).toContain('RADAR_USAGE_REPORTING=off')
51
+ })
52
+ })
53
+
54
+ describe('PrivacySection intro', () => {
55
+ it('does not claim data stays on this machine for an in-cluster install', () => {
56
+ const base = make({ state: 'off', source: 'deployment', canChange: false })
57
+ const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
58
+ expect(html).toContain('from inside it')
59
+ expect(html).not.toContain('from this machine')
60
+ })
61
+ })
62
+
63
+ describe('PrivacySection on a shared Radar', () => {
64
+ it('points at the Helm value while nothing set it', () => {
65
+ const base = make({ shared: true, canChange: false })
66
+ const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
67
+ expect(html).toContain('Applies to everyone using this Radar.')
68
+ expect(html).toContain('usageReporting.enabled')
69
+ expect(html).toContain('aria-checked="false"')
70
+ })
71
+
72
+ it('uses the environment variable outside a cluster', () => {
73
+ expect(render(make({ shared: true, canChange: false }))).toContain('RADAR_USAGE_REPORTING=on')
74
+ })
75
+ })
76
+
77
+ describe('PrivacySection storage and log wording', () => {
78
+ it('says logs, not sends, for development builds', () => {
79
+ const html = render(make({ state: 'on', source: 'user', developmentBuild: true, nextReportAt: '2026-09-25T12:00:00Z' }))
80
+ expect(html).toContain('logs')
81
+ expect(html).toContain('reports go to the log, never sent')
82
+ expect(render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-25T12:00:00Z' }))).toContain('sends')
83
+ })
84
+ })
@@ -0,0 +1,140 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ExternalLink } from 'lucide-react'
3
+ import { Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui/components/ui/Collapse'
4
+ import { ConfigToggle, SubHeading } from './controls'
5
+ import { isRecording, useSetUsageData, useUsageData, type UsageDataStatus } from '../../api/usage-data'
6
+
7
+ const DOCS_URL = 'https://radarhq.io/docs/configuration/usage-stats'
8
+
9
+ export function PrivacySection({ active }: { active: boolean }) {
10
+ const { data: status, isLoading, error, refetch } = useUsageData(active, { fresh: true })
11
+ const setUsageData = useSetUsageData()
12
+
13
+ // The pane stays mounted while hidden, so refetch each time it is revealed.
14
+ useEffect(() => {
15
+ if (active) void refetch()
16
+ }, [active, refetch])
17
+
18
+ if (isLoading) {
19
+ return <p className="text-sm text-theme-text-tertiary">Loading…</p>
20
+ }
21
+ if (error || !status) {
22
+ return <p className="text-sm text-theme-text-secondary">Couldn't load privacy settings.</p>
23
+ }
24
+
25
+ const recording = isRecording(status)
26
+
27
+ return (
28
+ <div className="space-y-6">
29
+ <p className="text-sm text-theme-text-secondary">
30
+ {status.preview.mode === 'in-cluster' || status.preview.mode === 'cloud'
31
+ ? 'Radar reads this cluster from inside it and keeps what it reads there.'
32
+ : 'Radar reads your cluster from this machine and keeps what it reads here.'}
33
+ </p>
34
+
35
+ <section className="space-y-3">
36
+ <SubHeading>Usage stats</SubHeading>
37
+ <ConfigToggle
38
+ label="Send anonymous usage stats"
39
+ description={<>Shows us which features get used, so we know what to build next.<br />Sent straight to us, with no third-party trackers.</>}
40
+ value={recording}
41
+ disabled={!status.canChange || setUsageData.isPending}
42
+ onChange={(v) => setUsageData.mutate(v)}
43
+ />
44
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs">
45
+ <dt className="text-theme-text-tertiary">What's sent</dt>
46
+ <dd className="text-theme-text-secondary">
47
+ The views you open, the actions and MCP tools you use, and each cluster's version,
48
+ platform, size range and known integrations.
49
+ </dd>
50
+ <dt className="text-theme-text-tertiary">What isn't</dt>
51
+ <dd className="text-theme-text-secondary">
52
+ Names, contents, logs, identifiers, timestamps, or the resources in your clusters.
53
+ </dd>
54
+ </dl>
55
+ <ManagedReason status={status} />
56
+ <SharedNote status={status} />
57
+ <ReportPreview status={status} />
58
+ </section>
59
+
60
+ </div>
61
+ )
62
+ }
63
+
64
+ function ManagedReason({ status }: { status: UsageDataStatus }) {
65
+ let text: string | null = null
66
+ switch (status.source) {
67
+ case 'env':
68
+ text = status.shared && status.preview.mode === 'in-cluster'
69
+ ? 'Set for everyone by the Helm value usageReporting.enabled.'
70
+ : `Set by RADAR_USAGE_REPORTING=${status.state}.`
71
+ break
72
+ case 'deployment':
73
+ text = 'Radar Cloud manages usage data for this installation.'
74
+ break
75
+ }
76
+ if (!text && status.developmentBuild && isRecording(status)) {
77
+ text = 'Development build: reports go to the log, never sent.'
78
+ }
79
+ return text ? <p className="text-xs text-theme-text-tertiary">{text}</p> : null
80
+ }
81
+
82
+ // A shared Radar is decided by its configuration; say where, while nothing set it.
83
+ function SharedNote({ status }: { status: UsageDataStatus }) {
84
+ if (!status.shared || status.source !== 'default') return null
85
+ return (
86
+ <p className="text-xs text-theme-text-tertiary">
87
+ Applies to everyone using this Radar. Set it with{' '}
88
+ {status.preview.mode === 'in-cluster'
89
+ ? <>the Helm value <code className="inline-code">usageReporting.enabled</code></>
90
+ : <code className="inline-code">RADAR_USAGE_REPORTING=on</code>}.
91
+ </p>
92
+ )
93
+ }
94
+
95
+ function ReportPreview({ status }: { status: UsageDataStatus }) {
96
+ const recording = isRecording(status)
97
+ const [open, setOpen] = useState(false)
98
+ const disclosure = useDisclosure(open)
99
+ // Development builds write the report to the log instead of sending it.
100
+ const logsOnly = status.developmentBuild
101
+ const next = status.nextReportAt ? new Date(status.nextReportAt) : null
102
+ return (
103
+ <div className="space-y-1.5">
104
+ <div className="flex items-center justify-between gap-3">
105
+ <button
106
+ type="button"
107
+ onClick={() => setOpen(o => !o)}
108
+ {...disclosure.buttonProps}
109
+ className="flex items-center gap-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
110
+ >
111
+ <CollapseChevron open={open} />
112
+ {recording ? 'See the next report' : 'See an example report'}
113
+ {recording && next && (
114
+ <span className="font-normal text-theme-text-tertiary">
115
+ {' '}· {logsOnly ? 'logs' : 'sends'} {next.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
116
+ </span>
117
+ )}
118
+ </button>
119
+ <a
120
+ href={DOCS_URL}
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:underline"
124
+ >
125
+ How it works <ExternalLink className="w-3 h-3" aria-hidden />
126
+ </a>
127
+ </div>
128
+ <Collapse open={open} id={disclosure.panelId}>
129
+ <pre className="max-h-80 overflow-auto rounded-md border border-theme-border bg-theme-elevated px-3 py-2 text-[11px] leading-relaxed font-mono text-theme-text-primary">
130
+ {JSON.stringify(status.preview, null, 2)}
131
+ </pre>
132
+ </Collapse>
133
+ {recording && status.canChange && (
134
+ <p className="text-xs text-theme-text-tertiary">
135
+ Waiting in <code className="inline-code">~/.radar/usage-report.json</code>. Turning this off deletes it.
136
+ </p>
137
+ )}
138
+ </div>
139
+ )
140
+ }
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
3
3
  import {
4
4
  Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug,
5
5
  Plus, Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
6
- LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins,
6
+ LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins, EyeOff,
7
7
  type LucideIcon,
8
8
  } from 'lucide-react'
9
9
  import { clsx } from 'clsx'
@@ -21,6 +21,8 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
21
21
  import { Tooltip } from '../ui/Tooltip'
22
22
  import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
23
23
  import { MyPermissionsContent } from './MyPermissionsDialog'
24
+ import { PrivacySection } from './PrivacySection'
25
+ import { ConfigToggle, SubHeading } from './controls'
24
26
  import { useDiagnose } from '../diagnose/DiagnoseContext'
25
27
  import { currencyOptionsForValue } from './currency-options'
26
28
  import { versionUpdateURL } from '../../utils/version'
@@ -449,6 +451,7 @@ export function SettingsDialog({
449
451
  { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
450
452
  { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
451
453
  { id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
454
+ { id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
452
455
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
453
456
  ]
454
457
 
@@ -510,6 +513,7 @@ export function SettingsDialog({
510
513
  Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
511
514
  <span className="text-theme-text-disabled"> · by Skyhook</span>
512
515
  </span>
516
+
513
517
  </div>
514
518
  </div>
515
519
  <button
@@ -562,10 +566,10 @@ export function SettingsDialog({
562
566
  </div>
563
567
  )}
564
568
 
565
- {!configData && !['overview', 'perms', 'ai'].includes(section) ? (
569
+ {!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
566
570
  <p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
567
571
  ) : <>
568
- {operatorManaged && section !== 'perms' && section !== 'ai' && <div className="mb-4"><OperatorManagedNotice /></div>}
572
+ {operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
569
573
  {/* Overview — status at a glance; the landing section */}
570
574
  <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
571
575
  <div className="mb-1">
@@ -796,6 +800,12 @@ export function SettingsDialog({
796
800
  )}
797
801
  </div>
798
802
 
803
+ {/* Privacy: the usage-stats choice. Readable by everyone; only a local
804
+ install can change it here. */}
805
+ <SectionPane id="privacy" active={section} title="Privacy" caption="Applies immediately." live>
806
+ <PrivacySection active={section === 'privacy'} />
807
+ </SectionPane>
808
+
799
809
  {/* Advanced — MCP + Timeline merged */}
800
810
  <SectionPane
801
811
  id="advanced"
@@ -954,16 +964,6 @@ interface NavItemDef {
954
964
  dirty: boolean
955
965
  }
956
966
 
957
- // Light subheading separating the two field groups inside a merged pane
958
- // (Cluster/Server, MCP/Timeline).
959
- function SubHeading({ children }: { children: ReactNode }) {
960
- return (
961
- <h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
962
- {children}
963
- </h4>
964
- )
965
- }
966
-
967
967
  function NavItem({
968
968
  item,
969
969
  active,
@@ -2798,45 +2798,6 @@ function ConfigNumberField({
2798
2798
  )
2799
2799
  }
2800
2800
 
2801
- function ConfigToggle({
2802
- label,
2803
- description,
2804
- value,
2805
- onChange,
2806
- }: {
2807
- label: string
2808
- description?: string
2809
- value: boolean
2810
- onChange: (value: boolean) => void
2811
- }) {
2812
- return (
2813
- <label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
2814
- <span className="min-w-0">
2815
- <span className="block text-sm text-theme-text-primary">{label}</span>
2816
- {description && (
2817
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
2818
- )}
2819
- </span>
2820
- <button
2821
- role="switch"
2822
- aria-checked={value}
2823
- onClick={() => onChange(!value)}
2824
- className={clsx(
2825
- 'relative w-9 h-5 shrink-0 rounded-full transition-colors',
2826
- value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
2827
- )}
2828
- >
2829
- <span
2830
- className={clsx(
2831
- 'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
2832
- value && 'translate-x-4'
2833
- )}
2834
- />
2835
- </button>
2836
- </label>
2837
- )
2838
- }
2839
-
2840
2801
  function EffectiveHint({
2841
2802
  current,
2842
2803
  effective,
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+
4
+ export function SubHeading({ children }: { children: ReactNode }) {
5
+ return (
6
+ <h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
7
+ {children}
8
+ </h4>
9
+ )
10
+ }
11
+
12
+ export function ConfigToggle({
13
+ label,
14
+ description,
15
+ value,
16
+ disabled,
17
+ onChange,
18
+ }: {
19
+ label: string
20
+ description?: ReactNode
21
+ value: boolean
22
+ disabled?: boolean
23
+ onChange: (value: boolean) => void
24
+ }) {
25
+ return (
26
+ <label className={clsx('flex items-start justify-between gap-3 py-1', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
27
+ <span className="min-w-0">
28
+ <span className={clsx('block text-sm', disabled ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>{label}</span>
29
+ {description && (
30
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
31
+ )}
32
+ </span>
33
+ <button
34
+ type="button"
35
+ role="switch"
36
+ aria-checked={value}
37
+ aria-label={label}
38
+ disabled={disabled}
39
+ onClick={() => onChange(!value)}
40
+ className={clsx(
41
+ 'relative w-9 h-5 shrink-0 rounded-full transition-colors',
42
+ value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border',
43
+ disabled && 'opacity-50'
44
+ )}
45
+ >
46
+ <span
47
+ className={clsx(
48
+ 'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
49
+ value && 'translate-x-4'
50
+ )}
51
+ />
52
+ </button>
53
+ </label>
54
+ )
55
+ }
@@ -7,6 +7,7 @@ export type SettingsSectionId =
7
7
  | 'argocd'
8
8
  | 'ai'
9
9
  | 'advanced'
10
+ | 'privacy'
10
11
 
11
12
  export function shouldOfferCostReview(
12
13
  costIntegrationDirty: boolean,
@@ -1,5 +1,6 @@
1
1
  import { Component, ReactNode } from 'react'
2
2
  import { AlertTriangle, RefreshCw } from 'lucide-react'
3
+ import { recordUsageEvent } from '../../api/usage-data'
3
4
 
4
5
  interface Props {
5
6
  children: ReactNode
@@ -12,6 +13,8 @@ interface Props {
12
13
  * while nothing is wrong.
13
14
  */
14
15
  resetKey: string | number
16
+ /** Fixed name of the screen inside, counted in usage stats when it crashes. */
17
+ usageLabel?: string
15
18
  }
16
19
 
17
20
  interface State {
@@ -34,6 +37,8 @@ export class ErrorBoundary extends Component<Props, State> {
34
37
 
35
38
  componentDidCatch(error: Error, info: React.ErrorInfo) {
36
39
  console.error('[ErrorBoundary]', error, info.componentStack)
40
+ // Only which screen crashed is counted, never the message.
41
+ if (this.props.usageLabel) recordUsageEvent({ type: 'ui', name: `ui_error:${this.props.usageLabel}` })
37
42
  }
38
43
 
39
44
  handleReset = () => this.setState({ hasError: false, error: null })
@@ -7,6 +7,7 @@ import { TRANSITION_BACKDROP, TRANSITION_MENU, TW_EASE_UI, overlayExitMs, overla
7
7
  import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
8
8
  import { getResourceIcon } from '../../utils/resource-icons'
9
9
  import type { SearchHit, SearchMatchedField } from '../../api/client'
10
+ import { recordUsageEvent } from '../../api/usage-data'
10
11
  import { bestScore, type CommandItem } from './command-items'
11
12
  import { SearchSyntaxHelp } from './SearchSyntaxHelp'
12
13
 
@@ -182,6 +183,21 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
182
183
 
183
184
  useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
184
185
 
186
+ // Usage data: one count per opening, one per opening that searched. Never
187
+ // the query itself.
188
+ const searchedThisOpen = useRef(false)
189
+ useEffect(() => {
190
+ if (!open) return
191
+ searchedThisOpen.current = false
192
+ recordUsageEvent({ type: 'ui', name: 'command_palette' })
193
+ }, [open])
194
+ useEffect(() => {
195
+ if (open && text.trim() && !searchedThisOpen.current) {
196
+ searchedThisOpen.current = true
197
+ recordUsageEvent({ type: 'ui', name: 'search' })
198
+ }
199
+ }, [open, text])
200
+
185
201
  // Hero autofocus parks the cursor in the field on mount (Home's primary
186
202
  // action) but must NOT pop the dropdown — landing on the page shouldn't dim
187
203
  // it behind a command palette. Suppress exactly the programmatic focus; a
@@ -16,6 +16,17 @@ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
16
16
 
17
17
  const DISMISSED_KEY = 'radar-update-dismissed'
18
18
 
19
+ // Whether the notice for this release was dismissed. The usage-data card
20
+ // shares the corner and waits for it.
21
+ export function isUpdateDismissed(latestVersion: string): boolean {
22
+ try {
23
+ return localStorage.getItem(DISMISSED_KEY) === latestVersion
24
+ } catch {
25
+ // localStorage unavailable (e.g. Safari private mode)
26
+ return false
27
+ }
28
+ }
29
+
19
30
  export function UpdateNotification() {
20
31
  const queryClient = useQueryClient()
21
32
  const { data: capabilities } = useCapabilities()
@@ -54,15 +65,8 @@ export function UpdateNotification() {
54
65
 
55
66
  // Check if this version was already dismissed
56
67
  useEffect(() => {
57
- if (versionInfo?.latestVersion) {
58
- try {
59
- const dismissedVersion = localStorage.getItem(DISMISSED_KEY)
60
- if (dismissedVersion === versionInfo.latestVersion) {
61
- setDismissed(true)
62
- }
63
- } catch {
64
- // localStorage unavailable (e.g. Safari private mode)
65
- }
68
+ if (versionInfo?.latestVersion && isUpdateDismissed(versionInfo.latestVersion)) {
69
+ setDismissed(true)
66
70
  }
67
71
  }, [versionInfo?.latestVersion])
68
72
 
@@ -15,6 +15,7 @@ import {
15
15
  AlertTriangle,
16
16
  Boxes,
17
17
  Server,
18
+ Megaphone,
18
19
  } from "lucide-react";
19
20
  import { useNamespaces, useContexts } from "../../api/client";
20
21
  import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
@@ -124,6 +125,8 @@ export interface CommandItemCallbacks {
124
125
  onSetNamespaces: (ns: string[]) => void;
125
126
  onToggleTheme: () => void;
126
127
  onShowDiagnostics?: () => void;
128
+ // Present only when release notes exist for this version.
129
+ onShowWhatsNew?: () => void;
127
130
  }
128
131
 
129
132
  const VIEW_ENTRIES: {
@@ -262,6 +265,16 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
262
265
  shortcut: "t",
263
266
  action: () => cb.onToggleTheme(),
264
267
  });
268
+ if (cb.onShowWhatsNew) {
269
+ result.push({
270
+ id: "action-whats-new",
271
+ label: "What's new",
272
+ category: "Actions",
273
+ icon: Megaphone,
274
+ action: () => cb.onShowWhatsNew?.(),
275
+ searchTerms: ["release notes", "changelog", "updates", "new features"],
276
+ });
277
+ }
265
278
  if (cb.onShowDiagnostics) {
266
279
  result.push({
267
280
  id: "action-diagnostics",
@@ -285,5 +298,6 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
285
298
  cb.onSetNamespaces,
286
299
  cb.onToggleTheme,
287
300
  cb.onShowDiagnostics,
301
+ cb.onShowWhatsNew,
288
302
  ]);
289
303
  }
@@ -0,0 +1,41 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { UsageDataStatus } from '../../api/usage-data'
4
+ import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
5
+
6
+ vi.mock('../../api/usage-data', () => ({
7
+ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
8
+ useMarkUsagePromptShown: () => vi.fn(),
9
+ }))
10
+
11
+ import { UsageDataAsk } from './UsageDataAsk'
12
+
13
+ function status(patch: Partial<UsageDataStatus>): UsageDataStatus {
14
+ return exampleStatus({ preview: exampleReport({ views: {} }), ...patch })
15
+ }
16
+
17
+ const render = (s: UsageDataStatus | undefined) =>
18
+ renderToString(<UsageDataAsk usageData={s} onReadMore={() => {}} />)
19
+
20
+ describe('UsageDataAsk', () => {
21
+ it('asks an undecided user who can decide', () => {
22
+ const html = render(status({}))
23
+ expect(html).toContain('Help improve Radar')
24
+ expect(html).toContain('Anonymous, with no third-party trackers.')
25
+ expect(html).toContain('See what')
26
+ expect(html).toContain('Send usage stats')
27
+ })
28
+
29
+ it('stays out of the way once answered or where the viewer cannot decide', () => {
30
+ expect(render(status({ state: 'on', source: 'user' }))).toBe('')
31
+ expect(render(status({ state: 'off', source: 'user' }))).toBe('')
32
+ expect(render(status({ state: 'off', source: 'deployment', canChange: false }))).toBe('')
33
+ expect(render(status({ state: 'off', source: 'env', canChange: false }))).toBe('')
34
+ expect(render(status({ shared: true, canChange: false, ask: false }))).toBe('')
35
+ expect(render(undefined)).toBe('')
36
+ })
37
+
38
+ it('waits when the server says it asked recently', () => {
39
+ expect(render(status({ ask: false }))).toBe('')
40
+ })
41
+ })