@skyhook-io/radar-app 1.14.5 → 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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -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,
@@ -37,3 +38,21 @@ export function costSourceApplyLabel(
37
38
  ): string {
38
39
  return source === 'prometheus' ? 'Apply source' : 'Test & apply source'
39
40
  }
41
+
42
+ export function prometheusHeadersFromRows(
43
+ rows: { key: string; value: string }[] | null,
44
+ ): Record<string, string> | undefined {
45
+ if (rows === null) return undefined
46
+ if (rows.length === 0) return {}
47
+ const entries: [string, string][] = []
48
+ const seen = new Set<string>()
49
+ for (const row of rows) {
50
+ const key = row.key.trim()
51
+ if (!key && !row.value) continue
52
+ if (!key || !row.value) throw new Error('Enter both a name and value for each header, or remove the incomplete row.')
53
+ if (seen.has(key.toLowerCase())) throw new Error(`Header "${key}" is entered more than once.`)
54
+ seen.add(key.toLowerCase())
55
+ entries.push([key, row.value])
56
+ }
57
+ return entries.length ? Object.fromEntries(entries) : undefined
58
+ }
@@ -10,6 +10,7 @@ import {
10
10
  buildAppMembershipIndex,
11
11
  eventsForApplication,
12
12
  isPinnedLaneRef,
13
+ pinnedLaneRefMatches,
13
14
  LIVE_TICK_MS,
14
15
  SEVERITY_TEXT,
15
16
  type ScrubberRange,
@@ -50,6 +51,13 @@ function loadPinnedLanes(): PinnedLaneRef[] {
50
51
  }
51
52
  }
52
53
 
54
+ export function togglePinnedLaneRefs(previous: PinnedLaneRef[], ref: PinnedLaneRef): PinnedLaneRef[] {
55
+ const pinned = previous.some((stored) => pinnedLaneRefMatches(stored, ref))
56
+ return pinned
57
+ ? previous.filter((stored) => !pinnedLaneRefMatches(stored, ref))
58
+ : [...previous, ref]
59
+ }
60
+
53
61
  // Helper to check if topology has meaningfully changed
54
62
  function topologyContentEqual(a: Topology | undefined, b: Topology | undefined): boolean {
55
63
  if (a === b) return true
@@ -348,9 +356,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
348
356
  }
349
357
  }, [pinnedLanes])
350
358
  const togglePin = useCallback((ref: PinnedLaneRef) => {
351
- setPinnedLanes((prev) => (
352
- prev.some((p) => p.id === ref.id) ? prev.filter((p) => p.id !== ref.id) : [...prev, ref]
353
- ))
359
+ setPinnedLanes((prev) => togglePinnedLaneRefs(prev, ref))
354
360
  }, [])
355
361
  // pinnedOnly is meaningless with no pins: unpinning the last lane (or landing
356
362
  // on ?pinnedOnly=1 with no stored pins) would otherwise leave the filter stuck
