@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.
- package/package.json +5 -5
- package/src/App.tsx +46 -5
- package/src/api/client.ts +53 -47
- package/src/api/cnpg.ts +95 -0
- package/src/api/timelineSource.ts +1 -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/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/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- 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 +155 -24
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- 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 {
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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('
|
|
26
|
-
it('
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
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('
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
46
|
-
expect(
|
|
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('
|
|
50
|
-
|
|
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('
|
|
54
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
:
|
|
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,
|
|
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
|
})
|