@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.
- package/package.json +1 -1
- package/src/App.tsx +51 -6
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.ts +37 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +13 -52
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.ts +1 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +11 -1
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- 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
|
+
})
|