@skyhook-io/radar-app 1.16.2 → 1.17.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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -7,33 +7,42 @@ import { DialogPortal } from '@skyhook-io/k8s-ui'
7
7
  import { getApiBase } from '../../api/config'
8
8
  import { markWhatsNewSeen, useCapabilities, useWhatsNewState, type WhatsNewState } from '../../api/client'
9
9
  import { compareVersions } from '../../utils/version'
10
- import { latestReleaseNotesFor, releaseLine, releaseNotesFor, RELEASE_NOTES, type HighlightTone, type ReleaseHighlight, type ReleaseNotes } from './releaseNotes'
10
+ import {
11
+ AUTO_OPEN_SCORE,
12
+ CHANGELOG_URL,
13
+ composeReleaseNotes,
14
+ latestReleaseNotesFor,
15
+ releaseLine,
16
+ releaseNotesFor,
17
+ RELEASE_NOTES,
18
+ unreadReleases,
19
+ type ComposedHighlight,
20
+ type ComposedNotes,
21
+ type ReleaseNotes,
22
+ } from './releaseNotes'
11
23
  import type { UsageDataStatus } from '../../api/usage-data'
12
24
  import { UsageDataAsk } from '../usage-data/UsageDataAsk'
13
25
 
14
26
  const LAST_SEEN_KEY = 'radar-whats-new-seen'
15
27
  const PREVIEW_PARAM = 'whats-new'
28
+ const PREVIEW_FROM_PARAM = 'whats-new-from'
16
29
  export const SHOW_WHATS_NEW_EVENT = 'radar:show-whats-new'
17
30
 
18
31
  /**
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.
32
+ * The notes to open automatically, if any: the unseen releases, composed, when
33
+ * what they'd show is worth an interruption. Unseen notes below the bar stay
34
+ * behind the nav rail's unread dot.
23
35
  */
24
- export function whatsNewToShow(
36
+ export function whatsNewToOpen(
25
37
  currentVersion: string,
26
38
  lastSeen: string | null,
27
39
  priorInstall: boolean,
28
40
  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
41
+ ): ComposedNotes | null {
42
+ const unread = unreadReleases(currentVersion, lastSeen, priorInstall, catalog)
43
+ if (unread.length === 0) return null
44
+ const notes = composeReleaseNotes(unread)
45
+ return notes.score >= AUTO_OPEN_SCORE ? notes : null
37
46
  }
38
47
 
39
48
  /**
@@ -120,9 +129,21 @@ interface WhatsNewProps {
120
129
  usageData?: UsageDataStatus
121
130
  }
122
131
 
132
+ interface DialogView {
133
+ notes: ComposedNotes
134
+ /** The last version the user saw notes for, when this view covers what came after it. */
135
+ since: string | null
136
+ /** The version the notes bring the user up to. */
137
+ to: string
138
+ /** Closing records `to` as seen; previews record nothing. */
139
+ acknowledges: boolean
140
+ }
141
+
123
142
  /**
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.
143
+ * Opens after an upgrade when the releases the user hasn't seen have enough to
144
+ * show; otherwise the nav rail's unread dot carries them.
145
+ * `?whats-new` (or `?whats-new=v1.15.0`) opens it on demand for previews, and
146
+ * `&whats-new-from=v1.14.0` previews it as composed for an upgrade from that version.
126
147
  */
127
148
  export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
128
149
  const { data: capabilities } = useCapabilities()
@@ -131,9 +152,9 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
131
152
  const { data: state } = useWhatsNewState(!!capabilities && !isCloud)
132
153
  const queryClient = useQueryClient()
133
154
  const [searchParams, setSearchParams] = useSearchParams()
134
- const [notes, setNotes] = useState<ReleaseNotes | null>(null)
155
+ // Fixed while open, so an acknowledgment from another tab can't reshuffle what's being read.
156
+ const [view, setView] = useState<DialogView | null>(null)
135
157
  const [open, setOpen] = useState(false)
136
- const [previousVersion, setPreviousVersion] = useState<string | null>(null)
137
158
  // In-cluster only; undefined when browser storage is unavailable.
