@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.14.6",
3
+ "version": "1.15.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -49,6 +49,9 @@ import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/Capabil
49
49
  import { UserMenu } from './components/UserMenu'
50
50
  import { ErrorBoundary } from './components/ui/ErrorBoundary'
51
51
  import { UpdateNotification } from './components/ui/UpdateNotification'
52
+ import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './components/whats-new/WhatsNew'
53
+ import { useUsageData, useUsageRecording } from './api/usage-data'
54
+ import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt'
52
55
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
53
56
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
54
57
  import { useEventSource } from './hooks/useEventSource'
@@ -67,7 +70,7 @@ import { Tooltip } from './components/ui/Tooltip'
67
70
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
68
71
  import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
69
72
  import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
70
- import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
73
+ import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource, withCrossViewParams } from './utils/navigation'
71
74
  import { findSelectedTopologyNode } from './utils/topology-selection'
72
75
  import { type OmnibarHandle } from './components/ui/Omnibar'
73
76
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
@@ -144,6 +147,28 @@ function getViewFromPath(pathname: string): ExtendedMainView {
144
147
  return 'home'
145
148
  }
146
149
 
150
+ // The usage-data name for the current screen: a resource list names its
151
+ // kind's plural ("resources:deployments"); the server keeps built-in kinds
152
+ // only, so a custom resource's name never leaves.
153
+ function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean): string {
154
+ if (upgrade) return 'upgrade'
155
+ if (view === 'resources') {
156
+ const plural = pathname.match(/^\/resources\/([^/]+)/)?.[1]
157
+ if (plural) return `resources:${plural.toLowerCase()}`
158
+ }
159
+ return view
160
+ }
161
+
162
+ // The screen a crash is counted under. Fixed names, because release builds
163
+ // shorten component names and the component stack can't say which screen.
164
+ const CRASH_LABELS: Record<ExtendedMainView, string> = {
165
+ home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
166
+ issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
167
+ cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
168
+ applications: 'Applications', workload: 'Workload', compare: 'Compare',
169
+ investigations: 'Investigations',
170
+ }
171
+
147
172
  // The namespace scope filter is meaningful only on namespaced surfaces. On
148
173
  // cluster-scoped views it does nothing, so we disable it with an explanation
149
174
  // rather than leaving a dead control that silently ignores the pick:
@@ -417,6 +442,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
417
442
  const mainView = getViewFromPath(location.pathname)
418
443
  const upgradeReadinessRoute = location.pathname.startsWith('/checks/upgrade')
419
444
 
445
+ // Opt-in usage data. Embedded hosts own their own consent, so Radar never asks there.
446
+ const usageData = useUsageData(!navCustomization.embedded)
447
+ // A status cached by another screen must not start recording inside a host.
448
+ useUsageRecording(usageView(location.pathname, mainView, upgradeReadinessRoute), navCustomization.embedded ? undefined : usageData.data)
449
+
420
450
  // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
421
451
  // mount) so the omnibar can open a CRD hit with an irregular plural from any
422
452
  // view — kindToPlural would otherwise English-guess the route before a
@@ -492,6 +522,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
492
522
  navigate({ pathname: path, search: newParams.toString() })
493
523
  }, [location.search, navigate, takeover, goHost])
494
524
 
525
+ const whatsNewStatus = useWhatsNewStatus()
526
+
527
+ const navigateToPath = useCallback((path: string) => {
528
+ navigate(withCrossViewParams(path, location.search))
529
+ }, [location.search, navigate])
530
+
495
531
  // The standalone rail expresses intent to leave the full-width investigation
496
532
  // workspace. Close it before routing so the destination is immediately visible;
497
533
  // docked investigations stay open across views as a persistent side panel.
@@ -777,13 +813,14 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
777
813
  metadata: { namespace: resource.namespace ?? '', name: resource.name },
778
814
  })
779
815
  if (gitOpsPath) {
780
- const destination = new URL(gitOpsPath, window.location.origin)
781
- if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
816
+ const destination = new URL(withCrossViewParams(gitOpsPath, searchParams.toString()), window.location.origin)
817
+ if (investigationRunID === null) destination.searchParams.delete('ai-run')
818
+ else if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
782
819
  navigate(`${destination.pathname}${destination.search}${destination.hash}`)
783
820
  return
784
821
  }
785
822
  navigateToResourceList(resource, investigationRunID)
