@skyhook-io/radar-app 1.14.5 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,147 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { AlertTriangle } from 'lucide-react'
3
+ import { pluralize } from '@skyhook-io/k8s-ui'
4
+ import { useSwitchContext, fetchSessionCounts, type SessionCounts } from '../api/client'
5
+ import { useContextSwitch } from '../context/ContextSwitchContext'
6
+ import { useToast } from './ui/Toast'
7
+ import { useDock } from './dock'
8
+ import type { ContextInfo, SelectedResource } from '../types'
9
+ import { parseContextForSwitcher } from '../utils/context-name'
10
+
11
+ function shouldSuppressSwitchErrorToast(error: unknown): boolean {
12
+ const message = error instanceof Error ? error.message : ''
13
+ return message.includes('cluster connection failed:')
14
+ }
15
+
16
+ interface PendingSwitch {
17
+ context: ContextInfo
18
+ openAfter?: SelectedResource
19
+ }
20
+
21
+ // The one way to switch contexts from the UI: the switching overlay, a
22
+ // confirmation when port-forwards or terminals would be killed, and an error
23
+ // toast when the switch fails. `openAfter` is opened once the new context is
24
+ // live. Callers render `confirmDialog`.
25
+ export function useContextSwitchFlow() {
26
+ const switchContext = useSwitchContext()
27
+ const { startSwitch, endSwitch, setOpenAfterSwitch } = useContextSwitch()
28
+ const { showError } = useToast()
29
+ const { tabs } = useDock()
30
+ const [pending, setPending] = useState<PendingSwitch | null>(null)
31
+ const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
32
+
33
+ const performSwitch = async ({ context, openAfter }: PendingSwitch) => {
34
+ const parsed = parseContextForSwitcher(context)
35
+ startSwitch({
36
+ raw: parsed.raw,
37
+ provider: parsed.provider,
38
+ account: parsed.account,
39
+ region: parsed.region,
40
+ clusterName: parsed.clusterName,
41
+ })
42
+ setOpenAfterSwitch(openAfter ? { context: context.name, resource: openAfter } : null)
43
+ try {
44
+ await switchContext.mutateAsync({ name: context.name })
45
+ } catch (error) {
46
+ console.error('Failed to switch context:', error)
47
+ setOpenAfterSwitch(null)
48
+ endSwitch()
49
+ // Backend may not transition to StateDisconnected on client-side errors
50
+ // (network, timeout) — without this toast the user gets no feedback.
51
+ if (!shouldSuppressSwitchErrorToast(error)) {
52
+ const message = error instanceof Error ? error.message : 'Unknown error'
53
+ showError('Failed to switch context', message)
54
+ }
55
+ }
56
+ }
57
+
58
+ const requestSwitch = async (context: ContextInfo, openAfter?: SelectedResource) => {
59
+ if (context.isCurrent || switchContext.isPending) return
60
+ // Active sessions (port forwards from API + terminal tabs from dock) get
61
+ // a confirmation prompt — switching contexts kills both.
62
+ try {
63
+ const counts = await fetchSessionCounts()
64
+ const terminalTabs = tabs.filter(t => t.type === 'terminal').length
65
+ const total = counts.portForwards + terminalTabs
66
+ if (total > 0) {
67
+ setSessionCounts({ ...counts, execSessions: terminalTabs, total })
68
+ setPending({ context, openAfter })
69
+ return
70
+ }
71
+ } catch (error) {
72
+ // Session-counts is best-effort; failing it shouldn't block the user.
73
+ // But warn — if there ARE active sessions we couldn't see, the switch
74
+ // will silently kill them.
75
+ console.error('Failed to check sessions:', error)
76
+ showError(
77
+ 'Could not check active sessions',
78
+ 'Switching anyway. Any open port-forwards or terminals will be terminated.',
79
+ )
80
+ }
81
+ performSwitch({ context, openAfter })
82
+ }
83
+
84
+ const confirm = () => {
85
+ if (pending) performSwitch(pending)
86
+ setPending(null)
87
+ }
88
+
89
+ const cancel = () => {
90
+ setPending(null)
91
+ setSessionCounts(null)
92
+ }
93
+
94
+ const confirmDialog: ReactNode = pending && sessionCounts ? (
95
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
96
+ <div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
97
+ <div className="px-4 py-3 border-b border-theme-border flex items-center gap-2">
98
+ <AlertTriangle className="w-5 h-5 text-amber-400" />
99
+ <span className="font-medium text-theme-text-primary">Active Sessions</span>
100
+ </div>
101
+ <div className="px-4 py-4">
102
+ <p className="text-sm text-theme-text-secondary mb-3">
103
+ Switching contexts will terminate active sessions:
104
+ </p>
105
+ <ul className="text-sm text-theme-text-primary space-y-1 mb-4">
106
+ {sessionCounts.portForwards > 0 && (
107
+ <li className="flex items-center gap-2">
108
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
109
+ {pluralize(sessionCounts.portForwards, 'port forward')}
110
+ </li>
111
+ )}
112
+ {sessionCounts.execSessions > 0 && (
113
+ <li className="flex items-center gap-2">
114
+ <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
115
+ {pluralize(sessionCounts.execSessions, 'terminal session')}
116
+ </li>
117
+ )}
118
+ </ul>
119
+ <p className="text-xs text-theme-text-tertiary">
120
+ Switch to: <span className="text-theme-text-secondary">{parseContextForSwitcher(pending.context).clusterName}</span>
121
+ </p>
122
+ </div>
123
+ <div className="px-4 py-3 border-t border-theme-border flex justify-end gap-2">
124
+ <button
125
+ onClick={cancel}
126
+ className="px-3 py-1.5 text-sm rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary transition-colors"
127
+ >
128
+ Cancel
129
+ </button>
130
+ <button
131
+ onClick={confirm}
132
+ className="px-3 py-1.5 text-sm rounded-md bg-amber-500 hover:bg-amber-600 text-white transition-colors"
133
+ >
134
+ Switch Anyway
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ ) : null
140
+
141
+ return {
142
+ requestSwitch,
143
+ isPending: switchContext.isPending,
144
+ error: switchContext.isError ? switchContext.error : null,
145
+ confirmDialog,
146
+ }
147
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Megaphone } from 'lucide-react'
3
+ import { nextSeenVersion, whatsNewToShow } from './WhatsNew'
4
+ import { latestReleaseNotesFor, releaseLine, RELEASE_NOTES, releaseNotesFor, type ReleaseNotes } from './releaseNotes'
5
+ import { compareVersions } from '../../utils/version'
6
+
7
+ const entry = (version: string): ReleaseNotes => ({
8
+ version,
9
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases',
10
+ highlights: [{ id: 'x', icon: Megaphone, title: 'X', description: 'Y' }],
11
+ improvements: [],
12
+ })
13
+
14
+ const catalog = [entry('v1.15.0'), entry('v2.0.0')]
15
+
16
+ const show = (current: string, lastSeen: string | null, prior = true) =>
17
+ whatsNewToShow(current, lastSeen, prior, catalog)?.version ?? null
18
+
19
+ describe('whatsNewToShow', () => {
20
+ it('shows when the notes are newer than the last version seen', () => {
21
+ expect(show('v2.0.0', 'v1.15.0')).toBe('v2.0.0')
22
+ expect(show('v2.0.0', 'v2.0.0')).toBeNull()
23
+ expect(show('2.0.0', 'v2.0.0')).toBeNull()
24
+ })
25
+
26
+ it('shows the newest earlier notes when the running release has none of its own', () => {
27
+ expect(show('v1.15.1', 'v1.14.1')).toBe('v1.15.0')
28
+ expect(show('v2.3.4', 'v1.15.2')).toBe('v2.0.0')
29
+ })
30
+
31
+ it('stays closed once a later release was seen, including after a downgrade', () => {
32
+ expect(show('v1.15.2', 'v1.15.1')).toBeNull()
33
+ expect(show('v1.15.0', 'v2.0.0')).toBeNull()
34
+ })
35
+
36
+ it('stays closed on a fresh install, and shows for installs that predate the seen record', () => {
37
+ expect(show('v2.0.0', null, false)).toBeNull()
38
+ expect(show('v2.0.0', null, true)).toBe('v2.0.0')
39
+ })
40
+
41
+ it('treats a record that is not a version as an install with nothing seen', () => {
42
+ expect(show('v2.0.0', 'vdev', false)).toBe('v2.0.0')
43
+ })
44
+
45
+ it('stays closed when no notes exist at or below the running version', () => {
46
+ expect(show('v1.14.9', 'v1.14.0')).toBeNull()
47
+ expect(show('dev', 'v1.14.1')).toBeNull()
48
+ })
49
+ })
50
+
51
+ describe('nextSeenVersion', () => {
52
+ it('only moves forward', () => {
53
+ expect(nextSeenVersion('v2.0.0', 'v1.15.0')).toBe('v2.0.0')
54
+ expect(nextSeenVersion('2.0.1', null)).toBe('v2.0.1')
55
+ expect(nextSeenVersion('1.15.0', 'v2.0.0')).toBeNull()
56
+ expect(nextSeenVersion('v2.0.0', 'v2.0.0')).toBeNull()
57
+ })
58
+
59
+ it('never records a development build, and replaces a record that is not a version', () => {
60
+ expect(nextSeenVersion('dev', null)).toBeNull()
61
+ expect(nextSeenVersion('dev', 'v2.0.0')).toBeNull()
62
+ expect(nextSeenVersion('v2.0.0', 'vdev')).toBe('v2.0.0')
63
+ })
64
+ })
65
+
66
+ describe('release notes lookup', () => {
67
+ it('matches an exact version with or without the v prefix', () => {
68
+ expect(releaseNotesFor('2.0.0', catalog)?.version).toBe('v2.0.0')
69
+ expect(releaseNotesFor(undefined, catalog)).toBeUndefined()
70
+ })
71
+
72
+ it('picks the newest entry at or below a version, whatever the catalog order', () => {
73
+ expect(latestReleaseNotesFor('v2.1.0', [entry('v2.0.0'), entry('v1.15.0')])?.version).toBe('v2.0.0')
74
+ expect(latestReleaseNotesFor('v1.99.0', catalog)?.version).toBe('v1.15.0')
75
+ expect(latestReleaseNotesFor('v1.0.0', catalog)).toBeUndefined()
76
+ })
77
+ })
78
+
79
+ describe('compareVersions', () => {
80
+ it('orders by semver, with a prerelease before its release', () => {
81
+ expect(compareVersions('v1.10.0', 'v1.9.9')).toBeGreaterThan(0)
82
+ expect(compareVersions('1.2.3', 'v1.2.3')).toBe(0)
83
+ expect(compareVersions('v1.2.3-rc.1', 'v1.2.3')).toBeLessThan(0)
84
+ expect(compareVersions('dev', 'v1.2.3')).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('the shipped catalog', () => {
89
+ it('has one well-formed entry per release', () => {
90
+ const versions = RELEASE_NOTES.map(n => n.version)
91
+ expect(new Set(versions).size).toBe(versions.length)
92
+ for (const notes of RELEASE_NOTES) {
93
+ expect(notes.version, notes.version).toMatch(/^v\d+\.\d+\.\d+$/)
94
+ expect(notes.highlights.length, `${notes.version} needs a lead highlight`).toBeGreaterThan(0)
95
+ expect(new Set(notes.highlights.map(h => h.id)).size, `${notes.version} highlight ids`).toBe(notes.highlights.length)
96
+ for (const h of notes.highlights) {
97
+ expect(!!h.path === !!h.cta, `${notes.version}/${h.id}: path and cta go together`).toBe(true)
98
+ if (h.path) expect(h.path, `${notes.version}/${h.id}`).toMatch(/^\//)
99
+ }
100
+ expect(notes.releaseUrl, notes.version).toMatch(/^https:\/\//)
101
+ }
102
+ })
103
+ })
104
+
105
+ describe('releaseLine', () => {
106
+ it('names the minor line, since its patches show the same notes', () => {
107
+ expect(releaseLine('v1.15.0')).toBe('v1.15')
108
+ expect(releaseLine('v2.0.0')).toBe('v2.0')
109
+ })
110
+ })
@@ -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
+ }