@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.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- 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/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- 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/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -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 +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- 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 {
|
|
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">
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|