786
- }, [navigate, navigateToHelmRelease, navigateToResourceList])
823
+ }, [navigate, navigateToHelmRelease, navigateToResourceList, searchParams])
787
824
 
788
825
  // Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
789
826
  // resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
@@ -1732,6 +1769,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1732
1769
  showPinToggle={!railForcedSlim}
1733
1770
  onOpenSettings={() => openSettings()}
1734
1771
  accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
1772
+ whatsNew={whatsNewStatus.available ? { unread: whatsNewStatus.unread, onOpen: openWhatsNew } : undefined}
1735
1773
  />
1736
1774
  )}
1737
1775
  {/* `relative` makes this column the containing block for the absolute
@@ -1833,6 +1871,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1833
1871
  onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
1834
1872
  onToggleTheme={toggleTheme}
1835
1873
  onShowDiagnostics={() => setShowDiagnostics(true)}
1874
+ onShowWhatsNew={whatsNewStatus.available ? openWhatsNew : undefined}
1836
1875
  onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
1837
1876
  />
1838
1877
  </div>
@@ -1977,7 +2016,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1977
2016
  {/* Search included, not just the path: selection inside a view rides in
1978
2017
  the query (?resource=, ?release=), so a path-only key would still
1979
2018
  strand a crash on the view that produced it. */}
1980
- <ErrorBoundary resetKey={location.pathname + location.search}>
2019
+ <ErrorBoundary
2020
+ resetKey={location.pathname + location.search}
2021
+ usageLabel={upgradeReadinessRoute ? 'Upgrade' : CRASH_LABELS[mainView]}
2022
+ >
1981
2023
  {/* Initial sync in progress: views that need the full cluster dataset
1982
2024
  show per-kind progress instead; resource views work as kinds sync. */}
1983
2025
  {viewsSyncGated && connection.syncStatus && (
@@ -1994,7 +2036,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1994
2036
  fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
1995
2037
  onNavigateToView={setMainView}
1996
2038
  onNavigateToHelmRelease={navCustomization.embedded ? undefined : navigateToHelmRelease}
1997
- onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined : (path) => navigate(path)}
2039
+ onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined : navigateToPath}
2040
+ onShowWhatsNew={!navCustomization.embedded && whatsNewStatus.available ? openWhatsNew : undefined}
1998
2041
  // Upgrade impact lives under /checks, which a Cloud host takes
1999
2042
  // over wholesale — its fleet pages have no upgrade sub-route, so
2000
2043
  // the version line stays plain text there.
@@ -2435,6 +2478,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2435
2478
 
2436
2479
  {/* Update notification — hidden in embedded mode (OSS download nudge). */}
2437
2480
  {!navCustomization.embedded && <UpdateNotification />}
2481
+ {!navCustomization.embedded && <WhatsNew onNavigate={navigateToPath} usageData={usageData.data} />}
2482
+ {!navCustomization.embedded && <UsageDataPrompt status={usageData.data} />}
2438
2483
 
2439
2484
  {/* Bottom Dock for Terminal/Logs */}
