@skyhook-io/radar-app 1.16.2 → 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.
Files changed (42) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +46 -5
  3. package/src/api/client.ts +53 -47
  4. package/src/api/cnpg.ts +95 -0
  5. package/src/api/timelineSource.ts +1 -1
  6. package/src/components/NoClusterAccessBanner.tsx +68 -0
  7. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  8. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  9. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  10. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  11. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  12. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  13. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  14. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  15. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  16. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  17. package/src/components/cnpg/CNPGView.tsx +156 -0
  18. package/src/components/cnpg/paths.ts +13 -0
  19. package/src/components/cnpg/routes.test.ts +60 -0
  20. package/src/components/cnpg/routes.ts +112 -0
  21. package/src/components/cnpg/shared.tsx +290 -0
  22. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  23. package/src/components/helm/ChartBrowser.tsx +6 -7
  24. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  25. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  26. package/src/components/helm/InstallWizard.tsx +5 -2
  27. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  28. package/src/components/helm/ValuesViewer.tsx +1 -1
  29. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  30. package/src/components/resources/ResourcesView.tsx +6 -38
  31. package/src/components/timeline/TimelineList.test.ts +47 -29
  32. package/src/components/timeline/TimelineList.tsx +57 -11
  33. package/src/components/timeline/TimelineView.tsx +9 -2
  34. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  35. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  36. package/src/components/whats-new/WhatsNew.tsx +146 -86
  37. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  38. package/src/components/whats-new/releaseNotes.ts +155 -24
  39. package/src/components/workload/WorkloadView.tsx +30 -4
  40. package/src/contexts/CapabilitiesContext.tsx +9 -1
  41. package/src/hooks/useResourceCounts.ts +49 -0
  42. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { applyClientFilters } from '../../api/timelineSource'
3
3
  import type { TimelineEvent } from '../../types'
4
- import { listIncludesManaged } from './TimelineList'
4
+ import { splitRoutineActivity } from './TimelineList'
5
5
 
6
+ let clock = Date.parse('2024-01-01T00:00:00.000Z')
6
7
  function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
8
+ clock += 1000
7
9
  return {
8
- timestamp: '2024-01-01T00:00:00.000Z',
10
+ timestamp: new Date(clock).toISOString(),
9
11
  source: 'informer',
10
12
  kind: 'Deployment',
11
13
  namespace: 'ns-a',
@@ -15,42 +17,58 @@ function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
15
17
  }
16
18
  }
17
19
 