@@ -1,16 +1,33 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
2
+ import type { ActivityFilterKey, PinnedLaneRef, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
3
3
  import {
4
4
  parseTimeMode,
5
5
  resolveApplicationTimelineScope,
6
6
  writeTimelineParams,
7
7
  onlyHighFreqDiffer,
8
8
  timeModeEqual,
9
+ togglePinnedLaneRefs,
9
10
  type TimelineMode,
10
11
  type PersistedTimelineState,
11
12
  type TimelineViewMode,
12
13
  } from './TimelineView'
13
14
 
15
+ describe('togglePinnedLaneRefs', () => {
16
+ it('unpins a migrated legacy CRD pin by its canonical live identity', () => {
17
+ const legacy: PinnedLaneRef = { id: 'Cluster/prod/main', kind: 'Cluster', namespace: 'prod', name: 'main' }
18
+ const live: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
19
+
20
+ expect(togglePinnedLaneRefs([legacy], live)).toEqual([])
21
+ })
22
+
23
+ it('does not unpin a different explicit API group', () => {
24
+ const cnpg: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
25
+ const capi: PinnedLaneRef = { id: 'Cluster.cluster.x-k8s.io/prod/main', kind: 'Cluster', group: 'cluster.x-k8s.io', namespace: 'prod', name: 'main' }
26
+
27
+ expect(togglePinnedLaneRefs([cnpg], capi)).toEqual([cnpg, capi])
28
+ })
29
+ })
30
+
14
31
  // These mirror the module-private defaults writeTimelineParams omits at (and
15
32
  // parseTimeMode falls back to). Kept in sync deliberately: the round-trip tests
16
33
  // below fail loudly if a module default drifts from what is asserted here.
@@ -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: {
@@ -146,9 +149,6 @@ const VIEW_ENTRIES: {
146
149
  { view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
147
150
  ];
148
151
 
149
- // The static command-palette items (Views, Resource Kinds, Contexts,
150
- // Namespaces, Actions) — shared by the centered modal (embedded) and the
151
- // standalone omnibar so the two never drift.
152
152
  export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
153
153
  const { data: namespacesData } = useNamespaces();
154
154
  const { data: contexts } = useContexts();
@@ -265,6 +265,16 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
265
265
  shortcut: "t",
266
266
  action: () => cb.onToggleTheme(),
267
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
+ }
268
278
  if (cb.onShowDiagnostics) {
269
279
  result.push({
270
280
  id: "action-diagnostics",
@@ -288,5 +298,6 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
288
298
  cb.onSetNamespaces,
289
299
  cb.onToggleTheme,
290
300
  cb.onShowDiagnostics,
301
+ cb.onShowWhatsNew,
291
302
  ]);
292
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
+ })
@@ -0,0 +1,106 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { BarChart3, Check } from 'lucide-react'
3
+ import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
4
+
5
+ // One wording for every place Radar asks, so the question can't drift.
6
+ export function UsageDataBlurb({ onReadMore }: { onReadMore: () => void }) {
7
+ return (
8
+ <>
9
+ <p className="text-sm font-medium text-theme-text-primary">Help improve Radar</p>
10
+ <p className="mt-0.5 text-xs text-theme-text-secondary leading-relaxed">
11
+ Anonymous, with no third-party trackers.{' '}
12
+ <button
13
+ type="button"
14
+ onClick={onReadMore}
15
+ className="whitespace-nowrap text-theme-text-primary underline underline-offset-2 hover:text-accent-text"
16
+ >
17
+ See what's sent
18
+ </button>
19
+ </p>
20
+ </>
21
+ )
22
+ }
23
+
24
+ // Shown in place of the question once answered, so it doesn't vanish under
25
+ // the cursor.
26
+ export function UsageDataAnswered({ answer }: { answer: boolean }) {
27
+ return (
28
+ <>
29
+ <Check className="w-3.5 h-3.5 shrink-0 text-accent" aria-hidden />
30
+ {answer
31
+ ? 'Thanks. Change this any time in Settings > Privacy.'
32
+ : 'Nothing will be sent. Change this any time in Settings > Privacy.'}
33
+ </>
34
+ )
35
+ }
36
+
37
+ // The usage-data question, asked inside What's New when the server says to:
38
+ // someone who closed it without answering is asked again only months later,
39
+ // and a "no" is never asked again. A shared Radar, or one set by its
40
+ // configuration, never asks.
41
+ export function UsageDataAsk({ usageData, onReadMore }: {
42
+ usageData: UsageDataStatus | undefined
43
+ onReadMore: () => void
44
+ }) {
45
+ const setUsageData = useSetUsageData()
46
+ const markUsagePromptShown = useMarkUsagePromptShown()
47
+ const [answer, setAnswer] = useState<boolean | null>(null)
48
+ // Latched: recording the showing stops the server offering the question,
49
+ // and the block must not vanish while the dialog is open.
50
+ const [offered, setOffered] = useState(!!usageData?.ask)
51
+ const recorded = useRef(false)
52
+
53
+ useEffect(() => {
54
+ if (usageData?.ask) setOffered(true)
55
+ }, [usageData?.ask])
56
+ useEffect(() => {
57
+ if (offered && !recorded.current) {
58
+ recorded.current = true
59
+ markUsagePromptShown()
60
+ }
61
+ }, [offered, markUsagePromptShown])
62
+
63
+ const undecided = offered && usageData?.state === 'undecided' && usageData.canChange
64
+ if (!undecided && answer === null) return null
65
+
66
+ if (answer !== null) {
67
+ return (
68
+ <div className="flex items-center gap-2 px-6 py-3 border-t border-theme-border text-xs text-theme-text-secondary">
69
+ <UsageDataAnswered answer={answer} />
70
+ </div>
71
+ )
72
+ }
73
+
74
+ const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
75
+
76
+ return (
77
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3 px-6 py-3.5 border-t border-theme-border">
78
+ <div className="flex gap-3 min-w-0 flex-1">
79
+ <span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
80
+ <BarChart3 className="w-4 h-4" aria-hidden />
81
+ </span>
82
+ <div className="min-w-0">
83
+ <UsageDataBlurb onReadMore={onReadMore} />
84
+ </div>
85
+ </div>
86
+ <div className="flex items-center gap-2 shrink-0 pl-11 sm:pl-0">
87
+ <button
88
+ type="button"
89
+ disabled={setUsageData.isPending}
90
+ onClick={() => choose(true)}
91
+ className="px-3 py-1.5 text-xs font-medium rounded-lg border border-theme-border bg-theme-surface text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
92
+ >
93
+ Send usage stats
94
+ </button>
95
+ <button
96
+ type="button"
97
+ disabled={setUsageData.isPending}
98
+ onClick={() => choose(false)}
99
+ className="px-3 py-1.5 text-xs font-medium rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
100
+ >
101
+ No thanks
102
+ </button>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import type { UsageDataStatus } from '../../api/usage-data'
3
+
4
+ vi.mock('../../api/client', () => ({ useVersionCheck: () => ({ data: undefined }) }))
5
+ vi.mock('../../api/usage-data', () => ({ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }), useMarkUsagePromptShown: () => vi.fn() }))
6
+
7
+ import { cardStillAsking, shouldShowFirstRunPrompt, updateNoticeVisible } from './UsageDataPrompt'
8
+
9
+ const status = (firstRunPrompt: boolean) => ({ firstRunPrompt } as UsageDataStatus)
10
+
11
+ describe('shouldShowFirstRunPrompt', () => {
12
+ it('shows only when the server offers it, after the delay, with the corner free', () => {
13
+ expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: false })).toBe(true)
14
+ expect(shouldShowFirstRunPrompt({ status: status(false), ready: true, updateNoticeShowing: false })).toBe(false)
15
+ expect(shouldShowFirstRunPrompt({ status: status(true), ready: false, updateNoticeShowing: false })).toBe(false)
16
+ expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: true })).toBe(false)
17
+ expect(shouldShowFirstRunPrompt({ status: undefined, ready: true, updateNoticeShowing: false })).toBe(false)
18
+ })
19
+ })
20
+
21
+ describe('updateNoticeVisible', () => {
22
+ const store = new Map<string, string>()
23
+ vi.stubGlobal('localStorage', {
24
+ getItem: (k: string) => store.get(k) ?? null,
25
+ setItem: (k: string, v: string) => void store.set(k, v),
26
+ })
27
+ afterEach(() => store.clear())
28
+
29
+ it('is visible until that version is dismissed', () => {
30
+ expect(updateNoticeVisible('1.16.0', true)).toBe(true)
31
+ store.set('radar-update-dismissed', '1.16.0')
32
+ expect(updateNoticeVisible('1.16.0', true)).toBe(false)
33
+ expect(updateNoticeVisible('1.16.0', false)).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe('cardStillAsking', () => {
38
+ const st = (state: UsageDataStatus['state'], canChange = true) => ({ state, canChange } as UsageDataStatus)
39
+
40
+ it('keeps asking while the choice is still open', () => {
41
+ expect(cardStillAsking(st('undecided'), null)).toBe(true)
42
+ })
43
+
44
+ it('stops asking once the choice was made elsewhere', () => {
45
+ expect(cardStillAsking(st('off'), null)).toBe(false)
46
+ expect(cardStillAsking(st('on'), null)).toBe(false)
47
+ expect(cardStillAsking(st('undecided', false), null)).toBe(false)
48
+ })
49
+
50
+ it('keeps its own answer on screen', () => {
51
+ expect(cardStillAsking(st('off'), false)).toBe(true)
52
+ })
53
+ })
@@ -0,0 +1,148 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { BarChart3, X } from 'lucide-react'
4
+ import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
5
+ import { useVersionCheck } from '../../api/client'
6
+ import { isUpdateDismissed } from '../ui/UpdateNotification'
7
+ import { UsageDataAnswered, UsageDataBlurb } from './UsageDataAsk'
8
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
9
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
10
+
11
+ // Long enough that the first thing a new user sees is their cluster, not a
12
+ // question about it.
13
+ export const FIRST_RUN_PROMPT_DELAY_MS = 15_000
14
+ const THANKS_MS = 4_000
15
+
16
+ // The update notice uses the same corner. One nudge at a time.
17
+ export function updateNoticeVisible(latest: string | undefined, updateAvailable: boolean | undefined): boolean {
18
+ return !!updateAvailable && !!latest && !isUpdateDismissed(latest)
19
+ }
20
+
21
+ export function shouldShowFirstRunPrompt(opts: {
22
+ status: UsageDataStatus | undefined
23
+ ready: boolean
24
+ updateNoticeShowing: boolean
25
+ }): boolean {
26
+ return !!opts.status?.firstRunPrompt && opts.ready && !opts.updateNoticeShowing
27
+ }
28
+
29
+ // Once shown, the card stays until answered or closed, unless the choice was
30
+ // made elsewhere (another tab, Settings) while it sat unanswered.
31
+ export function cardStillAsking(status: UsageDataStatus | undefined, answer: boolean | null): boolean {
32
+ return answer !== null || (status?.state === 'undecided' && !!status?.canChange)
33
+ }
34
+
35
+ // Shown once per machine, after the first install. Upgrading installs are
36
+ // asked in What's New instead. The server records the showing, so a reload,
37
+ // another browser or the Desktop app on the same machine won't show it again.
38
+ export function UsageDataPrompt({ status }: { status: UsageDataStatus | undefined }) {
39
+ const setUsageData = useSetUsageData()
40
+ const markUsagePromptShown = useMarkUsagePromptShown()
41
+ const { data: versionInfo } = useVersionCheck()
42
+ const [ready, setReady] = useState(false)
43
+ const [, setTick] = useState(0)
44
+ // Latched once shown: the server stops offering the prompt the moment it is
45
+ // recorded, and the card must not disappear because of that.
46
+ const [latched, setLatched] = useState(false)
47
+ const [dismissed, setDismissed] = useState(false)
48
+ const [answer, setAnswer] = useState<boolean | null>(null)
49
+ const recorded = useRef(false)
50
+
51
+ const offered = !!status?.firstRunPrompt
52
+ useEffect(() => {
53
+ if (!offered) return
54
+ const delay = window.setTimeout(() => setReady(true), FIRST_RUN_PROMPT_DELAY_MS)
55
+ // Dismissing the update notice only touches localStorage, which does not
56
+ // notify this tab, so look again now and then.
57
+ const poll = window.setInterval(() => setTick((t) => t + 1), 5_000)
58
+ return () => {
59
+ window.clearTimeout(delay)
60
+ window.clearInterval(poll)
61
+ }
62
+ }, [offered])
63
+
64
+ const eligible = shouldShowFirstRunPrompt({
65
+ status,
66
+ ready,
67
+ updateNoticeShowing: updateNoticeVisible(versionInfo?.latestVersion, versionInfo?.updateAvailable),
68
+ })
69
+ useEffect(() => {
70
+ if (eligible && !recorded.current) {
71
+ recorded.current = true
72
+ setLatched(true)
73
+ markUsagePromptShown()
74
+ }
75
+ }, [eligible, markUsagePromptShown])
76
+
77
+ useEffect(() => {
78
+ if (answer === null) return
79
+ const t = window.setTimeout(() => setDismissed(true), THANKS_MS)
80
+ return () => window.clearTimeout(t)
81
+ }, [answer])
82
+
83
+ const show = latched && !dismissed && cardStillAsking(status, answer)
84
+ const { shouldRender, isOpen } = useAnimatedUnmount(show, overlayExitMs('menu'))
85
+ if (!shouldRender) return null
86
+
87
+ const readMore = () => {
88
+ setDismissed(true)
89
+ window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'privacy' } }))
90
+ }
91
+ const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
92
+
93
+ return (
94
+ <div
95
+ role="dialog"
96
+ aria-label="Help improve Radar"
97
+ inert={!show || undefined}
98
+ className={clsx(
99
+ 'fixed bottom-4 right-4 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg overflow-hidden origin-bottom-right',
100
+ TRANSITION_MENU,
101
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-1 scale-[0.97]',
102
+ !show && 'pointer-events-none',
103
+ )}
104
+ style={overlayTransitionStyle(isOpen, 'menu')}
105
+ >
106
+ {answer === null ? (
107
+ <div className="relative flex gap-3 p-3.5 pr-9">
108
+ <button
109
+ type="button"
110
+ onClick={() => setDismissed(true)}
111
+ aria-label="Close"
112
+ className="absolute top-2.5 right-2.5 p-1 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated"
113
+ >
114
+ <X className="w-3.5 h-3.5" />
115
+ </button>
116
+ <span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
117
+ <BarChart3 className="w-4 h-4" aria-hidden />
118
+ </span>
119
+ <div className="min-w-0">
120
+ <UsageDataBlurb onReadMore={readMore} />
121
+ <div className="flex items-center gap-2 mt-2.5">
122
+ <button
123
+ type="button"
124
+ disabled={setUsageData.isPending}
125
+ onClick={() => choose(true)}
126
+ className="btn-brand px-3 py-1 text-xs font-medium rounded-md whitespace-nowrap disabled:opacity-50"
127
+ >
128
+ Send usage stats
129
+ </button>
130
+ <button
131
+ type="button"
132
+ disabled={setUsageData.isPending}
133
+ onClick={() => choose(false)}
134
+ className="px-3 py-1 text-xs font-medium rounded-md text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
135
+ >
136
+ No thanks
137
+ </button>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ ) : (
142
+ <div className="flex items-center gap-2 px-4 py-3 text-xs text-theme-text-secondary">
143
+ <UsageDataAnswered answer={answer} />
144
+ </div>
145
+ )}
146
+ </div>
147
+ )
148
+ }