2440
2485
  <BottomDock />
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { ApiError, useKueueAdmission as admissionQuery } from './client'
4
+
5
+ vi.mock('@tanstack/react-query', async (original) => ({ ...await original<typeof import('@tanstack/react-query')>(), useQuery: vi.fn(() => ({})) }))
6
+
7
+ describe('Kueue admission polling', () => {
8
+ function options(settings?: Parameters<typeof admissionQuery>[3]) {
9
+ admissionQuery('ml', 'training', 'current', settings)
10
+ return vi.mocked(useQuery).mock.calls.at(-1)![0]
11
+ }
12
+ function interval(settings: Parameters<typeof admissionQuery>[3], state: any = {}) {
13
+ const poll = options(settings).refetchInterval as (query: any) => number | false
14
+ return poll({ state })
15
+ }
16
+ it('separates Job and JobSet identities and recreated roots', () => {
17
+ expect(options({ isJob: true }).queryKey).toEqual(['kueue-admission', 'batch', 'jobs', 'ml', 'training', 'current'])
18
+ expect(options({ isRayJob: true }).queryKey).toEqual(['kueue-admission', 'ray.io', 'rayjobs', 'ml', 'training', 'current'])
19
+ expect(options().queryKey).toEqual(['kueue-admission', 'jobset.x-k8s.io', 'jobsets', 'ml', 'training', 'current'])
20
+ })
21
+ it('polls quiet and terminal Jobs slowly, active admission quickly', () => {
22
+ expect(interval({ isJob: true })).toBe(30000)
23
+ expect(interval({ isJob: true, hinted: true })).toBe(5000)
24
+ expect(interval({ isJob: true }, { data: { workloads: [{}] } })).toBe(5000)
25
+ expect(interval({ isJob: true, hinted: true, terminal: true })).toBe(30000)
26
+ expect(interval(undefined)).toBe(5000)
27
+ })
28
+ it('stops on confirmed absence or denied requests, not transient outages or old absence', () => {
29
+ expect(interval({ isJob: true }, { data: { uid: 'current', installed: false } })).toBe(false)
30
+ expect(interval({ isJob: true, hinted: true }, { data: { uid: 'old', installed: false } })).toBe(5000)
31
+ expect(interval({ isJob: true }, { error: new ApiError('denied', 403) })).toBe(false)
32
+ expect(interval({ isJob: true, hinted: true }, { error: new ApiError('warming', 503) })).toBe(5000)
33
+ })
34
+ })
package/src/api/client.ts CHANGED
@@ -1604,6 +1604,37 @@ export function useClusterInfo() {
1604
1604
  export type InstallMethod =
1605
1605
  "homebrew" | "krew" | "scoop" | "direct" | "desktop";
1606
1606
 
1607
+ export interface WhatsNewState {
1608
+ currentVersion: string;
1609
+ // "server": seenVersion / priorInstall are authoritative (local installs).
1610
+ // "browser": the client keeps its own record (in-cluster).
1611
+ storage: "server" | "browser";
1612
+ seenVersion?: string;
1613
+ priorInstall?: boolean;
1614
+ }
1615
+
1616
+ export function useWhatsNewState(enabled: boolean) {
1617
+ return useQuery<WhatsNewState>({
1618
+ queryKey: ["whats-new", getApiBase()],
1619
+ queryFn: () => fetchJSON("/whats-new"),
1620
+ enabled,
1621
+ // Refetched on focus (the app default is off), so another tab's
1622
+ // acknowledgment clears this tab's dot.
1623
+ staleTime: 0,
1624
+ refetchOnWindowFocus: true,
1625
+ retry: false,
1626
+ });
1627
+ }
1628
+
1629
+ export async function markWhatsNewSeen(version: string): Promise<void> {
1630
+ const response = await apiFetch(`${getApiBase()}/whats-new/seen`, {
1631
+ method: "POST",
1632
+ headers: { "Content-Type": "application/json" },
1633
+ body: JSON.stringify({ version }),
1634
+ });
1635
+ if (!response.ok) throw new ApiError(`HTTP ${response.status}`, response.status);
1636
+ }
1637
+
1607
1638
  export interface VersionInfo {
1608
1639
  currentVersion: string;
1609
1640
  latestVersion?: string;
@@ -6905,15 +6936,18 @@ export function useWorkloadRuns(
6905
6936
  });
6906
6937
  }
6907
6938
 