18
- // Mirrors the host: the source filter runs first, then the UI keeps the selected kinds.
19
- function listRows(events: TimelineEvent[], kinds: string[]): string[] {
20
- return applyClientFilters(events, { includeManaged: listIncludesManaged(false, kinds) })
21
- .filter((e) => kinds.length === 0 || kinds.includes(e.kind))
22
- .map((e) => e.id)
20
+ const deploy = ev({ id: 'deploy', kind: 'Deployment' })
21
+ const podStarted = ev({ id: 'pod-started', kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
22
+ const podOom = ev({ id: 'pod-oom', kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
23
+ const rsScaled = ev({ id: 'rs-scaled', kind: 'ReplicaSet' })
24
+ const cronJobOk = ev({ id: 'cron-job-ok', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'healthy' })
25
+ const cronJobFailed = ev({ id: 'cron-job-failed', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'unhealthy' })
26
+ const standaloneJob = ev({ id: 'standalone-job', kind: 'Job' })
27
+ const ALL = [deploy, podStarted, podOom, rsScaled, cronJobOk, cronJobFailed, standaloneJob]
28
+
29
+ // Mirrors the host: one uncapped query through the source's client filters.
30
+ function split(events: TimelineEvent[], kinds: string[], limit = 2000) {
31
+ return splitRoutineActivity(applyClientFilters(events, { kinds, includeManaged: true }), limit)
23
32
  }
33
+ const ids = (rows: TimelineEvent[]) => rows.map((e) => e.id).sort()
34
+ const oom = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
35
+ const started = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
24
36
 
25
- describe('listIncludesManaged', () => {
26
- it('includes managed rows when app-scoped or when any kind is selected', () => {
27
- expect(listIncludesManaged(true, [])).toBe(true)
28
- expect(listIncludesManaged(false, ['Pod'])).toBe(true)
29
- expect(listIncludesManaged(false, ['Job'])).toBe(true)
37
+ describe('splitRoutineActivity', () => {
38
+ it('keeps owner rows and child problems, holds routine child rows apart', () => {
39
+ const { withoutRoutine, routine } = split(ALL, [])
40
+ expect(ids(withoutRoutine)).toEqual(['cron-job-failed', 'deploy', 'pod-oom', 'standalone-job'])
41
+ expect(ids(routine)).toEqual(['cron-job-ok', 'pod-started', 'rs-scaled'])
30
42
  })
31
43
 
32
- it('keeps hiding managed rows for the unfiltered list', () => {
33
- expect(listIncludesManaged(false, [])).toBe(false)
34
- })
35
- })
44
+ it('shows a picked kind\'s problems and holds its routine rows apart', () => {
45
+ const pods = split(ALL, ['Pod'])
46
+ expect(ids(pods.withoutRoutine)).toEqual(['pod-oom'])
47
+ expect(ids(pods.routine)).toEqual(['pod-started'])
36
48
 
37
- describe('timeline list rows', () => {
38
- const events = [
39
- ev({ id: 'dep', kind: 'Deployment' }),
40
- ev({ id: 'pod', kind: 'Pod' }),
41
- ev({ id: 'job', kind: 'Job' }),
42
- ev({ id: 'cron-job', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' } }),
43
- ]
49
+ const jobs = split(ALL, ['Job'])
50
+ expect(ids(jobs.withoutRoutine)).toEqual(['cron-job-failed', 'standalone-job'])
51
+ expect(ids(jobs.routine)).toEqual(['cron-job-ok'])
52
+ })
44
53
 
45
- it('lists pod rows for a Pod selection', () => {
46
- expect(listRows(events, ['Pod'])).toEqual(['pod'])
54
+ it('lists rows newest first', () => {
55
+ expect(split(ALL, []).withoutRoutine.map((e) => e.id)).toEqual(['standalone-job', 'cron-job-failed', 'pod-oom', 'deploy'])
47
56
  })
48
57
 
49
- it('lists owned rows of an unmanaged kind for that kind', () => {
50
- expect(listRows(events, ['Job']).sort()).toEqual(['cron-job', 'job'])
58
+ it('never lets owner rows crowd out child problems', () => {
59
+ const owners = [1, 2, 3].map((n) => ev({ id: `deploy-${n}`, kind: 'Deployment' }))
60
+ const { withoutRoutine, truncated } = split([oom('old-oom'), ...owners], [], 2)
61
+ expect(ids(withoutRoutine)).toEqual(['deploy-2', 'deploy-3', 'old-oom'])
62
+ expect(truncated).toBe(true)
51
63
  })
52
64
 
53
- it('hides pod and owned rows when no kind is selected', () => {
54
- expect(listRows(events, []).sort()).toEqual(['dep', 'job'])
65
+ it('only adds rows when routine activity is shown', () => {
66
+ const olderDeploy = ev({ id: 'older-deploy', kind: 'Deployment' })
67
+ const burst = [1, 2, 3].map((n) => started(`started-${n}`))
68
+ const { withoutRoutine, withRoutine, routine, routineTruncated } = split([olderDeploy, ...burst], [], 2)
69
+ expect(ids(withoutRoutine)).toEqual(['older-deploy'])
70
+ expect(ids(withRoutine)).toEqual(['older-deploy', 'started-2', 'started-3'])
71
+ expect(ids(routine)).toEqual(['started-2', 'started-3'])
72
+ expect(routineTruncated).toBe(true)
55
73
  })
56
74
  })
@@ -3,13 +3,16 @@ import {
3
3
  SEVERITY_TEXT,
4
4
  TimelineList as TimelineListUI,
5
5
  eventsForApplication,
6
+ isTimelineProblem,
6
7
  type ActivityTypeFilter,
7
8
  type ActivityFilterKey,
8
9
  type AppMembershipIndex,
9
10
  type TimeRange,
10
11
  type Topology,
11
12
  } from '@skyhook-io/k8s-ui'
13
+ import type { TimelineEvent } from '../../types'
12
14
  import { useTimelineSource } from '../../context/TimelineSource'
15
+ import { isManagedTimelineEvent } from '../../api/timelineSource'
13
16
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
14
17
  import type { NavigateToResource } from '../../utils/navigation'
15
18
  import { AlertTriangle, RefreshCw } from 'lucide-react'
@@ -24,10 +27,33 @@ export type { ActivityTypeFilter, ActivityFilterKey }
24
27
  const LIST_FETCH_LIMIT = 2000
25
28
  const APP_SCOPED_FETCH_LIMIT = 10000
26
29
 
27
- // The source drops managed rows (Pod, ReplicaSet, Event, or any owned row) before
28
- // the kind filter runs, so an explicit kind selection must keep them or it lists nothing.
29
- export function listIncludesManaged(appScoped: boolean, kinds: string[]): boolean {
30
- return appScoped || kinds.length > 0
30
+ function isChildProblem(e: TimelineEvent): boolean {
31
+ return isManagedTimelineEvent(e) && isTimelineProblem(e)
32
+ }
33
+
34
+ const byNewest = (a: TimelineEvent, b: TimelineEvent) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
35
+
36
+ // Splits the list's rows into owner-level rows, problems on child resources,
37
+ // and routine child activity, each newest first under its own `limit`. Separate
38
+ // budgets keep the promises the toggle makes: a burst of owner rows can't crowd
39
+ // out child problems, and showing routine activity only ever adds rows.
40
+ export function splitRoutineActivity(allRows: TimelineEvent[], limit: number) {
41
+ const owners: TimelineEvent[] = []
42
+ const childProblems: TimelineEvent[] = []
43
+ const routine: TimelineEvent[] = []
44
+ for (const e of allRows) {
45
+ const bucket = !isManagedTimelineEvent(e) ? owners : isTimelineProblem(e) ? childProblems : routine
46
+ bucket.push(e)
47
+ }
48
+ const base = [...owners.slice(0, limit), ...childProblems.slice(0, limit)].sort(byNewest)
49
+ const shownRoutine = routine.slice(0, limit)
50
+ return {
51
+ withoutRoutine: base,
52
+ withRoutine: [...base, ...shownRoutine].sort(byNewest),
53
+ routine: shownRoutine,
54
+ truncated: owners.length > limit || childProblems.length > limit,
55
+ routineTruncated: routine.length > limit,
56
+ }
31
57
  }
32
58
 
33
59
  interface TimelineListProps {
@@ -39,6 +65,10 @@ interface TimelineListProps {
39
65
  initialTimeRange?: TimeRange
40
66
  showDeleted: boolean
41
67
  onShowDeletedChange: (showDeleted: boolean) => void
68
+ // Routine activity (normal steps of pods, replica sets and other child
69
+ // resources) is hidden unless the user opts in. Problems always show.
70
+ showRoutine: boolean
71
+ onShowRoutineChange: (showRoutine: boolean) => void
42
72
  // Shared filter state lifted to TimelineView so it survives the view switch.
43
73
  search: string
44
74
  onSearchChange: (value: string) => void
@@ -64,7 +94,7 @@ interface TimelineListProps {
64
94
  appScopeLoading?: boolean
65
95
  }
66
96
 
67
- export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
97
+ export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, showRoutine, onShowRoutineChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
68
98
  const hasLimitedAccess = useHasLimitedAccess()
69
99
  const timelineSource = useTimelineSource()
70
100
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
@@ -77,26 +107,36 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
77
107
  }, [])
78
108
 
79
109
  const fetchLimit = appScoped ? APP_SCOPED_FETCH_LIMIT : LIST_FETCH_LIMIT
110
+ // The application-scoped list keeps every row: its app filter needs the pods.
111
+ const splitRows = !appScoped
112
+ // Uncapped when splitting: each part gets its own cap below. The rows come
113
+ // from the loaded ring, so this costs no extra fetch.
80
114
  const { data: fetchedEvents, isLoading, isError, error, refetch } = timelineSource.useEvents({
81
115
  namespaces,
82
116
  kinds: queryParams.kinds,
83
117
  timeRange: queryParams.timeRange,
84
118
  includeK8sEvents: true,
85
- includeManaged: listIncludesManaged(appScoped, queryParams.kinds),
119
+ includeManaged: true,
86
120
  includeDeleted: showDeleted,
87
- limit: fetchLimit,
121
+ limit: splitRows ? undefined : fetchLimit,
88
122
  fromMs: selectionWindow?.fromMs,
89
123
  toMs: selectionWindow?.toMs,
90
124
  })
125
+ const split = useMemo(
126
+ () => (splitRows && fetchedEvents ? splitRoutineActivity(fetchedEvents, fetchLimit) : undefined),
127
+ [splitRows, fetchedEvents, fetchLimit],
128
+ )
91
129
  const events = useMemo(() => {
92
- const unscoped = fetchedEvents ?? []
130
+ const unscoped = split ? (showRoutine ? split.withRoutine : split.withoutRoutine) : fetchedEvents ?? []
93
131
  return appScoped
94
132
  ? focusedAppIndex
95
133
  ? eventsForApplication(unscoped, topology, focusedAppIndex)
96
134
  : []
97
135
  : unscoped
98
- }, [appScoped, focusedAppIndex, topology, fetchedEvents])
99
- const sourceTruncated = (fetchedEvents?.length ?? 0) >= fetchLimit
136
+ }, [appScoped, focusedAppIndex, topology, split, showRoutine, fetchedEvents])
137
+ const sourceTruncated = split
138
+ ? split.truncated || (showRoutine && split.routineTruncated)
139
+ : (fetchedEvents?.length ?? 0) >= fetchLimit
100
140
 
101
141
  // Full-screen error only when nothing is loaded; a failing background poll
102
142
  // with data on screen keeps rendering (data before error).
@@ -151,6 +191,10 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
151
191
  hideRangeSelector={!!selectionWindow}
152
192
  showDeleted={showDeleted}
153
193
  onShowDeletedChange={onShowDeletedChange}
194
+ showRoutine={showRoutine}
195
+ onShowRoutineChange={split ? onShowRoutineChange : undefined}
196
+ routineEvents={split?.routine}
197
+ foldPerResource={split && !showRoutine ? isChildProblem : undefined}
154
198
  search={search}
155
199
  onSearchChange={onSearchChange}
156
200
  activityFilter={activityFilter}
@@ -163,7 +207,9 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
163
207
  isTruncated={sourceTruncated}
164
208
  truncationMessage={appScoped && sourceTruncated
165
209
  ? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
166
- : undefined}
210
+ : split && sourceTruncated
211
+ ? 'Some older activity in this range is not listed. Narrow the time range to see it.'
212
+ : undefined}
167
213
  />
168
214
  </div>
169
215
  </div>
@@ -125,6 +125,7 @@ export interface PersistedTimelineState {
125
125
  viewMode: TimelineViewMode
126
126
  mode: TimelineMode
127
127
  showDeleted: boolean
128
+ showRoutine: boolean
128
129
  pinnedOnly: boolean
129
130
  search: string
130
131
  activityFilter: ActivityFilterKey[]
@@ -237,6 +238,7 @@ export function writeTimelineParams(
237
238
  set('activity', s.activityFilter.length ? s.activityFilter.join(',') : null)
238
239
  set('kinds', s.kindFilter.length ? s.kindFilter.join(',') : null)
239
240
  set('deleted', s.showDeleted ? null : '0')
241
+ set('routine', s.showRoutine ? '1' : null)
240
242
  set('pinnedOnly', s.pinnedOnly ? '1' : null)
241
243
  set('q', s.search.length ? s.search : null)
242
244
  set('grouping', s.grouping !== DEFAULT_GROUPING ? s.grouping : null)
@@ -309,6 +311,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
309
311
  // and so the fetch can exclude deletes at the source rather than only hiding
310
312
  // them client-side.
311
313
  const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
314
+ const [showRoutine, setShowRoutine] = useState(() => searchParams.get('routine') === '1')
312
315
  // ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
313
316
  // arm a filter that hides everything. Gate the read on stored pins so the param
314
317
  // can never arm on its own — ordering-proof, independent of when the empty-pins
@@ -634,6 +637,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
634
637
  setMode((prev) => (timeModeEqual(prev, nextMode) ? prev : nextMode))
635
638
  const nextDeleted = sp.get('deleted') !== '0'
636
639
  setShowDeleted((prev) => (prev === nextDeleted ? prev : nextDeleted))
640
+ const nextRoutine = sp.get('routine') === '1'
641
+ setShowRoutine((prev) => (prev === nextRoutine ? prev : nextRoutine))
637
642
  // Same guard as the lazy init: the param can only arm the filter when pins
638
643
  // exist, so a mount that runs this after the empty-pins reset can't re-arm it.
639
644
  const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
@@ -658,7 +663,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
658
663
  const current = searchParamsRef.current
659
664
  const target = writeTimelineParams(
660
665
  current,
661
- { viewMode, mode, showDeleted, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
666
+ { viewMode, mode, showDeleted, showRoutine, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
662
667
  { isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
663
668
  )
664
669
  const targetStr = target.toString()
@@ -672,7 +677,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
672
677
  const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
673
678
  didMountUrlSyncRef.current = true
674
679
  setSearchParamsRef.current(target, { replace })
675
- }, [viewMode, mode, showDeleted, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
680
+ }, [viewMode, mode, showDeleted, showRoutine, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
676
681
 
677
682
  // Fetch all activity - zoom controls what's visible in the UI. This ring feeds
678
683
  // the swimlanes and the local strip's histogram, so it also runs in list mode
@@ -1032,6 +1037,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
1032
1037
  initialTimeRange={initialTimeRange}
1033
1038
  showDeleted={showDeleted}
1034
1039
  onShowDeletedChange={setShowDeleted}
1040
+ showRoutine={showRoutine}
1041
+ onShowRoutineChange={setShowRoutine}
1035
1042
  search={search}
1036
1043
  onSearchChange={setSearch}
1037
1044
  activityFilter={activityFilter}
@@ -75,6 +75,7 @@ function parseState(
75
75
  viewMode,
76
76
  mode: parseTimeMode(params, opts.isRetained, opts.maxRangeDays),
77
77
  showDeleted: params.get('deleted') !== '0',
78
+ showRoutine: params.get('routine') === '1',
78
79
  pinnedOnly: params.get('pinnedOnly') === '1' && opts.hasPins,
79
80
  search: params.get('q') ?? '',
80
81
  activityFilter,
@@ -89,6 +90,7 @@ const defaultState: PersistedTimelineState = {
89
90
  viewMode: DEFAULT_VIEW,
90
91
  mode: live(DEFAULT_LIVE_WIDTH_MS),
91
92
  showDeleted: true,
93
+ showRoutine: false,
92
94
  pinnedOnly: false,
93
95
  search: '',
94
96
  activityFilter: [],
@@ -158,6 +160,7 @@ describe('writeTimelineParams', () => {
158
160
  viewMode: 'list',
159
161
  mode: live(6 * 60 * 60 * 1000),
160
162
  showDeleted: false,
163
+ showRoutine: true,
161
164
  pinnedOnly: true,
162
165
  search: 'nginx',
163
166
  activityFilter: ['warnings', 'unhealthy'],
@@ -175,6 +178,7 @@ describe('writeTimelineParams', () => {
175
178
  ['kinds', 'Pod,Deployment'],
176
179
  ['pinnedOnly', '1'],
177
180
  ['q', 'nginx'],
181
+ ['routine', '1'],
178
182
  ['sort', 'recent'],
179
183
  ['view', 'list'],
180
184
  ['window', '21600000'],
@@ -265,6 +269,7 @@ describe('parse(write(state)) round-trip', () => {
265
269
  viewMode: 'list',
266
270
  mode: live(6 * 60 * 60 * 1000),
267
271
  showDeleted: false,
272
+ showRoutine: true,
268
273
  pinnedOnly: true,
269
274
  search: 'nginx',
270
275
  activityFilter: ['warnings', 'unhealthy'],
@@ -338,6 +343,7 @@ describe('onlyHighFreqDiffer (history replace vs push)', () => {
338
343
  expect(onlyHighFreqDiffer('', 'activity=warnings')).toBe(false)
339
344
  expect(onlyHighFreqDiffer('', 'kinds=Pod')).toBe(false)
340
345
  expect(onlyHighFreqDiffer('', 'deleted=0')).toBe(false)
346
+ expect(onlyHighFreqDiffer('', 'routine=1')).toBe(false)
341
347
  expect(onlyHighFreqDiffer('', 'pinnedOnly=1')).toBe(false)
342
348
  })
343
349
 
@@ -1,50 +1,125 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { Megaphone } from 'lucide-react'
3
- import { nextSeenVersion, whatsNewToShow } from './WhatsNew'
4
- import { latestReleaseNotesFor, releaseLine, RELEASE_NOTES, releaseNotesFor, type ReleaseNotes } from './releaseNotes'
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 entry = (version: string): ReleaseNotes => ({
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
- releaseUrl: 'https://github.com/skyhook-io/radar/releases',
10
- highlights: [{ id: 'x', icon: Megaphone, title: 'X', description: 'Y' }],
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 show = (current: string, lastSeen: string | null, prior = true) =>
17
- whatsNewToShow(current, lastSeen, prior, catalog)?.version ?? null
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('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()
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('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')
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('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()
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('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')
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(show('v2.0.0', 'vdev', false)).toBe('v2.0.0')
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 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()
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 lead highlight`).toBeGreaterThan(0)
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
  })