138
159
  const [browserLastSeen, setBrowserLastSeen] = useState(readBrowserLastSeen)
139
160
  const decidedRef = useRef(false)
@@ -142,6 +163,7 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
142
163
 
143
164
  const currentVersion = state?.currentVersion
144
165
  const previewParam = searchParams.get(PREVIEW_PARAM)
166
+ const previewFrom = searchParams.get(PREVIEW_FROM_PARAM)
145
167
  // One authority per install: the server record for a personal install, this
146
168
  // browser's storage for a shared one. undefined = nothing can be recorded.
147
169
  const lastSeen = !state ? undefined : state.storage === 'server' ? state.seenVersion ?? null : browserLastSeen
@@ -154,10 +176,12 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
154
176
  return () => window.removeEventListener('storage', onStorage)
155
177
  }, [])
156
178
 
157
- const recordSeen = useCallback((s: WhatsNewState, seen: string | null) => {
158
- const next = nextSeenVersion(s.currentVersion, seen)
179
+ // Records the version a dialog was opened for, not the running one: the
180
+ // server can be upgraded while the dialog is open.
181
+ const recordSeen = useCallback((version: string, storage: WhatsNewState['storage'], seen: string | null) => {
182
+ const next = nextSeenVersion(version, seen)
159
183
  if (next === null) return
160
- if (s.storage === 'browser') {
184
+ if (storage === 'browser') {
161
185
  writeBrowserLastSeen(next)
162
186
  setBrowserLastSeen(readBrowserLastSeen())
163
187
  return
@@ -174,59 +198,70 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
174
198
  )
175
199
  }, [queryClient])
176
200
 
201
+ const show = useCallback((next: DialogView) => {
202
+ setView(next)
203
+ setOpen(true)
204
+ }, [])
205
+
177
206
  useEffect(() => {
178
207
  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])
208
+ const target = releaseNotesFor(previewParam)?.version ?? currentVersion ?? RELEASE_NOTES[0]?.version
209
+ if (!target) return
210
+ const upgrade = previewFrom ? unreadReleases(target, previewFrom, true) : []
211
+ if (upgrade.length > 0) {
212
+ show({ notes: composeReleaseNotes(upgrade), since: previewFrom, to: target, acknowledges: false })
213
+ return
214
+ }
215
+ const single = releaseNotesFor(previewParam) ?? latestReleaseNotesFor(currentVersion) ?? RELEASE_NOTES[0]
216
+ if (single) show({ notes: composeReleaseNotes([single]), since: null, to: single.version, acknowledges: false })
217
+ }, [previewParam, previewFrom, currentVersion, show])
185
218
 
186
219
  useEffect(() => {
187
220
  if (!state || lastSeen === undefined || previewParam !== null || decidedRef.current) return
188
221
  decidedRef.current = true
189
- const due = whatsNewToShow(state.currentVersion, lastSeen, priorInstall)
222
+ const due = whatsNewToOpen(state.currentVersion, lastSeen, priorInstall)
190
223
  // Only on Home: someone arriving on a deep link came for that page, often
191
224
  // mid-incident. Elsewhere the nav rail's unread dot carries the notes.
192
225
  if (due && (pathname === '/' || pathname === '/home')) {
193
- setPreviousVersion(lastSeen)
194
- setNotes(due)
195
- setOpen(true)
196
- } else if (!due && lastSeen === null) {
197
- recordSeen(state, lastSeen)
226
+ show({ notes: due, since: lastSeen, to: state.currentVersion, acknowledges: true })
227
+ } else if (lastSeen === null && unreadReleases(state.currentVersion, lastSeen, priorInstall).length === 0) {
228
+ // A fresh install: start the record, so the next upgrade has something to compare against.
229
+ recordSeen(state.currentVersion, state.storage, lastSeen)
198
230
  }
199
231
  // Decided once, when the state loads; the preview param is handled above.
200
232
  // eslint-disable-next-line react-hooks/exhaustive-deps
201
233
  }, [state?.currentVersion, state?.storage])
202
234
 
203
235
  const available = !!state && !isCloud && !!latestReleaseNotesFor(state.currentVersion)
204
- const unread = available && lastSeen !== undefined && !!whatsNewToShow(state.currentVersion, lastSeen, priorInstall)
236
+ const unread = available && lastSeen !== undefined && unreadReleases(state.currentVersion, lastSeen, priorInstall).length > 0
205
237
  useEffect(() => { publishStatus({ available, unread }) }, [available, unread])
206
238
  useEffect(() => () => publishStatus(NO_STATUS), [])
207
239
 
208
240
  useEffect(() => {
209
241
  const handler = () => {
210
- const latest = latestReleaseNotesFor(currentVersion) ?? RELEASE_NOTES[0]
211
- if (!latest) return
212
- setPreviousVersion(null)
213
- setNotes(latest)
214
- setOpen(true)
242
+ if (!currentVersion) return
243
+ const pending = lastSeen === undefined ? [] : unreadReleases(currentVersion, lastSeen, priorInstall)
244
+ if (pending.length > 0) {
245
+ show({ notes: composeReleaseNotes(pending), since: lastSeen ?? null, to: currentVersion, acknowledges: true })
246
+ return
247
+ }
248
+ const latest = latestReleaseNotesFor(currentVersion)
249
+ if (latest) show({ notes: composeReleaseNotes([latest]), since: null, to: currentVersion, acknowledges: true })
215
250
  }
216
251
  window.addEventListener(SHOW_WHATS_NEW_EVENT, handler)
217
252
  return () => window.removeEventListener(SHOW_WHATS_NEW_EVENT, handler)
218
- }, [currentVersion])
253
+ }, [currentVersion, lastSeen, priorInstall, show])
219
254
 
