@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,423 @@
1
+ import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
2
+ import { useLocation, useSearchParams } from 'react-router-dom'
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import { clsx } from 'clsx'
5
+ import { ArrowRight, Check, ExternalLink, Megaphone, X } from 'lucide-react'
6
+ import { DialogPortal } from '@skyhook-io/k8s-ui'
7
+ import { getApiBase } from '../../api/config'
8
+ import { markWhatsNewSeen, useCapabilities, useWhatsNewState, type WhatsNewState } from '../../api/client'
9
+ import { compareVersions } from '../../utils/version'
10
+ import { latestReleaseNotesFor, releaseLine, releaseNotesFor, RELEASE_NOTES, type HighlightTone, type ReleaseHighlight, type ReleaseNotes } from './releaseNotes'
11
+ import type { UsageDataStatus } from '../../api/usage-data'
12
+ import { UsageDataAsk } from '../usage-data/UsageDataAsk'
13
+
14
+ const LAST_SEEN_KEY = 'radar-whats-new-seen'
15
+ const PREVIEW_PARAM = 'whats-new'
16
+ export const SHOW_WHATS_NEW_EVENT = 'radar:show-whats-new'
17
+
18
+ /**
19
+ * The notes to open automatically, if any: the newest release at or below the
20
+ * running version that is newer than what was last seen. A fresh install has
21
+ * nothing to compare against and sees nothing; an install that predates the
22
+ * seen record sees the notes once.
23
+ */
24
+ export function whatsNewToShow(
25
+ currentVersion: string,
26
+ lastSeen: string | null,
27
+ priorInstall: boolean,
28
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
29
+ ): ReleaseNotes | null {
30
+ const notes = latestReleaseNotesFor(currentVersion, catalog)
31
+ if (!notes) return null
32
+ // A record that isn't a version (a development build's) proves an install
33
+ // but says nothing about which notes were seen.
34
+ const newer = lastSeen === null ? null : compareVersions(notes.version, lastSeen)
35
+ if (newer === null) return lastSeen !== null || priorInstall ? notes : null
36
+ return newer > 0 ? notes : null
37
+ }
38
+
39
+ /**
40
+ * The version to record once the running version's notes are acknowledged, or
41
+ * null when nothing should change. It only moves forward, so a downgrade
42
+ * doesn't replay notes on the way back up, and a development build records
43
+ * nothing — a non-version record could never be compared again.
44
+ */
45
+ export function nextSeenVersion(currentVersion: string, lastSeen: string | null): string | null {
46
+ if (compareVersions(currentVersion, currentVersion) === null) return null
47
+ const cmp = lastSeen === null ? null : compareVersions(currentVersion, lastSeen)
48
+ if (cmp !== null && cmp <= 0) return null
49
+ return normalize(currentVersion)
50
+ }
51
+
52
+ function normalize(version: string): string {
53
+ return version.startsWith('v') ? version : `v${version}`
54
+ }
55
+
56
+ // A local Radar's browser origin changes between launches (Desktop binds a
57
+ // random port), so its record lives server-side; in-cluster, each browser
58
+ // keeps its own. undefined = nothing can be recorded, so don't auto-open.
59
+ function readBrowserLastSeen(): string | null | undefined {
60
+ try {
61
+ return localStorage.getItem(LAST_SEEN_KEY)
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ function hadRadarStateBeforeThisSession(): boolean {
68
+ try {
69
+ for (let i = 0; i < localStorage.length; i++) {
70
+ const key = localStorage.key(i)
71
+ if (key && key !== LAST_SEEN_KEY && key.startsWith('radar')) return true
72
+ }
73
+ } catch { /* ignore */ }
74
+ return false
75
+ }
76
+
77
+ // Read at module load, before this session's own queries write radar-* keys
78
+ // (the version check records its last run), which would make every fresh
79
+ // install look like an upgrade.
80
+ const HAD_PRIOR_RADAR_STATE = hadRadarStateBeforeThisSession()
81
+
82
+ function writeBrowserLastSeen(version: string) {
83
+ try { localStorage.setItem(LAST_SEEN_KEY, version) } catch { /* ignore */ }
84
+ }
85
+
86
+ export interface WhatsNewStatus {
87
+ /** Notes exist for this version, so there is something to open. */
88
+ available: boolean
89
+ /** Those notes are newer than what the user has seen. */
90
+ unread: boolean
91
+ }
92
+
93
+ // The rail and the omnibar sit outside this component but need its status.
94
+ const NO_STATUS: WhatsNewStatus = { available: false, unread: false }
95
+ let status = NO_STATUS
96
+ const statusListeners = new Set<() => void>()
97
+
98
+ function publishStatus(next: WhatsNewStatus) {
99
+ if (next.available === status.available && next.unread === status.unread) return
100
+ status = next
101
+ statusListeners.forEach(listener => listener())
102
+ }
103
+
104
+ function subscribeStatus(listener: () => void) {
105
+ statusListeners.add(listener)
106
+ return () => { statusListeners.delete(listener) }
107
+ }
108
+
109
+ export function useWhatsNewStatus(): WhatsNewStatus {
110
+ return useSyncExternalStore(subscribeStatus, () => status)
111
+ }
112
+
113
+ export function openWhatsNew() {
114
+ window.dispatchEvent(new Event(SHOW_WHATS_NEW_EVENT))
115
+ }
116
+
117
+ interface WhatsNewProps {
118
+ onNavigate: (path: string) => void
119
+ // Omitted by hosts that don't run usage data; the dialog then never asks.
120
+ usageData?: UsageDataStatus
121
+ }
122
+
123
+ /**
124
+ * Opens once after Radar is upgraded to a version that has release notes.
125
+ * `?whats-new` (or `?whats-new=v1.15.0`) opens it on demand for previews.
126
+ */
127
+ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
128
+ const { data: capabilities } = useCapabilities()
129
+ // Radar Cloud ships its own release communication.
130
+ const isCloud = capabilities?.deployment?.mode === 'cloud'
131
+ const { data: state } = useWhatsNewState(!!capabilities && !isCloud)
132
+ const queryClient = useQueryClient()
133
+ const [searchParams, setSearchParams] = useSearchParams()
134
+ const [notes, setNotes] = useState<ReleaseNotes | null>(null)
135
+ const [open, setOpen] = useState(false)
136
+ const [previousVersion, setPreviousVersion] = useState<string | null>(null)
137
+ // In-cluster only; undefined when browser storage is unavailable.
138
+ const [browserLastSeen, setBrowserLastSeen] = useState(readBrowserLastSeen)
139
+ const decidedRef = useRef(false)
140
+ const { pathname } = useLocation()
141
+ const titleId = useId()
142
+
143
+ const currentVersion = state?.currentVersion
144
+ const previewParam = searchParams.get(PREVIEW_PARAM)
145
+ // One authority per install: the server record for a personal install, this
146
+ // browser's storage for a shared one. undefined = nothing can be recorded.
147
+ const lastSeen = !state ? undefined : state.storage === 'server' ? state.seenVersion ?? null : browserLastSeen
148
+ const priorInstall = state?.storage === 'server' ? !!state.priorInstall : HAD_PRIOR_RADAR_STATE
149
+
150
+ // Another tab acknowledging the notes clears this tab's dot too.
151
+ useEffect(() => {
152
+ const onStorage = () => setBrowserLastSeen(readBrowserLastSeen())
153
+ window.addEventListener('storage', onStorage)
154
+ return () => window.removeEventListener('storage', onStorage)
155
+ }, [])
156
+
157
+ const recordSeen = useCallback((s: WhatsNewState, seen: string | null) => {
158
+ const next = nextSeenVersion(s.currentVersion, seen)
159
+ if (next === null) return
160
+ if (s.storage === 'browser') {
161
+ writeBrowserLastSeen(next)
162
+ setBrowserLastSeen(readBrowserLastSeen())
163
+ return
164
+ }
165
+ // Only a confirmed write clears the dot; a failed one is retried on the
166
+ // next close. A read still in flight predates the write, so it's dropped.
167
+ const queryKey = ['whats-new', getApiBase()]
168
+ markWhatsNewSeen(next).then(
169
+ async () => {
170
+ await queryClient.cancelQueries({ queryKey })
171
+ queryClient.setQueryData<WhatsNewState>(queryKey, prev => prev && { ...prev, seenVersion: next })
172
+ },
173
+ err => console.warn('[whats-new] Failed to record seen version', next, err),
174
+ )
175
+ }, [queryClient])
176
+
177
+ useEffect(() => {
178
+ if (previewParam === null) return
179
+ const preview = releaseNotesFor(previewParam) ?? latestReleaseNotesFor(currentVersion) ?? RELEASE_NOTES[0]
180
+ if (!preview) return
181
+ setPreviousVersion(null)
182
+ setNotes(preview)
183
+ setOpen(true)
184
+ }, [previewParam, currentVersion])
185
+
186
+ useEffect(() => {
187
+ if (!state || lastSeen === undefined || previewParam !== null || decidedRef.current) return
188
+ decidedRef.current = true
189
+ const due = whatsNewToShow(state.currentVersion, lastSeen, priorInstall)
190
+ // Only on Home: someone arriving on a deep link came for that page, often
191
+ // mid-incident. Elsewhere the nav rail's unread dot carries the notes.
192
+ if (due && (pathname === '/' || pathname === '/home')) {
193
+ setPreviousVersion(lastSeen)
194
+ setNotes(due)
195
+ setOpen(true)
196
+ } else if (!due && lastSeen === null) {
197
+ recordSeen(state, lastSeen)
198
+ }
199
+ // Decided once, when the state loads; the preview param is handled above.
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, [state?.currentVersion, state?.storage])
202
+
203
+ const available = !!state && !isCloud && !!latestReleaseNotesFor(state.currentVersion)
204
+ const unread = available && lastSeen !== undefined && !!whatsNewToShow(state.currentVersion, lastSeen, priorInstall)
205
+ useEffect(() => { publishStatus({ available, unread }) }, [available, unread])
206
+ useEffect(() => () => publishStatus(NO_STATUS), [])
207
+
208
+ useEffect(() => {
209
+ const handler = () => {
210
+ const latest = latestReleaseNotesFor(currentVersion) ?? RELEASE_NOTES[0]
211
+ if (!latest) return
212
+ setPreviousVersion(null)
213
+ setNotes(latest)
214
+ setOpen(true)
215
+ }
216
+ window.addEventListener(SHOW_WHATS_NEW_EVENT, handler)
217
+ return () => window.removeEventListener(SHOW_WHATS_NEW_EVENT, handler)
218
+ }, [currentVersion])
219
+
220
+ const close = useCallback(() => {
221
+ setOpen(false)
222
+ // A preview of another release acknowledges nothing about this one.
223
+ if (state && lastSeen !== undefined && notes && notes === latestReleaseNotesFor(state.currentVersion)) recordSeen(state, lastSeen)
224
+ if (searchParams.has(PREVIEW_PARAM)) {
225
+ const next = new URLSearchParams(searchParams)
226
+ next.delete(PREVIEW_PARAM)
227
+ setSearchParams(next, { replace: true })
228
+ }
229
+ }, [state, lastSeen, notes, recordSeen, searchParams, setSearchParams])
230
+
231
+ const go = useCallback((path: string) => {
232
+ close()
233
+ onNavigate(path)
234
+ }, [close, onNavigate])
235
+
236
+ const readAboutUsageData = useCallback(() => {
237
+ close()
238
+ window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'privacy' } }))
239
+ }, [close])
240
+
241
+ if (isCloud) return null
242
+
243
+ return (
244
+ <DialogPortal open={open} onClose={close} ariaLabelledBy={titleId} className="w-[760px] max-w-[calc(100vw-2rem)] max-h-[min(840px,calc(100vh-4rem))] flex flex-col overflow-hidden rounded-xl">
245
+ {notes && (
246
+ <WhatsNewContent
247
+ titleId={titleId}
248
+ notes={notes}
249
+ previousVersion={previousVersion}
250
+ currentVersion={currentVersion}
251
+ onClose={close}
252
+ onNavigate={go}
253
+ ask={<UsageDataAsk usageData={usageData} onReadMore={readAboutUsageData} />}
254
+ />
255
+ )}
256
+ </DialogPortal>
257
+ )
258
+ }
259
+
260
+ interface WhatsNewContentProps {
261
+ titleId?: string
262
+ notes: ReleaseNotes
263
+ previousVersion?: string | null
264
+ /** The running version, which can be newer than the release the notes are for. */
265
+ currentVersion?: string
266
+ onClose: () => void
267
+ onNavigate: (path: string) => void
268
+ // Rendered between the notes and the footer, outside the scroll area so it
269
+ // stays visible however long the notes run.
270
+ ask?: ReactNode
271
+ }
272
+
273
+ export function WhatsNewContent({ titleId, notes, previousVersion, currentVersion, onClose, onNavigate, ask }: WhatsNewContentProps) {
274
+ const [lead, ...rest] = notes.highlights
275
+ const to = currentVersion ? normalize(currentVersion) : notes.version
276
+ const from = previousVersion && normalize(previousVersion) !== to ? normalize(previousVersion) : null
277
+
278
+ return (
279
+ <>
280
+ <div className="relative px-6 pt-5 pb-4 bg-gradient-to-b from-accent-muted to-transparent">
281
+ <div className="flex items-center gap-4 pr-8">
282
+ <div className="flex items-center justify-center w-11 h-11 rounded-xl bg-accent text-white shadow-glow-brand-sm shrink-0">
283
+ <Megaphone className="w-5 h-5" aria-hidden />
284
+ </div>
285
+ <div className="min-w-0">
286
+ <h2 id={titleId} className="text-lg font-semibold text-theme-text-primary leading-tight">
287
+ What's new in Radar <span className="font-mono">{releaseLine(notes.version)}</span>
288
+ </h2>
289
+ {from ? (
290
+ <p className="flex flex-wrap items-center gap-1.5 mt-1.5 text-xs text-theme-text-tertiary">
291
+ <span>You just updated</span>
292
+ <span className="font-mono px-1.5 py-0.5 rounded bg-theme-elevated text-theme-text-secondary">{from}</span>
293
+ <ArrowRight className="w-3 h-3" aria-label="to" />
294
+ <span className="font-mono px-1.5 py-0.5 rounded bg-accent-muted text-accent-text">{to}</span>
295
+ </p>
296
+ ) : (
297
+ <p className="mt-1 text-xs text-theme-text-tertiary">Highlights from this release</p>
298
+ )}
299
+ </div>
300
+ </div>
301
+ <button
302
+ type="button"
303
+ onClick={onClose}
304
+ aria-label="Close"
305
+ className="absolute top-4 right-4 p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
306
+ >
307
+ <X className="w-4 h-4" />
308
+ </button>
309
+ </div>
310
+
311
+ {/* The fade sits over the bottom padding, so it only covers content while more is below. */}
312
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)]">
313
+ <ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
314
+ {lead && (
315
+ <li className="sm:col-span-2">
316
+ <HighlightCard item={lead} lead onNavigate={onNavigate} />
317
+ </li>
318
+ )}
319
+ {rest.map((item, i) => (
320
+ // An odd card out spans the row instead of leaving a hole beside it.
321
+ <li key={item.id} className={clsx(rest.length % 2 === 1 && i === rest.length - 1 && 'sm:col-span-2')}>
322
+ <HighlightCard item={item} onNavigate={onNavigate} />
323
+ </li>
324
+ ))}
325
+ </ul>
326
+
327
+ {notes.improvements.length > 0 && (
328
+ <div className="mt-4">
329
+ <h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary mb-2">
330
+ Also in this release
331
+ </h3>
332
+ <ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
333
+ {notes.improvements.map(line => (
334
+ <li key={line} className="flex gap-2 text-xs text-theme-text-secondary leading-relaxed">
335
+ <Check className="w-3.5 h-3.5 mt-px text-accent shrink-0" aria-hidden />
336
+ {line}
337
+ </li>
338
+ ))}
339
+ </ul>
340
+ </div>
341
+ )}
342
+ </div>
343
+
344
+ {ask}
345
+
346
+ <div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-theme-border bg-theme-base/60">
347
+ <a
348
+ href={notes.releaseUrl}
349
+ target="_blank"
350
+ rel="noopener noreferrer"
351
+ className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:underline"
352
+ >
353
+ Full release notes
354
+ <ExternalLink className="w-3 h-3" aria-hidden />
355
+ </a>
356
+ <button type="button" onClick={onClose} className="btn-brand px-4 py-1.5 text-sm font-medium rounded-lg">
357
+ Got it
358
+ </button>
359
+ </div>
360
+ </>
361
+ )
362
+ }
363
+
364
+ // Literal class strings so Tailwind keeps them.
365
+ const TONE_TILE: Record<HighlightTone, string> = {
366
+ violet: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
367
+ teal: 'bg-teal-500/10 text-teal-600 dark:text-teal-400',
368
+ emerald: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
369
+ indigo: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
370
+ }
371
+
372
+ function HighlightCard({ item, lead = false, onNavigate }: {
373
+ item: ReleaseHighlight
374
+ lead?: boolean
375
+ onNavigate: (path: string) => void
376
+ }) {
377
+ const Icon = item.icon
378
+ const descriptionId = useId()
379
+ const actionable = !!(item.path && item.cta)
380
+ const className = clsx(
381
+ 'flex gap-3 w-full h-full text-left rounded-lg border',
382
+ lead ? 'p-3.5 border-accent/30 bg-gradient-to-br from-accent-muted to-transparent' : 'p-3 border-theme-border',
383
+ actionable && 'group transition-colors hover:border-accent/50 hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent',
384
+ )
385
+ const body = (
386
+ <>
387
+ <span className={clsx(
388
+ 'flex items-center justify-center shrink-0 rounded-lg',
389
+ lead ? 'w-10 h-10 bg-accent text-white' : clsx('w-8 h-8', item.tone ? TONE_TILE[item.tone] : 'bg-accent-muted text-accent'),
390
+ )}>
391
+ <Icon className={lead ? 'w-5 h-5' : 'w-4 h-4'} aria-hidden />
392
+ </span>
393
+ <span className="block min-w-0 flex-1">
394
+ <span className={clsx('block font-medium text-theme-text-primary leading-snug', lead ? 'text-base' : 'text-sm')}>
395
+ {item.title}
396
+ </span>
397
+ <span id={descriptionId} className={clsx('block mt-0.5 text-theme-text-secondary leading-relaxed', lead ? 'text-sm' : 'text-xs')}>
398
+ {item.description}
399
+ </span>
400
+ {actionable && (
401
+ <span className="inline-flex items-center gap-1 mt-1.5 text-xs font-medium text-accent-text group-hover:underline">
402
+ {item.cta}
403
+ <ArrowRight className="w-3 h-3" aria-hidden />
404
+ </span>
405
+ )}
406
+ </span>
407
+ </>
408
+ )
409
+
410
+ return actionable ? (
411
+ <button
412
+ type="button"
413
+ onClick={() => onNavigate(item.path!)}
414
+ aria-label={`${item.cta}: ${item.title}`}
415
+ aria-describedby={descriptionId}
416
+ className={className}
417
+ >
418
+ {body}
419
+ </button>
420
+ ) : (
421
+ <div className={className}>{body}</div>
422
+ )
423
+ }
@@ -0,0 +1,229 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { MemoryRouter } from 'react-router-dom'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { Megaphone } from 'lucide-react'
8
+ import type { WhatsNewState } from '../../api/client'
9
+ import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './WhatsNew'
10
+ import { RELEASE_NOTES } from './releaseNotes'
11
+
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
13
+
14
+ let root: Root
15
+ let client: QueryClient
16
+ let serverState: WhatsNewState
17
+ const seenPosts: string[] = []
18
+ let failSeenPosts = 0
19
+ let pendingRead: ((state: WhatsNewState) => void) | null = null
20
+ let holdNextRead = false
21
+ const status = { current: { available: false, unread: false } }
22
+
23
+ function memoryStorage(): Storage {
24
+ const items = new Map<string, string>()
25
+ return {
26
+ get length() { return items.size },
27
+ key: (i: number) => Array.from(items.keys())[i] ?? null,
28
+ getItem: (k: string) => items.get(k) ?? null,
29
+ setItem: (k: string, v: string) => { items.set(k, String(v)) },
30
+ removeItem: (k: string) => { items.delete(k) },
31
+ clear: () => items.clear(),
32
+ }
33
+ }
34
+
35
+ function StatusProbe() {
36
+ status.current = useWhatsNewStatus()
37
+ return null
38
+ }
39
+
40
+ beforeEach(() => {
41
+ RELEASE_NOTES.push({
42
+ version: 'v1.15.0',
43
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases',
44
+ highlights: [{ id: 'x', icon: Megaphone, title: 'Capacity views', description: 'd' }],
45
+ improvements: [],
46
+ })
47
+ seenPosts.length = 0
48
+ failSeenPosts = 0
49
+ pendingRead = null
50
+ holdNextRead = false
51
+ vi.stubGlobal('localStorage', memoryStorage())
52
+ // Radar's own defaults: focus refetch is off unless a query opts in.
53
+ client = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })
54
+ const element = document.createElement('div')
55
+ document.body.appendChild(element)
56
+ root = createRoot(element)
57
+ vi.stubGlobal('fetch', vi.fn(async (input: string, init?: RequestInit) => {
58
+ const path = new URL(input, 'http://localhost').pathname
59
+ if (path === '/api/capabilities') return Response.json({ deployment: { mode: serverState.storage === 'server' ? 'local' : 'in-cluster' } })
60
+ if (path === '/api/whats-new') {
61
+ const snapshot = { ...serverState }
62
+ if (holdNextRead) {
63
+ holdNextRead = false
64
+ return new Promise<Response>(resolve => { pendingRead = s => resolve(Response.json(s)) })
65
+ }
66
+ return Response.json(snapshot)
67
+ }
68
+ if (path === '/api/whats-new/seen' && init?.method === 'POST') {
69
+ seenPosts.push(JSON.parse(String(init.body)).version)
70
+ if (failSeenPosts > 0) {
71
+ failSeenPosts--
72
+ return Response.json({ error: 'failed to record the seen version' }, { status: 500 })
73
+ }
74
+ serverState = { ...serverState, seenVersion: seenPosts[seenPosts.length - 1] }
75
+ return new Response(null, { status: 204 })
76
+ }
77
+ throw new Error(`Unexpected request: ${input}`)
78
+ }))
79
+ })
80
+
81
+ afterEach(async () => {
82
+ await act(async () => root.unmount())
83
+ RELEASE_NOTES.length = 0
84
+ client.clear()
85
+ document.body.replaceChildren()
86
+ vi.unstubAllGlobals()
87
+ })
88
+
89
+ async function render(path: string) {
90
+ await act(async () => {
91
+ root.render(
92
+ <QueryClientProvider client={client}>
93
+ <MemoryRouter initialEntries={[path]}>
94
+ <WhatsNew onNavigate={() => {}} />
95
+ <StatusProbe />
96
+ </MemoryRouter>
97
+ </QueryClientProvider>,
98
+ )
99
+ })
100
+ await vi.waitFor(async () => {
101
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
102
+ expect(status.current.available).toBe(true)
103
+ })
104
+ }
105
+
106
+ const dialog = () => document.querySelector('[role="dialog"][aria-modal="true"]')
107
+
108
+ async function clickGotIt() {
109
+ const gotIt = Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Got it')!
110
+ await act(async () => { gotIt.click() })
111
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
112
+ }
113
+
114
+ describe('WhatsNew', () => {
115
+ it('auto-opens on Home after an upgrade and records the running version on close', async () => {
116
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
117
+ await render('/')
118
+ expect(dialog()?.textContent).toContain('v1.14.1')
119
+ expect(status.current.unread).toBe(true)
120
+ await clickGotIt()
121
+ expect(seenPosts).toEqual(['v1.15.2'])
122
+ expect(status.current.unread).toBe(false)
123
+ })
124
+
125
+ it('stays closed on a deep link, leaving the notes unread until opened on demand', async () => {
126
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
127
+ await render('/resources/pods')
128
+ expect(dialog()).toBeNull()
129
+ expect(status.current.unread).toBe(true)
130
+ expect(seenPosts).toEqual([])
131
+ await act(async () => { openWhatsNew() })
132
+ expect(dialog()).not.toBeNull()
133
+ await clickGotIt()
134
+ expect(seenPosts).toEqual(['v1.15.2'])
135
+ expect(status.current.unread).toBe(false)
136
+ })
137
+
138
+ it('records a fresh install without opening', async () => {
139
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', priorInstall: false }
140
+ await render('/')
141
+ expect(dialog()).toBeNull()
142
+ expect(seenPosts).toEqual(['v1.15.2'])
143
+ expect(status.current.unread).toBe(false)
144
+ })
145
+
146
+ it('keeps the in-cluster record in the browser, never on the server', async () => {
147
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
148
+ localStorage.setItem('radar-whats-new-seen', 'v1.14.1')
149
+ await render('/')
150
+ expect(dialog()).not.toBeNull()
151
+ await clickGotIt()
152
+ expect(localStorage.getItem('radar-whats-new-seen')).toBe('v1.15.2')
153
+ expect(seenPosts).toEqual([])
154
+ })
155
+
156
+ it('records nothing when previewing another release', async () => {
157
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
158
+ RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v1.14.0' })
159
+ serverState.seenVersion = 'v1.14.1'
160
+ await render('/resources/pods?whats-new=v1.14.0')
161
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.14")
162
+ await clickGotIt()
163
+ expect(seenPosts).toEqual([])
164
+ expect(status.current.unread).toBe(true)
165
+ })
166
+
167
+ it('keeps the notes unread when recording fails, and retries on the next close', async () => {
168
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
169
+ failSeenPosts = 1
170
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
171
+ await render('/')
172
+ await clickGotIt()
173
+ expect(seenPosts).toEqual(['v1.15.2'])
174
+ expect(status.current.unread).toBe(true)
175
+ await act(async () => { openWhatsNew() })
176
+ await clickGotIt()
177
+ expect(seenPosts).toEqual(['v1.15.2', 'v1.15.2'])
178
+ expect(status.current.unread).toBe(false)
179
+ })
180
+
181
+ it('clears the dot when another tab acknowledges the notes', async () => {
182
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
183
+ localStorage.setItem('radar-whats-new-seen', 'v1.14.1')
184
+ await render('/resources/pods')
185
+ expect(status.current.unread).toBe(true)
186
+ localStorage.setItem('radar-whats-new-seen', 'v1.15.2')
187
+ await act(async () => { window.dispatchEvent(new Event('storage')) })
188
+ expect(status.current.unread).toBe(false)
189
+ })
190
+
191
+ it('picks up another tab\'s acknowledgment when this window regains focus', async () => {
192
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
193
+ await render('/resources/pods')
194
+ expect(status.current.unread).toBe(true)
195
+ serverState = { ...serverState, seenVersion: 'v1.15.2' }
196
+ await act(async () => { focusManager.setFocused(false); focusManager.setFocused(true) })
197
+ await vi.waitFor(() => expect(status.current.unread).toBe(false))
198
+ focusManager.setFocused(undefined)
199
+ })
200
+
201
+ it('does not let a read that started before the acknowledgment undo it', async () => {
202
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
203
+ await render('/')
204
+ const staleSnapshot = { ...serverState }
205
+ holdNextRead = true
206
+ await act(async () => { void client.refetchQueries({ queryKey: ['whats-new'] }) })
207
+ await vi.waitFor(() => expect(pendingRead).not.toBeNull())
208
+ await clickGotIt()
209
+ await vi.waitFor(() => expect(status.current.unread).toBe(false))
210
+ await act(async () => { pendingRead!(staleSnapshot) })
211
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)) })
212
+ expect(status.current.unread).toBe(false)
213
+ })
214
+
215
+ it('never records a development build', async () => {
216
+ serverState = { currentVersion: 'dev', storage: 'server', priorInstall: false }
217
+ RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v0.0.0' })
218
+ await act(async () => {
219
+ root.render(
220
+ <QueryClientProvider client={client}>
221
+ <MemoryRouter initialEntries={['/']}><WhatsNew onNavigate={() => {}} /><StatusProbe /></MemoryRouter>
222
+ </QueryClientProvider>,
223
+ )
224
+ })
225
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)) })
226
+ expect(dialog()).toBeNull()
227
+ expect(seenPosts).toEqual([])
228
+ })
229
+ })