@skyhook-io/radar-app 1.16.1 → 1.16.3
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 +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
|
@@ -1,50 +1,125 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { Megaphone } from 'lucide-react'
|
|
3
|
-
import { nextSeenVersion,
|
|
4
|
-
import {
|
|
3
|
+
import { nextSeenVersion, whatsNewToOpen } from './WhatsNew'
|
|
4
|
+
import {
|
|
5
|
+
AUTO_OPEN_SCORE,
|
|
6
|
+
composeReleaseNotes,
|
|
7
|
+
latestReleaseNotesFor,
|
|
8
|
+
MAX_CARDS,
|
|
9
|
+
MAX_LINES,
|
|
10
|
+
releaseLine,
|
|
11
|
+
RELEASE_NOTES,
|
|
12
|
+
releaseNotesFor,
|
|
13
|
+
unreadReleases,
|
|
14
|
+
type ReleaseNotes,
|
|
15
|
+
} from './releaseNotes'
|
|
5
16
|
import { compareVersions } from '../../utils/version'
|
|
6
17
|
|
|
7
|
-
const
|
|
18
|
+
const highlight = (id: string, importance: number) => ({ id, icon: Megaphone, title: id, description: 'd', importance })
|
|
19
|
+
|
|
20
|
+
const entry = (version: string, importances: number[] = [9, 9, 9], improvements: string[] = []): ReleaseNotes => ({
|
|
8
21
|
version,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
improvements: [],
|
|
22
|
+
highlights: importances.map((importance, i) => highlight(`${version}-${i}`, importance)),
|
|
23
|
+
improvements,
|
|
12
24
|
})
|
|
13
25
|
|
|
14
26
|
const catalog = [entry('v1.15.0'), entry('v2.0.0')]
|
|
15
27
|
|
|
16
|
-
const
|
|
17
|
-
|
|
28
|
+
const unread = (current: string, lastSeen: string | null, prior = true, list = catalog) =>
|
|
29
|
+
unreadReleases(current, lastSeen, prior, list).map(n => n.version)
|
|
18
30
|
|
|
19
|
-
describe('
|
|
20
|
-
it('
|
|
21
|
-
expect(
|
|
22
|
-
expect(
|
|
23
|
-
expect(
|
|
31
|
+
describe('unreadReleases', () => {
|
|
32
|
+
it('lists the releases after the last one seen, newest first', () => {
|
|
33
|
+
expect(unread('v2.0.0', 'v1.14.0')).toEqual(['v2.0.0', 'v1.15.0'])
|
|
34
|
+
expect(unread('v2.0.0', 'v1.15.0')).toEqual(['v2.0.0'])
|
|
35
|
+
expect(unread('v2.0.0', 'v2.0.0')).toEqual([])
|
|
36
|
+
expect(unread('2.0.0', 'v2.0.0')).toEqual([])
|
|
24
37
|
})
|
|
25
38
|
|
|
26
|
-
it('
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
39
|
+
it('covers a patch of a release with notes, and nothing above the running version', () => {
|
|
40
|
+
expect(unread('v1.15.1', 'v1.14.1')).toEqual(['v1.15.0'])
|
|
41
|
+
expect(unread('v2.3.4', 'v1.15.2')).toEqual(['v2.0.0'])
|
|
29
42
|
})
|
|
30
43
|
|
|
31
|
-
it('
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
44
|
+
it('is empty once a later release was seen, including after a downgrade', () => {
|
|
45
|
+
expect(unread('v1.15.2', 'v1.15.1')).toEqual([])
|
|
46
|
+
expect(unread('v1.15.0', 'v2.0.0')).toEqual([])
|
|
34
47
|
})
|
|
35
48
|
|
|
36
|
-
it('
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
49
|
+
it('is empty on a fresh install, and covers everything for installs that predate the seen record', () => {
|
|
50
|
+
expect(unread('v2.0.0', null, false)).toEqual([])
|
|
51
|
+
expect(unread('v2.0.0', null, true)).toEqual(['v2.0.0', 'v1.15.0'])
|
|
39
52
|
})
|
|
40
53
|
|
|
41
54
|
it('treats a record that is not a version as an install with nothing seen', () => {
|
|
42
|
-
expect(
|
|
55
|
+
expect(unread('v2.0.0', 'vdev', false)).toEqual(['v2.0.0', 'v1.15.0'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is empty when no notes exist at or below the running version', () => {
|
|
59
|
+
expect(unread('v1.14.9', 'v1.14.0')).toEqual([])
|
|
60
|
+
expect(unread('dev', 'v1.14.1')).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('orders by version whatever the catalog order', () => {
|
|
64
|
+
expect(unread('v2.0.0', null, true, [entry('v1.15.0'), entry('v2.0.0'), entry('v1.16.0')])).toEqual(['v2.0.0', 'v1.16.0', 'v1.15.0'])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('composeReleaseNotes', () => {
|
|
69
|
+
it('ranks highlights by importance, weighting older releases down', () => {
|
|
70
|
+
const notes = composeReleaseNotes([entry('v1.17.0', [6]), entry('v1.16.0', [9]), entry('v1.15.0', [10])])
|
|
71
|
+
expect(notes.highlights.map(h => [h.id, h.score])).toEqual([
|
|
72
|
+
['v1.16.0-0', 6.75],
|
|
73
|
+
['v1.17.0-0', 6],
|
|
74
|
+
['v1.15.0-0', 5],
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('breaks ties toward the newer release, then the authored order', () => {
|
|
79
|
+
const notes = composeReleaseNotes([entry('v1.16.0', [3, 6]), entry('v1.15.0', [8])])
|
|
80
|
+
expect(notes.highlights.map(h => h.id)).toEqual(['v1.16.0-1', 'v1.15.0-0', 'v1.16.0-0'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps the authored order of a single release with equal importance', () => {
|
|
84
|
+
expect(composeReleaseNotes([entry('v1.16.0', [5, 5, 5])]).highlights.map(h => h.id)).toEqual(['v1.16.0-0', 'v1.16.0-1', 'v1.16.0-2'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('shows at most three releases and says when older ones were left out', () => {
|
|
88
|
+
const four = [entry('v1.18.0'), entry('v1.17.0'), entry('v1.16.0'), entry('v1.15.0')]
|
|
89
|
+
const notes = composeReleaseNotes(four)
|
|
90
|
+
expect(notes.versions).toEqual(['v1.18.0', 'v1.17.0', 'v1.16.0'])
|
|
91
|
+
expect(notes.complete).toBe(false)
|
|
92
|
+
expect(notes.highlights.some(h => h.version === 'v1.15.0')).toBe(false)
|
|
93
|
+
expect(composeReleaseNotes(four.slice(0, 3)).complete).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('turns highlights past the cards into lines ahead of the improvements, within the line cap', () => {
|
|
97
|
+
const notes = composeReleaseNotes([
|
|
98
|
+
entry('v1.16.0', [7, 7, 7, 7, 7, 2], ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']),
|
|
99
|
+
entry('v1.15.0', [1], ['old']),
|
|
100
|
+
])
|
|
101
|
+
expect(notes.highlights).toHaveLength(MAX_CARDS)
|
|
102
|
+
expect(notes.lines).toHaveLength(MAX_LINES)
|
|
103
|
+
expect(notes.lines.slice(0, 2).map(l => l.text)).toEqual(['v1.16.0-5', 'v1.15.0-0'])
|
|
104
|
+
expect(notes.lines.map(l => l.text)).not.toContain('old')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('scores the dialog by the cards it shows', () => {
|
|
108
|
+
expect(composeReleaseNotes([entry('v1.16.0', [4, 4, 4, 4, 4, 10])]).score).toBe(10 + 4 * 4)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('whatsNewToOpen', () => {
|
|
113
|
+
it('opens when the unseen releases show enough, and not for a quiet release', () => {
|
|
114
|
+
const list = [entry('v1.15.0', [9, 8, 7, 7, 7]), entry('v1.16.0', [6, 5, 4])]
|
|
115
|
+
expect(whatsNewToOpen('v1.15.0', 'v1.14.1', true, list)?.score).toBe(38)
|
|
116
|
+
expect(whatsNewToOpen('v1.16.0', 'v1.15.0', true, list)).toBeNull()
|
|
117
|
+
expect(whatsNewToOpen('v1.16.0', 'v1.14.1', true, list)?.score).toBeGreaterThanOrEqual(AUTO_OPEN_SCORE)
|
|
43
118
|
})
|
|
44
119
|
|
|
45
|
-
it('stays closed when
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
120
|
+
it('stays closed when nothing is unseen', () => {
|
|
121
|
+
expect(whatsNewToOpen('v2.0.0', 'v2.0.0', true, catalog)).toBeNull()
|
|
122
|
+
expect(whatsNewToOpen('v2.0.0', null, false, catalog)).toBeNull()
|
|
48
123
|
})
|
|
49
124
|
})
|
|
50
125
|
|
|
@@ -91,13 +166,17 @@ describe('the shipped catalog', () => {
|
|
|
91
166
|
expect(new Set(versions).size).toBe(versions.length)
|
|
92
167
|
for (const notes of RELEASE_NOTES) {
|
|
93
168
|
expect(notes.version, notes.version).toMatch(/^v\d+\.\d+\.\d+$/)
|
|
94
|
-
expect(notes.highlights.length, `${notes.version} needs a
|
|
169
|
+
expect(notes.highlights.length, `${notes.version} needs a highlight`).toBeGreaterThan(0)
|
|
95
170
|
expect(new Set(notes.highlights.map(h => h.id)).size, `${notes.version} highlight ids`).toBe(notes.highlights.length)
|
|
171
|
+
expect(new Set(notes.improvements).size, `${notes.version} improvements`).toBe(notes.improvements.length)
|
|
172
|
+
const top = notes.highlights.reduce((best, h) => (h.importance > best.importance ? h : best))
|
|
96
173
|
for (const h of notes.highlights) {
|
|
174
|
+
expect(h.description.length, `${notes.version}/${h.id}: description fits a card`).toBeLessThanOrEqual(150)
|
|
175
|
+
if (h.leadDescription) expect(h.id, `${notes.version}/${h.id}: only the release's top highlight can lead`).toBe(top.id)
|
|
176
|
+
expect(Number.isInteger(h.importance) && h.importance >= 1 && h.importance <= 10, `${notes.version}/${h.id}: importance is 1-10`).toBe(true)
|
|
97
177
|
expect(!!h.path === !!h.cta, `${notes.version}/${h.id}: path and cta go together`).toBe(true)
|
|
98
178
|
if (h.path) expect(h.path, `${notes.version}/${h.id}`).toMatch(/^\//)
|
|
99
179
|
}
|
|
100
|
-
expect(notes.releaseUrl, notes.version).toMatch(/^https:\/\//)
|
|
101
180
|
}
|
|
102
181
|
})
|
|
103
182
|
})
|
|
@@ -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 {
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
36
|
+
export function whatsNewToOpen(
|
|
25
37
|
currentVersion: string,
|
|
26
38
|
lastSeen: string | null,
|
|
27
39
|
priorInstall: boolean,
|
|
28
40
|
catalog: ReleaseNotes[] = RELEASE_NOTES,
|
|
29
|
-
):
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
125
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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 (
|
|
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
|
|
180
|
-
if (!
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 =
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 &&
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
if (
|
|
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,
|
|
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
|
-
{
|
|
280
|
+
{view && (
|
|
246
281
|
<WhatsNewContent
|
|
247
282
|
titleId={titleId}
|
|
248
|
-
notes={notes}
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
{line}
|
|
337
|
-
|
|
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-
|
|
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={
|
|
403
|
+
href={CHANGELOG_URL}
|
|
349
404
|
target="_blank"
|
|
350
405
|
rel="noopener noreferrer"
|
|
351
|
-
className="inline-flex items-center gap-1 text-
|
|
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
|
|
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
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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',
|
|
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">
|