220
255
  const close = useCallback(() => {
221
256
  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)) {
257
+ if (view?.acknowledges && state && lastSeen !== undefined) recordSeen(view.to, state.storage, lastSeen)
258
+ if (searchParams.has(PREVIEW_PARAM) || searchParams.has(PREVIEW_FROM_PARAM)) {
225
259
  const next = new URLSearchParams(searchParams)
226
260
  next.delete(PREVIEW_PARAM)
261
+ next.delete(PREVIEW_FROM_PARAM)
227
262
  setSearchParams(next, { replace: true })
228
263
  }
229
- }, [state, lastSeen, notes, recordSeen, searchParams, setSearchParams])
264
+ }, [state, lastSeen, view, recordSeen, searchParams, setSearchParams])
230
265
 
231
266
  const go = useCallback((path: string) => {
232
267
  close()
@@ -242,12 +277,12 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
242
277
 
243
278
  return (
244
279
  <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 && (
280
+ {view && (
246
281
  <WhatsNewContent
247
282
  titleId={titleId}
248
- notes={notes}
249
- previousVersion={previousVersion}
250
- currentVersion={currentVersion}
283
+ notes={view.notes}
284
+ since={view.since}
285
+ to={view.to}
251
286
  onClose={close}
252
287
  onNavigate={go}
253
288
  ask={<UsageDataAsk usageData={usageData} onReadMore={readAboutUsageData} />}
@@ -259,10 +294,11 @@ export function WhatsNew({ onNavigate, usageData }: WhatsNewProps) {
259
294
 
260
295
  interface WhatsNewContentProps {
261
296
  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
297
+ notes: ComposedNotes
298
+ /** The last version the user saw notes for, when the notes cover what came after it. */
299
+ since?: string | null
300
+ /** The version the notes bring the user up to, which can be newer than the releases they cover. */
301
+ to?: string
266
302
  onClose: () => void
267
303
  onNavigate: (path: string) => void
268
304
  // Rendered between the notes and the footer, outside the scroll area so it
@@ -270,10 +306,24 @@ interface WhatsNewContentProps {
270
306
  ask?: ReactNode
271
307
  }
272
308
 
273
- export function WhatsNewContent({ titleId, notes, previousVersion, currentVersion, onClose, onNavigate, ask }: WhatsNewContentProps) {
309
+ function isVersion(version: string | null | undefined): version is string {
310
+ return !!version && compareVersions(version, version) !== null
311
+ }
312
+
313
+ export function WhatsNewContent({ titleId, notes, since, to: toVersion, onClose, onNavigate, ask }: WhatsNewContentProps) {
274
314
  const [lead, ...rest] = notes.highlights
275
- const to = currentVersion ? normalize(currentVersion) : notes.version
276
- const from = previousVersion && normalize(previousVersion) !== to ? normalize(previousVersion) : null
315
+ const newest = notes.versions[0]
316
+ const mixed = notes.versions.length > 1
317
+ const to = isVersion(toVersion) ? normalize(toVersion) : newest
318
+ const from = isVersion(since) && normalize(since) !== to ? normalize(since) : null
319
+ // Older releases than the dialog covers were left out, so "since" would overpromise.
320
+ const title = !mixed
321
+ ? <>What's new in Radar <span className="font-mono">{releaseLine(newest)}</span></>
322
+ : from && notes.complete
323
+ ? <>What's new since Radar <span className="font-mono">{releaseLine(from)}</span></>
324
+ : <>Recent highlights in Radar</>
325
+ // Only releases older than the newest are tagged: the heading already names it.
326
+ const olderTag = (version: string) => mixed && version !== newest ? releaseLine(version) : null
277
327
 
278
328
  return (
279
329
  <>
@@ -284,17 +334,16 @@ export function WhatsNewContent({ titleId, notes, previousVersion, currentVersio
284
334
  </div>
285
335
  <div className="min-w-0">
286
336
  <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>
337
+ {title}
288
338
  </h2>
289
339
  {from ? (
290
340
  <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
341
  <span className="font-mono px-1.5 py-0.5 rounded bg-theme-elevated text-theme-text-secondary">{from}</span>
293
342
  <ArrowRight className="w-3 h-3" aria-label="to" />
294
343
  <span className="font-mono px-1.5 py-0.5 rounded bg-accent-muted text-accent-text">{to}</span>
295
344
  </p>
296
345
  ) : (
297
- <p className="mt-1 text-xs text-theme-text-tertiary">Highlights from this release</p>
346
+ <p className="mt-1 text-xs text-theme-text-tertiary">{mixed ? 'Highlights from recent releases' : 'Highlights from this release'}</p>
298
347
  )}
299
348
  </div>
300
349
  </div>
@@ -313,29 +362,35 @@ export function WhatsNewContent({ titleId, notes, previousVersion, currentVersio
313
362
  <ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
314
363
  {lead && (
315
364
  <li className="sm:col-span-2">
316
- <HighlightCard item={lead} lead onNavigate={onNavigate} />
365
+ <HighlightCard item={lead} lead tag={olderTag(lead.version)} onNavigate={onNavigate} />
317
366
  </li>
318
367
  )}
319
368
  {rest.map((item, i) => (
320
369
  // 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} />
370
+ <li key={`${item.version}/${item.id}`} className={clsx(rest.length % 2 === 1 && i === rest.length - 1 && 'sm:col-span-2')}>
371
+ <HighlightCard item={item} tone={TONE_TILES[i % TONE_TILES.length]} tag={olderTag(item.version)} onNavigate={onNavigate} />
323
372
  </li>
324
373
  ))}
325
374
  </ul>
326
375
 
327
- {notes.improvements.length > 0 && (
376
+ {notes.lines.length > 0 && (
328
377
  <div className="mt-4">
329
378
  <h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary mb-2">
330
- Also in this release
379
+ {mixed ? 'Also new' : 'Also in this release'}
331
380
  </h3>
332
381
  <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
- ))}
382
+ {notes.lines.map(line => {
383
+ const tag = olderTag(line.version)
384
+ return (
385
+ <li key={`${line.version}/${line.text}`} className="flex gap-2 text-xs text-theme-text-secondary leading-relaxed">
386
+ <Check className="w-3.5 h-3.5 mt-px text-accent shrink-0" aria-hidden />
387
+ <span>
388
+ {line.text}
389
+ {tag && <span className="ml-1.5 font-mono text-[11px] text-theme-text-tertiary">{tag}</span>}
390
+ </span>
391
+ </li>
392
+ )
393
+ })}
339
394
  </ul>
340
395
  </div>
341
396
  )}
@@ -343,15 +398,15 @@ export function WhatsNewContent({ titleId, notes, previousVersion, currentVersio
343
398
 
344
399
  {ask}
345
400
 
346
- <div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-theme-border bg-theme-base/60">
401
+ <div className="flex items-center justify-end gap-2 px-6 py-3 border-t border-theme-border bg-theme-base/60">
347
402
  <a
348
- href={notes.releaseUrl}
403
+ href={CHANGELOG_URL}
349
404
  target="_blank"
350
405
  rel="noopener noreferrer"
351
- className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:underline"
406
+ className="inline-flex items-center gap-1.5 rounded-lg border border-theme-border px-4 py-1.5 text-sm font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
352
407
  >
353
- Full release notes
354
- <ExternalLink className="w-3 h-3" aria-hidden />
408
+ Full changelog
409
+ <ExternalLink className="w-3.5 h-3.5" aria-hidden />
355
410
  </a>
356
411
  <button type="button" onClick={onClose} className="btn-brand px-4 py-1.5 text-sm font-medium rounded-lg">
357
412
  Got it
@@ -361,17 +416,21 @@ export function WhatsNewContent({ titleId, notes, previousVersion, currentVersio
361
416
  )
362
417
  }
363
418
 
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
419
+ // By position, not by release, so cards mixed from several releases never
420
+ // repeat a color. Literal class strings so Tailwind keeps them.
421
+ const TONE_TILES = [
422
+ 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
423
+ 'bg-teal-500/10 text-teal-600 dark:text-teal-400',
424
+ 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
425
+ 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
426
+ ]
427
+
428
+ function HighlightCard({ item, lead = false, tone, tag, onNavigate }: {
429
+ item: ComposedHighlight
374
430
  lead?: boolean
431
+ tone?: string
432
+ /** The release an older highlight came from, when the dialog mixes releases. */
433
+ tag?: string | null
375
434
  onNavigate: (path: string) => void
376
435
  }) {
377
436
  const Icon = item.icon
@@ -386,16 +445,17 @@ function HighlightCard({ item, lead = false, onNavigate }: {
386
445
  <>
387
446
  <span className={clsx(
388
447
  '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'),
448
+ lead ? 'w-10 h-10 bg-accent text-white' : clsx('w-8 h-8', tone ?? 'bg-accent-muted text-accent'),
390
449
  )}>
391
450
  <Icon className={lead ? 'w-5 h-5' : 'w-4 h-4'} aria-hidden />
392
451
  </span>
393
452
  <span className="block min-w-0 flex-1">
394
453
  <span className={clsx('block font-medium text-theme-text-primary leading-snug', lead ? 'text-base' : 'text-sm')}>
395
454
  {item.title}
455
+ {tag && <span className="ml-1.5 font-mono font-normal text-[11px] text-theme-text-tertiary">{tag}</span>}
396
456
  </span>
397
457
  <span id={descriptionId} className={clsx('block mt-0.5 text-theme-text-secondary leading-relaxed', lead ? 'text-sm' : 'text-xs')}>
398
- {item.description}
458
+ {lead ? item.leadDescription ?? item.description : item.description}
399
459
  </span>
400
460
  {actionable && (
401
461
  <span className="inline-flex items-center gap-1 mt-1.5 text-xs font-medium text-accent-text group-hover:underline">
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
7
  import { Megaphone } from 'lucide-react'
8
8
  import type { WhatsNewState } from '../../api/client'
9
9
  import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './WhatsNew'
10
- import { RELEASE_NOTES } from './releaseNotes'
10
+ import { RELEASE_NOTES, type ReleaseNotes } from './releaseNotes'
11
11
 
12
12
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
13
13
 
@@ -37,13 +37,28 @@ function StatusProbe() {
37
37
  return null
38
38
  }
39
39
 
40
+ const SHIPPED = [...RELEASE_NOTES]
41
+
42
+ const card = (id: string, title: string, importance: number) => ({ id, icon: Megaphone, title, description: 'd', importance })
43
+
44
+ // A release worth opening the dialog for, and a quiet one that isn't.
45
+ const HEADLINE: ReleaseNotes = {
46
+ version: 'v1.15.0',
47
+ highlights: [{ ...card('capacity', 'Capacity views', 9), leadDescription: 'Capacity lead copy' }, card('diff', 'Manifest diffs', 9), card('rollouts', 'Rollout traffic', 9)],
48
+ improvements: ['Sort memory'],
49
+ }
50
+ const QUIET: ReleaseNotes = {
51
+ version: 'v1.16.0',
52
+ highlights: [card('timeline', 'Workload Timeline', 4), card('logs', 'Log levels', 4)],
53
+ improvements: ['Traffic fixes'],
54
+ }
55
+
56
+ function setCatalog(...entries: ReleaseNotes[]) {
57
+ RELEASE_NOTES.splice(0, RELEASE_NOTES.length, ...entries)
58
+ }
59
+
40
60
  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
- })
61
+ setCatalog(HEADLINE)
47
62
  seenPosts.length = 0
48
63
  failSeenPosts = 0
49
64
  pendingRead = null
@@ -80,7 +95,7 @@ beforeEach(() => {
80
95
 
81
96
  afterEach(async () => {
82
97
  await act(async () => root.unmount())
83
- RELEASE_NOTES.length = 0
98
+ setCatalog(...SHIPPED)
84
99
  client.clear()
85
100
  document.body.replaceChildren()
86
101
  vi.unstubAllGlobals()
@@ -155,7 +170,7 @@ describe('WhatsNew', () => {
155
170
 
156
171
  it('records nothing when previewing another release', async () => {
157
172
  serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
158
- RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v1.14.0' })
173
+ setCatalog(HEADLINE, { ...HEADLINE, version: 'v1.14.0' })
159
174
  serverState.seenVersion = 'v1.14.1'
160
175
  await render('/resources/pods?whats-new=v1.14.0')
161
176
  expect(dialog()?.textContent).toContain("What's new in Radar v1.14")
@@ -214,7 +229,7 @@ describe('WhatsNew', () => {
214
229
 
215
230
  it('never records a development build', async () => {
216
231
  serverState = { currentVersion: 'dev', storage: 'server', priorInstall: false }
217
- RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v0.0.0' })
232
+ setCatalog(HEADLINE, { ...HEADLINE, version: 'v0.0.0' })
218
233
  await act(async () => {
219
234
  root.render(
220
235
  <QueryClientProvider client={client}>
@@ -226,4 +241,111 @@ describe('WhatsNew', () => {
226
241
  expect(dialog()).toBeNull()
227
242
  expect(seenPosts).toEqual([])
228
243
  })
244
+ it('leaves a quiet release behind the unread dot, and records it once opened', async () => {
245
+ setCatalog(HEADLINE, QUIET)
246
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.15.0', priorInstall: true }
247
+ await render('/')
248
+ expect(dialog()).toBeNull()
249
+ expect(status.current.unread).toBe(true)
250
+ expect(seenPosts).toEqual([])
251
+ await act(async () => { openWhatsNew() })
252
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.16")
253
+ expect(dialog()?.textContent).not.toContain('Capacity views')
254
+ await clickGotIt()
255
+ expect(seenPosts).toEqual(['v1.16.0'])
256
+ expect(status.current.unread).toBe(false)
257
+ })
258
+
259
+ it('does not record a quiet upgrade without a seen record as read', async () => {
260
+ setCatalog(QUIET)
261
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', priorInstall: true }
262
+ await render('/')
263
+ expect(dialog()).toBeNull()
264
+ expect(seenPosts).toEqual([])
265
+ expect(status.current.unread).toBe(true)
266
+ })
267
+
268
+ it('composes the releases a user skipped, leading with the strongest', async () => {
269
+ setCatalog(HEADLINE, QUIET)
270
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
271
+ await render('/')
272
+ const text = dialog()?.textContent ?? ''
273
+ expect(text).toContain("What's new since Radar v1.14")
274
+ expect(text).toContain('Also new')
275
+ expect(dialog()?.querySelector('li')?.textContent).toContain('Capacity views')
276
+ expect(dialog()?.querySelector('li')?.textContent).toContain('v1.15')
277
+ const tiles = Array.from(dialog()!.querySelectorAll('ul > li span.w-8')).map(t => t.className)
278
+ expect(tiles).toHaveLength(4)
279
+ expect(new Set(tiles).size).toBe(4)
280
+ await clickGotIt()
281
+ expect(seenPosts).toEqual(['v1.16.0'])
282
+ })
283
+
284
+ it('previews an upgrade from another version without recording anything', async () => {
285
+ setCatalog(HEADLINE, QUIET)
286
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.16.0', priorInstall: true }
287
+ await render('/?whats-new=v1.16.0&whats-new-from=v1.14.0')
288
+ expect(dialog()?.textContent).toContain("What's new since Radar v1.14")
289
+ await clickGotIt()
290
+ expect(seenPosts).toEqual([])
291
+ })
292
+
293
+ it('records nothing when previewing the running release', async () => {
294
+ serverState = { currentVersion: 'v1.15.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
295
+ await render('/resources/pods?whats-new')
296
+ expect(dialog()).not.toBeNull()
297
+ await clickGotIt()
298
+ expect(seenPosts).toEqual([])
299
+ expect(status.current.unread).toBe(true)
300
+ })
301
+
302
+ it('does not name a seen record that is not a version', async () => {
303
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
304
+ localStorage.setItem('radar-whats-new-seen', 'vdev')
305
+ await render('/')
306
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.15")
307
+ expect(dialog()?.textContent).not.toContain('vdev')
308
+ })
309
+
310
+ it('links to the public changelog', async () => {
311
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
312
+ await render('/')
313
+ const link = Array.from(dialog()!.querySelectorAll('a')).find(a => a.textContent?.includes('Full changelog'))
314
+ expect(link?.getAttribute('href')).toBe('https://radarhq.io/changelog')
315
+ })
316
+ it('records the version it was opened for, even if the server is upgraded while it is open', async () => {
317
+ setCatalog(HEADLINE, QUIET)
318
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
319
+ await render('/')
320
+ expect(dialog()).not.toBeNull()
321
+ serverState = { ...serverState, currentVersion: 'v1.16.0' }
322
+ await act(async () => { await client.refetchQueries({ queryKey: ['whats-new'] }) })
323
+ await clickGotIt()
324
+ expect(seenPosts).toEqual(['v1.15.2'])
325
+ })
326
+
327
+ it('names the previewed release as the destination of a previewed upgrade', async () => {
328
+ setCatalog(HEADLINE, QUIET)
329
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
330
+ await render('/?whats-new=v1.16.0&whats-new-from=v1.14.0')
331
+ const text = dialog()?.textContent ?? ''
332
+ expect(text).toContain('v1.16.0')
333
+ expect(text).not.toContain('v1.15.2')
334
+ })
335
+ it('uses the lead copy only while a highlight leads the dialog', async () => {
336
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
337
+ await render('/')
338
+ expect(dialog()?.textContent).toContain('Capacity lead copy')
339
+ await clickGotIt()
340
+ const stronger: ReleaseNotes = { version: 'v1.16.0', highlights: [card('big', 'Bigger thing', 10), card('big2', 'Second thing', 10)], improvements: [] }
341
+ setCatalog(HEADLINE, stronger)
342
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
343
+ await act(async () => root.unmount())
344
+ root = createRoot(document.body.appendChild(document.createElement('div')))
345
+ client.clear()
346
+ await render('/')
347
+ const text = dialog()?.textContent ?? ''
348
+ expect(text).toContain('Capacity views')
349
+ expect(text).not.toContain('Capacity lead copy')
350
+ })
229
351
  })