6908
- export function useKueueAdmission(namespace: string, name: string, uid: string | undefined) {
6939
+ export function useKueueAdmission(namespace: string, name: string, uid: string | undefined, options?: { isJob?: boolean; isRayJob?: boolean; hinted?: boolean; terminal?: boolean }) {
6940
+ const group = options?.isRayJob ? 'ray.io' : options?.isJob ? 'batch' : 'jobset.x-k8s.io'
6941
+ const kind = options?.isRayJob ? 'rayjobs' : options?.isJob ? 'jobs' : 'jobsets'
6909
6942
  return useQuery<KueueAdmissionResponse>({
6910
- queryKey: ['kueue-admission', 'jobset.x-k8s.io', namespace, name, uid],
6911
- queryFn: () => fetchJSON(`/kueue/admission/jobsets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?group=jobset.x-k8s.io`),
6943
+ queryKey: ['kueue-admission', group, kind, namespace, name, uid],
6944
+ queryFn: () => fetchJSON(`/kueue/admission/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?group=${group}`),
6912
6945
  enabled: Boolean(namespace && name && uid),
6913
6946
  staleTime: 5000,
6914
6947
  refetchInterval: (query) => {
6915
6948
  if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
6916
6949
  if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
6950
+ if ((options?.isJob || options?.isRayJob) && (options.terminal || (!options.hinted && !query.state.data?.workloads.length))) return 30000
6917
6951
  return 5000
6918
6952
  },
6919
6953
  retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
@@ -0,0 +1,33 @@
1
+ import type { UsageDataStatus, UsageReport } from './usage-data'
2
+
3
+ // Test-only: a report and status shaped like the server's.
4
+ export function exampleReport(patch: Partial<UsageReport> = {}): UsageReport {
5
+ return {
6
+ schema: 3,
7
+ version: '1.15.0', os: 'darwin', arch: 'arm64', installMethod: 'homebrew', mode: 'local',
8
+ periodStart: '2026-09-23', periodEnd: '2026-09-24',
9
+ setup: {
10
+ authMode: 'none', timelineStorage: 'memory', mcpEnabled: true, prometheus: 'none',
11
+ costSource: 'auto', browsers: ['chrome'],
12
+ },
13
+ engagement: { sessions: 2, activeMinutes: '20-49' },
14
+ views: {}, actions: {}, mcpTools: {}, uiEvents: {}, errors: {},
15
+ clusters: {
16
+ contexts: '5-9', used: 1,
17
+ shapes: [{ kubernetesVersion: '1.33', platform: 'eks', nodes: '10-19', integrations: ['argo-cd'] }],
18
+ },
19
+ ...patch,
20
+ }
21
+ }
22
+
23
+ export function exampleStatus(patch: Partial<UsageDataStatus> = {}): UsageDataStatus {
24
+ return {
25
+ state: 'undecided', source: 'default', canChange: true,
26
+ developmentBuild: false,
27
+ preview: exampleReport(),
28
+ firstRunPrompt: false,
29
+ ask: true,
30
+ shared: false,
31
+ ...patch,
32
+ }
33
+ }
@@ -0,0 +1,165 @@
1
+ import { useCallback, useEffect } from 'react'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { fetchJSON } from './client'
4
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from './config'
5
+
6
+ export type UsageDataState = 'undecided' | 'on' | 'off'
7
+ export type UsageDataSource = 'default' | 'user' | 'env' | 'deployment'
8
+
9
+ export interface ClusterShape {
10
+ kubernetesVersion: string
11
+ platform: string
12
+ nodes: string
13
+ integrations: string[]
14
+ }
15
+
16
+ export interface UsageReport {
17
+ schema: number
18
+ version: string
19
+ os: string
20
+ arch: string
21
+ installMethod: string
22
+ mode: string
23
+ periodStart: string
24
+ periodEnd: string
25
+ setup: {
26
+ authMode: string
27
+ timelineStorage: string
28
+ mcpEnabled: boolean
29
+ prometheus: string
30
+ costSource: string
31
+ browsers: string[]
32
+ }
33
+ engagement: { sessions: number; activeMinutes: string }
34
+ views: Record<string, number>
35
+ actions: Record<string, number>
36
+ mcpTools: Record<string, number>
37
+ uiEvents: Record<string, number>
38
+ errors: Record<string, number>
39
+ clusters: { contexts: string; used: number; shapes: ClusterShape[] }
40
+ }
41
+
42
+ export interface UsageDataStatus {
43
+ state: UsageDataState
44
+ source: UsageDataSource
45
+ canChange: boolean
46
+ developmentBuild: boolean
47
+ nextReportAt?: string
48
+ preview: UsageReport
49
+ firstRunPrompt: boolean
50
+ // What's New may ask: never asked, or asked and left unanswered long enough ago.
51
+ ask: boolean
52
+ // Several people use this Radar, so its configuration decides and nobody is asked.
53
+ shared: boolean
54
+ }
55
+
56
+ const usageDataKey = (apiBase: string) => ['usage-data', apiBase]
57
+
58
+ // `fresh` is for the report preview: it must match what would be sent now, so
59
+ // it refetches every time it is shown instead of reusing the app shell's copy.
60
+ export function useUsageData(enabled = true, { fresh = false }: { fresh?: boolean } = {}) {
61
+ const apiBase = getApiBase()
62
+ return useQuery<UsageDataStatus>({
63
+ queryKey: usageDataKey(apiBase),
64
+ queryFn: () => fetchJSON<UsageDataStatus>('/usage-data'),
65
+ enabled,
66
+ staleTime: fresh ? 0 : 60_000,
67
+ refetchOnMount: fresh ? 'always' : true,
68
+ retry: false,
69
+ })
70
+ }
71
+
72
+ export function useSetUsageData() {
73
+ const queryClient = useQueryClient()
74
+ const apiBase = getApiBase()
75
+ return useMutation({
76
+ mutationFn: (enabled: boolean) =>
77
+ fetchJSON<UsageDataStatus>('/usage-data', {
78
+ method: 'PUT',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ enabled }),
81
+ }),
82
+ meta: { errorMessage: 'Failed to save usage data choice' },
83
+ // A status read still in flight predates the choice; it must not
84
+ // replace the answer the server just confirmed.
85
+ onSuccess: async (status) => {
86
+ const queryKey = usageDataKey(apiBase)
87
+ await queryClient.cancelQueries({ queryKey })
88
+ queryClient.setQueryData(queryKey, status)
89
+ },
90
+ })
91
+ }
92
+
93
+ // Records that the question was shown, answered or not, and stops this tab
94
+ // offering it again from its cached status. Fire and forget: a failure only
95
+ // means it might show once more.
96
+ export function useMarkUsagePromptShown() {
97
+ const queryClient = useQueryClient()
98
+ const apiBase = getApiBase()
99
+ return useCallback(() => {
100
+ const queryKey = usageDataKey(apiBase)
101
+ void queryClient.cancelQueries({ queryKey })
102
+ queryClient.setQueryData<UsageDataStatus>(queryKey, (prev) => prev && { ...prev, firstRunPrompt: false, ask: false })
103
+ void fetch(apiUrl('/usage-data/prompt-shown'), {
104
+ method: 'POST',
105
+ headers: getAuthHeaders(),
106
+ credentials: getCredentialsMode(),
107
+ }).catch(() => {})
108
+ }, [queryClient, apiBase])
109
+ }
110
+
111
+ export function isRecording(status: UsageDataStatus | undefined): boolean {
112
+ return status?.state === 'on'
113
+ }
114
+
115
+ // Set from the status query so code without hook access (error boundaries,
116
+ // event handlers) can skip the request entirely when usage data is off.
117
+ let recording = false
118
+
119
+ function setUsageRecording(on: boolean) {
120
+ recording = on
121
+ }
122
+
123
+ type UsageEvent =
124
+ | { type: 'view'; name: string }
125
+ | { type: 'ui'; name: string }
126
+ | { type: 'session' }
127
+ | { type: 'active'; minutes: number }
128
+
129
+ // Fire and forget; the server drops anything outside its allow-list.
130
+ export function recordUsageEvent(event: UsageEvent): void {
131
+ if (!recording) return
132
+ void fetch(apiUrl('/usage-data/event'), {
133
+ method: 'POST',
134
+ headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
135
+ credentials: getCredentialsMode(),
136
+ body: JSON.stringify(event),
137
+ keepalive: true,
138
+ }).catch(() => {})
139
+ }
140
+
141
+ const ACTIVE_TICK_MINUTES = 5
142
+
143
+ // Counts views, one session per page load, and visible time in five-minute
144
+ // ticks. Nothing is sent unless usage data is on.
145
+ export function useUsageRecording(view: string, status: UsageDataStatus | undefined) {
146
+ const on = isRecording(status)
147
+ useEffect(() => {
148
+ setUsageRecording(on)
149
+ }, [on])
150
+
151
+ useEffect(() => {
152
+ if (on) recordUsageEvent({ type: 'view', name: view })
153
+ }, [view, on])
154
+
155
+ useEffect(() => {
156
+ if (!on) return
157
+ recordUsageEvent({ type: 'session' })
158
+ const tick = window.setInterval(() => {
159
+ if (document.visibilityState === 'visible') {
160
+ recordUsageEvent({ type: 'active', minutes: ACTIVE_TICK_MINUTES })
161
+ }
162
+ }, ACTIVE_TICK_MINUTES * 60_000)
163
+ return () => window.clearInterval(tick)
164
+ }, [on])
165
+ }
@@ -91,9 +91,9 @@ describe('Cloud dialog connection availability', () => {
91
91
  expect(requests).not.toContain('/api/cloud/install/discover')
92
92
  expect(requests).not.toContain('/api/cloud/install/prepare')
93
93
  await act(async () => { button('How it works and what it costs')!.click() })
94
- expect(document.body.textContent).toContain('You approve the connection before anything is installed.')
94
+ expect(document.body.textContent).toContain('Radar runs in your cluster and tunnels outward to Radar Cloud')
95
95
  expect(document.body.textContent).not.toContain('Setup runs here in the app')
96
- expect(document.body.textContent).not.toContain('Nothing installs on click')
96
+ expect(document.body.textContent).not.toContain('see what gets installed')
97
97
  })
98
98
 
99
99
  it('restores cluster discovery and Connect in the open dialog when the connection recovers', async () => {