@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import {
|
|
4
|
+
TimelineSwimlanes,
|
|
5
|
+
EventDetailPanel,
|
|
6
|
+
chipKindLabel,
|
|
7
|
+
clampWindowToBounds,
|
|
8
|
+
zoomWindowWithinBounds,
|
|
9
|
+
zoomWindowContinuous,
|
|
10
|
+
wheelZoomFactor,
|
|
11
|
+
clusterEventsByPosition,
|
|
12
|
+
clusterBreakdown,
|
|
13
|
+
clusterDrawerState,
|
|
14
|
+
resolveEventCluster,
|
|
15
|
+
restoreIsPending,
|
|
16
|
+
eventSeverityRank,
|
|
17
|
+
windowInsideGap,
|
|
18
|
+
computeAutoExpandedLanes,
|
|
19
|
+
reconcileAutoExpand,
|
|
20
|
+
mergeLaneOrderById,
|
|
21
|
+
collectLaneIdsDeep,
|
|
22
|
+
AUTO_COLLAPSE_THRESHOLD,
|
|
23
|
+
CLUSTER_MIN_GAP_PCT,
|
|
24
|
+
CLUSTER_MAX_SPAN_FACTOR,
|
|
25
|
+
type PositionedTimelineEvent,
|
|
26
|
+
type TimeWindow,
|
|
27
|
+
} from './TimelineSwimlanes'
|
|
28
|
+
import type { EventType, TimelineEvent } from '../../types/core'
|
|
29
|
+
import type { ResourceLane } from '../../utils/resource-hierarchy'
|
|
30
|
+
import { buildAppMembershipIndex, type AppRow } from '../../utils/applications'
|
|
31
|
+
|
|
32
|
+
const mkEvent = (id: string, eventType: EventType = 'update'): TimelineEvent => ({
|
|
33
|
+
id,
|
|
34
|
+
timestamp: new Date(NOW).toISOString(),
|
|
35
|
+
source: 'informer',
|
|
36
|
+
kind: 'Pod',
|
|
37
|
+
namespace: 'default',
|
|
38
|
+
name: id,
|
|
39
|
+
eventType,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const positioned = (...items: [TimelineEvent, number][]): PositionedTimelineEvent[] =>
|
|
43
|
+
items.map(([event, x]) => ({ event, x }))
|
|
44
|
+
|
|
45
|
+
const HOUR = 60 * 60 * 1000
|
|
46
|
+
const DAY = 24 * HOUR
|
|
47
|
+
const NOW = 1_700_000_000_000
|
|
48
|
+
const BOUNDS: TimeWindow = { fromMs: NOW - 7 * DAY, toMs: NOW }
|
|
49
|
+
|
|
50
|
+
describe('clampWindowToBounds (view window ⊂ bounds)', () => {
|
|
51
|
+
it('leaves a window already inside the bounds untouched', () => {
|
|
52
|
+
const win: TimeWindow = { fromMs: NOW - 3 * HOUR, toMs: NOW - 2 * HOUR }
|
|
53
|
+
expect(clampWindowToBounds(win, BOUNDS)).toEqual(win)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shifts a window past the future edge back inside, preserving width', () => {
|
|
57
|
+
const win: TimeWindow = { fromMs: NOW - HOUR, toMs: NOW + HOUR }
|
|
58
|
+
const width = win.toMs - win.fromMs
|
|
59
|
+
const clamped = clampWindowToBounds(win, BOUNDS)
|
|
60
|
+
expect(clamped.toMs).toBe(BOUNDS.toMs)
|
|
61
|
+
expect(clamped.toMs - clamped.fromMs).toBe(width)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('caps a window wider than the bounds to the full bounds (fully zoomed out)', () => {
|
|
65
|
+
const win: TimeWindow = { fromMs: NOW - 30 * DAY, toMs: NOW }
|
|
66
|
+
expect(clampWindowToBounds(win, BOUNDS)).toEqual({ fromMs: BOUNDS.fromMs, toMs: BOUNDS.toMs })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('zoomWindowWithinBounds (preset stepping, end-anchored)', () => {
|
|
71
|
+
it('zooms in to the next-smaller preset, keeping the END fixed (focus on recent)', () => {
|
|
72
|
+
// 2h window → zoom in → 1h window ending at the same moment.
|
|
73
|
+
const win: TimeWindow = { fromMs: NOW - 3 * HOUR, toMs: NOW - HOUR }
|
|
74
|
+
const next = zoomWindowWithinBounds(win, 'in', BOUNDS)
|
|
75
|
+
expect(next.toMs - next.fromMs).toBe(HOUR)
|
|
76
|
+
expect(next.toMs).toBe(win.toMs)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('zooms out to the next-larger preset, reaching farther back (END fixed)', () => {
|
|
80
|
+
const win: TimeWindow = { fromMs: NOW - 90 * 60 * 1000, toMs: NOW - 30 * 60 * 1000 } // 1h wide
|
|
81
|
+
const next = zoomWindowWithinBounds(win, 'out', BOUNDS)
|
|
82
|
+
expect(next.toMs - next.fromMs).toBe(2 * HOUR)
|
|
83
|
+
expect(next.toMs).toBe(win.toMs)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('caps zoom-out at the bounds width', () => {
|
|
87
|
+
const win: TimeWindow = { fromMs: NOW - 5 * DAY, toMs: NOW } // 5d, near the 7d bounds
|
|
88
|
+
const next = zoomWindowWithinBounds(win, 'out', BOUNDS)
|
|
89
|
+
// Next preset (7d) equals the bounds width → snaps to the full bounds.
|
|
90
|
+
expect(next).toEqual({ fromMs: BOUNDS.fromMs, toMs: BOUNDS.toMs })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('works without bounds (uncapped) for the general case', () => {
|
|
94
|
+
const win: TimeWindow = { fromMs: NOW - 2 * HOUR, toMs: NOW }
|
|
95
|
+
const next = zoomWindowWithinBounds(win, 'in')
|
|
96
|
+
expect(next.toMs - next.fromMs).toBe(HOUR)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('wheelZoomFactor (continuous wheel zoom)', () => {
|
|
101
|
+
it('returns a factor > 1 scrolling down (zoom out) and < 1 up (zoom in)', () => {
|
|
102
|
+
expect(wheelZoomFactor(100)).toBeGreaterThan(1)
|
|
103
|
+
expect(wheelZoomFactor(-100)).toBeLessThan(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is symmetric: a down notch and an up notch cancel out', () => {
|
|
107
|
+
expect(wheelZoomFactor(100) * wheelZoomFactor(-100)).toBeCloseTo(1, 10)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a zero delta is a no-op (factor 1)', () => {
|
|
111
|
+
expect(wheelZoomFactor(0)).toBe(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('clamps a jumpy delta so one event can never leap octaves', () => {
|
|
115
|
+
// A 2000px delta is treated the same as the 100px clamp — a bounded step.
|
|
116
|
+
expect(wheelZoomFactor(2000)).toBe(wheelZoomFactor(100))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('normalizes line-mode deltas so wheel feel is device-independent', () => {
|
|
120
|
+
// deltaMode 1 (lines): a small line delta scales up to the pixel equivalent.
|
|
121
|
+
expect(wheelZoomFactor(6.25, 1)).toBeCloseTo(wheelZoomFactor(100, 0), 10)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('accumulates smoothly for a trackpad pinch (tiny sub-notch deltas)', () => {
|
|
125
|
+
// Ten small pinch events compound toward — but stay gentler than — one notch.
|
|
126
|
+
const pinch = wheelZoomFactor(-4) ** 10
|
|
127
|
+
expect(pinch).toBeLessThan(1)
|
|
128
|
+
expect(pinch).toBeGreaterThan(wheelZoomFactor(-100))
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('zoomWindowContinuous (smooth window scaling, end-anchored)', () => {
|
|
133
|
+
it('scales the window width by the factor, keeping the END fixed', () => {
|
|
134
|
+
const win: TimeWindow = { fromMs: NOW - 2 * HOUR, toMs: NOW - HOUR } // 1h wide
|
|
135
|
+
const next = zoomWindowContinuous(win, 1.5, BOUNDS)
|
|
136
|
+
expect(next.toMs - next.fromMs).toBeCloseTo(1.5 * HOUR, 0)
|
|
137
|
+
expect(next.toMs).toBe(win.toMs)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('lands between preset rungs — the whole point (no ladder snap)', () => {
|
|
141
|
+
const win: TimeWindow = { fromMs: NOW - HOUR, toMs: NOW } // 1h
|
|
142
|
+
const next = zoomWindowContinuous(win, 1.3, BOUNDS)
|
|
143
|
+
const widthHours = (next.toMs - next.fromMs) / HOUR
|
|
144
|
+
expect(widthHours).toBeCloseTo(1.3, 5) // not 1 and not the next rung (2)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('caps zoom-out at the bounds width', () => {
|
|
148
|
+
const win: TimeWindow = { fromMs: NOW - 6 * DAY, toMs: NOW } // 6d, near 7d bounds
|
|
149
|
+
const next = zoomWindowContinuous(win, 4, BOUNDS) // would be 24d
|
|
150
|
+
expect(next).toEqual({ fromMs: BOUNDS.fromMs, toMs: BOUNDS.toMs })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('floors zoom-in at the shared 15-minute window floor', () => {
|
|
154
|
+
const win: TimeWindow = { fromMs: NOW - 30 * 60_000, toMs: NOW } // 30min
|
|
155
|
+
const next = zoomWindowContinuous(win, 0.01, BOUNDS) // would be 18s
|
|
156
|
+
expect(next.toMs - next.fromMs).toBe(15 * 60_000)
|
|
157
|
+
expect(next.toMs).toBe(win.toMs)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('clusterEventsByPosition (collapse near-overlapping markers)', () => {
|
|
162
|
+
it('returns no clusters for an empty input', () => {
|
|
163
|
+
expect(clusterEventsByPosition([], CLUSTER_MIN_GAP_PCT)).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('passes non-overlapping events through as count-1 clusters (position preserved)', () => {
|
|
167
|
+
const input = positioned(
|
|
168
|
+
[mkEvent('a'), 10],
|
|
169
|
+
[mkEvent('b'), 50],
|
|
170
|
+
[mkEvent('c'), 90],
|
|
171
|
+
)
|
|
172
|
+
const out = clusterEventsByPosition(input, CLUSTER_MIN_GAP_PCT)
|
|
173
|
+
expect(out).toHaveLength(3)
|
|
174
|
+
expect(out.map((c) => c.count)).toEqual([1, 1, 1])
|
|
175
|
+
expect(out.map((c) => c.x)).toEqual([10, 50, 90])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('collapses events within min-gap into one cluster at the average position', () => {
|
|
179
|
+
const out = clusterEventsByPosition(
|
|
180
|
+
positioned([mkEvent('a'), 10], [mkEvent('b'), 10.5], [mkEvent('c'), 11]),
|
|
181
|
+
CLUSTER_MIN_GAP_PCT,
|
|
182
|
+
)
|
|
183
|
+
expect(out).toHaveLength(1)
|
|
184
|
+
expect(out[0].count).toBe(3)
|
|
185
|
+
expect(out[0].events.map((e) => e.id)).toEqual(['a', 'b', 'c'])
|
|
186
|
+
expect(out[0].x).toBeCloseTo(10.5)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('picks the most-severe member as the cluster dominant', () => {
|
|
190
|
+
// update (rank 0) + warning (rank 3) → warning dominates regardless of order.
|
|
191
|
+
const out = clusterEventsByPosition(
|
|
192
|
+
positioned([mkEvent('u', 'update'), 20], [mkEvent('w', 'Warning'), 20.3]),
|
|
193
|
+
CLUSTER_MIN_GAP_PCT,
|
|
194
|
+
)
|
|
195
|
+
expect(out).toHaveLength(1)
|
|
196
|
+
expect(out[0].count).toBe(2)
|
|
197
|
+
expect(out[0].dominant.id).toBe('w')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('ranks severity warning > delete > add > update', () => {
|
|
201
|
+
expect(eventSeverityRank(mkEvent('x', 'Warning'))).toBeGreaterThan(eventSeverityRank(mkEvent('x', 'delete')))
|
|
202
|
+
expect(eventSeverityRank(mkEvent('x', 'delete'))).toBeGreaterThan(eventSeverityRank(mkEvent('x', 'add')))
|
|
203
|
+
expect(eventSeverityRank(mkEvent('x', 'add'))).toBeGreaterThan(eventSeverityRank(mkEvent('x', 'update')))
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('clusterBreakdown (pill hover breakdown)', () => {
|
|
208
|
+
it('groups members into count×label lines, most-severe label first', () => {
|
|
209
|
+
const { lines, more } = clusterBreakdown([
|
|
210
|
+
mkEvent('u1', 'update'),
|
|
211
|
+
mkEvent('u2', 'update'),
|
|
212
|
+
mkEvent('a1', 'add'),
|
|
213
|
+
{ ...mkEvent('w1', 'Warning'), reason: 'BackOff' },
|
|
214
|
+
])
|
|
215
|
+
expect(more).toBe(0)
|
|
216
|
+
expect(lines).toEqual([
|
|
217
|
+
{ label: 'BackOff', count: 1 },
|
|
218
|
+
{ label: 'Created', count: 1 },
|
|
219
|
+
{ label: 'Modified', count: 2 },
|
|
220
|
+
])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('merges same-reason warnings into one line with a count', () => {
|
|
224
|
+
const { lines } = clusterBreakdown([
|
|
225
|
+
{ ...mkEvent('w1', 'Warning'), reason: 'BackOff' },
|
|
226
|
+
{ ...mkEvent('w2', 'Warning'), reason: 'BackOff' },
|
|
227
|
+
{ ...mkEvent('w3', 'Warning'), reason: 'Unhealthy' },
|
|
228
|
+
])
|
|
229
|
+
expect(lines).toEqual([
|
|
230
|
+
{ label: 'BackOff', count: 2 },
|
|
231
|
+
{ label: 'Unhealthy', count: 1 },
|
|
232
|
+
])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('caps at maxLines and reports the remaining event total as more', () => {
|
|
236
|
+
const { lines, more } = clusterBreakdown(
|
|
237
|
+
[
|
|
238
|
+
{ ...mkEvent('w1', 'Warning'), reason: 'BackOff' },
|
|
239
|
+
{ ...mkEvent('w2', 'Warning'), reason: 'Unhealthy' },
|
|
240
|
+
{ ...mkEvent('w3', 'Warning'), reason: 'Failed' },
|
|
241
|
+
mkEvent('d1', 'delete'),
|
|
242
|
+
mkEvent('a1', 'add'),
|
|
243
|
+
mkEvent('u1', 'update'),
|
|
244
|
+
],
|
|
245
|
+
4,
|
|
246
|
+
)
|
|
247
|
+
expect(lines).toHaveLength(4)
|
|
248
|
+
expect(lines[3]).toEqual({ label: 'Deleted', count: 1 })
|
|
249
|
+
expect(more).toBe(2)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('labels a reason-less warning as Warning', () => {
|
|
253
|
+
const { lines } = clusterBreakdown([mkEvent('w1', 'Warning')])
|
|
254
|
+
expect(lines).toEqual([{ label: 'Warning', count: 1 }])
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('chipKindLabel — the real Kind, verbatim', () => {
|
|
259
|
+
it('returns the full Kind unchanged — no abbreviation, ALL-CAPS, or truncation', () => {
|
|
260
|
+
expect(chipKindLabel('Deployment')).toEqual({ label: 'Deployment', abbreviated: false })
|
|
261
|
+
expect(chipKindLabel('PodDisruptionBudget')).toEqual({ label: 'PodDisruptionBudget', abbreviated: false })
|
|
262
|
+
expect(chipKindLabel('HorizontalPodAutoscaler')).toEqual({ label: 'HorizontalPodAutoscaler', abbreviated: false })
|
|
263
|
+
expect(chipKindLabel('VerticalPodAutoscalerCheckpoint')).toEqual({ label: 'VerticalPodAutoscalerCheckpoint', abbreviated: false })
|
|
264
|
+
expect(chipKindLabel('ClusterSecretStore')).toEqual({ label: 'ClusterSecretStore', abbreviated: false })
|
|
265
|
+
})
|
|
266
|
+
it('labels kind-less events as Event', () => {
|
|
267
|
+
expect(chipKindLabel('')).toEqual({ label: 'Event', abbreviated: false })
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('bulk expand/collapse toggle (single morphing control)', () => {
|
|
272
|
+
it('renders ONE state-aware toggle in the resource header, tooltip naming action + shortcut', () => {
|
|
273
|
+
const ev = (name: string): TimelineEvent => ({
|
|
274
|
+
id: name, timestamp: new Date().toISOString(), source: 'informer',
|
|
275
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
276
|
+
})
|
|
277
|
+
// Flat lanes (no groups) → nothing expanded → the toggle offers Expand.
|
|
278
|
+
const html = renderToString(<TimelineSwimlanes events={[ev('web')]} />)
|
|
279
|
+
expect(html).toContain('aria-label="Expand all resources"')
|
|
280
|
+
expect(html).not.toContain('aria-label="Collapse all resources"')
|
|
281
|
+
// The "(E)" shortcut hint rides the custom Tooltip (not SSR'd) — the
|
|
282
|
+
// aria-labels above are the state-aware affordance.
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('compact mode (embedded detail-view swimlane)', () => {
|
|
287
|
+
const ev = (name: string): TimelineEvent => ({
|
|
288
|
+
id: name, timestamp: new Date().toISOString(), source: 'informer',
|
|
289
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('hides the bulk expand/collapse toggle — flat lanes never nest', () => {
|
|
293
|
+
const full = renderToString(<TimelineSwimlanes events={[ev('web')]} />)
|
|
294
|
+
expect(full).toContain('aria-label="Expand all resources"')
|
|
295
|
+
const compact = renderToString(<TimelineSwimlanes events={[ev('web')]} grouping="flat" compact />)
|
|
296
|
+
expect(compact).not.toContain('aria-label="Expand all resources"')
|
|
297
|
+
expect(compact).not.toContain('aria-label="Collapse all resources"')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('widens the label column (no namespace subtitle or tree competing for it)', () => {
|
|
301
|
+
const full = renderToString(<TimelineSwimlanes events={[ev('web')]} />)
|
|
302
|
+
expect(full).toContain('w-[360px]')
|
|
303
|
+
const compact = renderToString(<TimelineSwimlanes events={[ev('web')]} grouping="flat" compact />)
|
|
304
|
+
expect(compact).toContain('w-[440px]')
|
|
305
|
+
expect(compact).not.toContain('w-[360px]')
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('EventDetailPanel cluster mode (a ×N pill exposes every member)', () => {
|
|
310
|
+
// A mixed cluster: one warning dominates the pill glyph; three benign updates
|
|
311
|
+
// hide behind it. The founder's bug was that only one of these ever showed.
|
|
312
|
+
const T0 = 1_700_000_000_000
|
|
313
|
+
const mk = (id: string, eventType: EventType, atMs: number, opts: Partial<TimelineEvent> = {}): TimelineEvent => ({
|
|
314
|
+
id, timestamp: new Date(atMs).toISOString(),
|
|
315
|
+
source: eventType === 'Warning' ? 'k8s_event' : 'informer',
|
|
316
|
+
kind: 'Pod', namespace: 'default', name: 'web', eventType, ...opts,
|
|
317
|
+
})
|
|
318
|
+
const clusterEvents: TimelineEvent[] = [
|
|
319
|
+
mk('add-web', 'add', T0),
|
|
320
|
+
mk('upd-web', 'update', T0 + 30_000),
|
|
321
|
+
mk('warn-web', 'Warning', T0 + 60_000, { reason: 'BackOff', message: 'Back-off restarting failed container' }),
|
|
322
|
+
mk('del-web', 'delete', T0 + 90_000),
|
|
323
|
+
]
|
|
324
|
+
// Ordered most-severe-first, as the drawer opens it (Warning → delete → add → update).
|
|
325
|
+
const ordered = [...clusterEvents].sort((a, b) => eventSeverityRank(b) - eventSeverityRank(a))
|
|
326
|
+
|
|
327
|
+
it('renders a row for every clustered event plus the summary header', () => {
|
|
328
|
+
const html = renderToString(
|
|
329
|
+
<EventDetailPanel events={ordered} selectedId={ordered[0].id} onSelectId={() => {}} onClose={() => {}} />,
|
|
330
|
+
)
|
|
331
|
+
// Honest summary: the "N events" badge + a member list with all four rows.
|
|
332
|
+
expect(html).toContain('events</span>')
|
|
333
|
+
expect(html).toContain('aria-label="Drawer events"')
|
|
334
|
+
expect((html.match(/<li>/g) ?? []).length).toBe(4)
|
|
335
|
+
expect(html).toContain('BackOff')
|
|
336
|
+
expect(html).toContain('>add<')
|
|
337
|
+
expect(html).toContain('>update<')
|
|
338
|
+
expect(html).toContain('>delete<')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('preselects the most-severe member (the Warning the pill glyph promised)', () => {
|
|
342
|
+
// ordered[0] is the Warning; its message renders in the detail pane by default.
|
|
343
|
+
expect(ordered[0].id).toBe('warn-web')
|
|
344
|
+
const html = renderToString(
|
|
345
|
+
<EventDetailPanel events={ordered} selectedId={ordered[0].id} onSelectId={() => {}} onClose={() => {}} />,
|
|
346
|
+
)
|
|
347
|
+
expect(html).toContain('Back-off restarting failed container')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('shows the detail of whichever member is selected (list click swaps it)', () => {
|
|
351
|
+
// Selecting a benign member swaps the detail pane away from the warning.
|
|
352
|
+
const html = renderToString(
|
|
353
|
+
<EventDetailPanel events={ordered} selectedId="upd-web" onSelectId={() => {}} onClose={() => {}} />,
|
|
354
|
+
)
|
|
355
|
+
// With a benign member selected, its detail shows instead of the warning body.
|
|
356
|
+
expect(html).not.toContain('Back-off restarting failed container')
|
|
357
|
+
// Still lists all four members.
|
|
358
|
+
expect((html.match(/<li>/g) ?? []).length).toBe(4)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('N=1 renders the SAME rail+detail anatomy — a rail of one, selected', () => {
|
|
362
|
+
const single = mk('warn-web', 'Warning', T0, { reason: 'BackOff', message: 'Back-off restarting failed container' })
|
|
363
|
+
const html = renderToString(
|
|
364
|
+
<EventDetailPanel events={[single]} selectedId={single.id} onSelectId={() => {}} onClose={() => {}} />,
|
|
365
|
+
)
|
|
366
|
+
// One anatomy: the rail always renders — a single event is a rail of one.
|
|
367
|
+
expect(html).toContain('Back-off restarting failed container')
|
|
368
|
+
expect(html).toContain('aria-label="Drawer events"')
|
|
369
|
+
expect((html.match(/<li/g) ?? []).length).toBe(1)
|
|
370
|
+
expect(html).toContain('1 of 1') // header stepper reflects the rail of one
|
|
371
|
+
expect(html).not.toContain(' events</span>') // no misleading "1 events" badge
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('N=1 with allEvents grows the rail with ±15-min correlated neighbors', () => {
|
|
375
|
+
const single = mk('warn-web', 'Warning', T0, { reason: 'BackOff', message: 'Back-off restarting failed container' })
|
|
376
|
+
const neighbor = mk('upd-api', 'update', T0 + 2 * 60_000, { name: 'api' })
|
|
377
|
+
const far = mk('upd-db', 'update', T0 + 60 * 60_000, { name: 'db' }) // outside ±15m
|
|
378
|
+
const html = renderToString(
|
|
379
|
+
<EventDetailPanel events={[single]} selectedId={single.id} onSelectId={() => {}} onClose={() => {}} allEvents={[single, neighbor, far]} />,
|
|
380
|
+
)
|
|
381
|
+
expect(html).toContain('Within ±15 min')
|
|
382
|
+
expect(html).toContain('>api<') // the neighbor joined the rail
|
|
383
|
+
expect(html).not.toContain('>db<') // out-of-window event did not
|
|
384
|
+
expect(html).toContain('1 of 2')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('toolbar lens controls (controlled vs uncontrolled)', () => {
|
|
389
|
+
it('shows zoom buttons and the window label in uncontrolled mode', () => {
|
|
390
|
+
const html = renderToString(<TimelineSwimlanes events={[]} />)
|
|
391
|
+
expect(html).toContain('aria-label="Zoom in"')
|
|
392
|
+
expect(html).toContain('aria-label="Zoom out"')
|
|
393
|
+
expect(html).toContain(' window')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('hides zoom buttons and the window label when controlled (viewWindow set)', () => {
|
|
397
|
+
const html = renderToString(
|
|
398
|
+
<TimelineSwimlanes
|
|
399
|
+
events={[]}
|
|
400
|
+
viewWindow={{ fromMs: NOW - HOUR, toMs: NOW }}
|
|
401
|
+
bounds={{ fromMs: NOW - 24 * HOUR, toMs: NOW }}
|
|
402
|
+
onViewWindowChange={() => {}}
|
|
403
|
+
/>,
|
|
404
|
+
)
|
|
405
|
+
expect(html).not.toContain('aria-label="Zoom in"')
|
|
406
|
+
expect(html).not.toContain('aria-label="Zoom out"')
|
|
407
|
+
expect(html).not.toContain(' window')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('hides the strip "→ Now" button when controlled — the scrubber chip owns the path back to now', () => {
|
|
411
|
+
// Even with the window a full hour behind the latest edge, controlled mode no
|
|
412
|
+
// longer renders its own jump button; the scrubber's live/paused chip covers it.
|
|
413
|
+
const html = renderToString(
|
|
414
|
+
<TimelineSwimlanes
|
|
415
|
+
events={[]}
|
|
416
|
+
viewWindow={{ fromMs: NOW - 2 * HOUR, toMs: NOW - HOUR }}
|
|
417
|
+
bounds={{ fromMs: NOW - 24 * HOUR, toMs: NOW }}
|
|
418
|
+
onViewWindowChange={() => {}}
|
|
419
|
+
/>,
|
|
420
|
+
)
|
|
421
|
+
expect(html).not.toContain('Jump to current time')
|
|
422
|
+
expect(html).not.toContain('→ Now')
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
describe('windowInsideGap (view fully inside a recording gap)', () => {
|
|
427
|
+
const GAPS: TimeWindow[] = [{ fromMs: NOW - 12 * HOUR, toMs: NOW - 6 * HOUR }]
|
|
428
|
+
|
|
429
|
+
it('is true when the window sits entirely inside a gap', () => {
|
|
430
|
+
const win: TimeWindow = { fromMs: NOW - 10 * HOUR, toMs: NOW - 8 * HOUR }
|
|
431
|
+
expect(windowInsideGap(win, GAPS)).toBe(true)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('is true at the exact gap edges (inclusive)', () => {
|
|
435
|
+
const win: TimeWindow = { fromMs: NOW - 12 * HOUR, toMs: NOW - 6 * HOUR }
|
|
436
|
+
expect(windowInsideGap(win, GAPS)).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('is false when the window only partially overlaps a gap', () => {
|
|
440
|
+
const win: TimeWindow = { fromMs: NOW - 8 * HOUR, toMs: NOW - 4 * HOUR }
|
|
441
|
+
expect(windowInsideGap(win, GAPS)).toBe(false)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('is false when the window is fully outside every gap', () => {
|
|
445
|
+
const win: TimeWindow = { fromMs: NOW - 3 * HOUR, toMs: NOW - HOUR }
|
|
446
|
+
expect(windowInsideGap(win, GAPS)).toBe(false)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('is false with no gaps or no window', () => {
|
|
450
|
+
expect(windowInsideGap({ fromMs: NOW - HOUR, toMs: NOW }, [])).toBe(false)
|
|
451
|
+
expect(windowInsideGap(undefined, GAPS)).toBe(false)
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
describe('app-group header lane (grouping=app + appIndex)', () => {
|
|
456
|
+
it('renders an app header with the app name and member count', () => {
|
|
457
|
+
const now = Date.now()
|
|
458
|
+
const ev = (name: string): TimelineEvent => ({
|
|
459
|
+
id: name, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
460
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
461
|
+
})
|
|
462
|
+
const rows: AppRow[] = [{
|
|
463
|
+
key: 'team-a/app/billing', name: 'billing', health: 'healthy',
|
|
464
|
+
identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'app.kubernetes.io/instance' },
|
|
465
|
+
workloads: [
|
|
466
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-api', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
467
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-worker', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
468
|
+
],
|
|
469
|
+
}]
|
|
470
|
+
const html = renderToString(
|
|
471
|
+
<TimelineSwimlanes events={[ev('billing-api'), ev('billing-worker')]} grouping="app" appIndex={buildAppMembershipIndex(rows)} />,
|
|
472
|
+
)
|
|
473
|
+
expect(html).toContain('billing')
|
|
474
|
+
expect(html).toContain('+2')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('keeps a server-declared member visible even when its events are outside the window', () => {
|
|
478
|
+
const now = 1_700_000_000_000
|
|
479
|
+
const ev = (name: string, iso: string): TimelineEvent => ({
|
|
480
|
+
id: name, timestamp: iso, source: 'informer',
|
|
481
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
482
|
+
})
|
|
483
|
+
const rows: AppRow[] = [{
|
|
484
|
+
key: 'team-a/app/billing', name: 'billing', health: 'healthy',
|
|
485
|
+
identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'app.kubernetes.io/instance' },
|
|
486
|
+
workloads: [
|
|
487
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-api', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
488
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-worker', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
489
|
+
],
|
|
490
|
+
}]
|
|
491
|
+
const html = renderToString(
|
|
492
|
+
<TimelineSwimlanes
|
|
493
|
+
events={[ev('billing-api', new Date(now).toISOString()), ev('billing-worker', new Date(now - 10 * 24 * 3600_000).toISOString())]}
|
|
494
|
+
grouping="app"
|
|
495
|
+
appIndex={buildAppMembershipIndex(rows)}
|
|
496
|
+
viewWindow={{ fromMs: now - 3600_000, toMs: now + 3600_000 }}
|
|
497
|
+
/>,
|
|
498
|
+
)
|
|
499
|
+
// Both members are STRUCTURAL (the server's app identity declares them), so
|
|
500
|
+
// the out-of-window one stays — hiding the app's own workloads/Service made
|
|
501
|
+
// a matched app read as incomplete. The window noise filter still applies
|
|
502
|
+
// to evidence/name-matched lanes (covered in resource-hierarchy tests).
|
|
503
|
+
expect(html).toContain('billing-api')
|
|
504
|
+
expect(html).toContain('billing-worker')
|
|
505
|
+
expect(html).toContain('+2')
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
describe('pinned resource lanes', () => {
|
|
510
|
+
it('renders the PINNED caption and a pinned row even with zero events in the selection', () => {
|
|
511
|
+
// No events at all for the pinned resource — the row is synthesized from the
|
|
512
|
+
// pin record so the eye keeps its target (empty track = "nothing happened").
|
|
513
|
+
const html = renderToString(
|
|
514
|
+
<TimelineSwimlanes
|
|
515
|
+
events={[]}
|
|
516
|
+
pinnedLanes={[{ id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' }]}
|
|
517
|
+
onTogglePin={() => {}}
|
|
518
|
+
/>,
|
|
519
|
+
)
|
|
520
|
+
expect(html).toContain('Pinned')
|
|
521
|
+
expect(html).toContain('web')
|
|
522
|
+
expect(html).toContain('team-a')
|
|
523
|
+
// Pinned row's toggle is the filled Unpin affordance.
|
|
524
|
+
expect(html).toContain('aria-label="Unpin"')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('renders a pinned row above the sorted lanes when the resource has events', () => {
|
|
528
|
+
const now = Date.now()
|
|
529
|
+
const ev = (name: string): TimelineEvent => ({
|
|
530
|
+
id: name, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
531
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
532
|
+
})
|
|
533
|
+
const html = renderToString(
|
|
534
|
+
<TimelineSwimlanes
|
|
535
|
+
events={[ev('web'), ev('api')]}
|
|
536
|
+
pinnedLanes={[{ id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' }]}
|
|
537
|
+
onTogglePin={() => {}}
|
|
538
|
+
/>,
|
|
539
|
+
)
|
|
540
|
+
const pinnedIdx = html.indexOf('Pinned')
|
|
541
|
+
const webIdx = html.indexOf('web')
|
|
542
|
+
expect(pinnedIdx).toBeGreaterThanOrEqual(0)
|
|
543
|
+
// The pinned caption precedes the (duplicated) pinned row.
|
|
544
|
+
expect(webIdx).toBeGreaterThan(pinnedIdx)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('renders a pinned APP-GROUP header (live members) with the filled Unpin affordance', () => {
|
|
548
|
+
const now = Date.now()
|
|
549
|
+
const ev = (name: string): TimelineEvent => ({
|
|
550
|
+
id: name, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
551
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
552
|
+
})
|
|
553
|
+
const rows: AppRow[] = [{
|
|
554
|
+
key: 'team-a/app/billing', name: 'billing', health: 'healthy',
|
|
555
|
+
identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'app.kubernetes.io/instance' },
|
|
556
|
+
workloads: [
|
|
557
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-api', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
558
|
+
{ kind: 'Deployment', namespace: 'team-a', name: 'billing-worker', health: 'healthy', ready: 1, desired: 1, restarts: 0 },
|
|
559
|
+
],
|
|
560
|
+
}]
|
|
561
|
+
const html = renderToString(
|
|
562
|
+
<TimelineSwimlanes
|
|
563
|
+
events={[ev('billing-api'), ev('billing-worker')]}
|
|
564
|
+
grouping="app"
|
|
565
|
+
appIndex={buildAppMembershipIndex(rows)}
|
|
566
|
+
pinnedLanes={[{ type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' }]}
|
|
567
|
+
onTogglePin={() => {}}
|
|
568
|
+
/>,
|
|
569
|
+
)
|
|
570
|
+
expect(html).toContain('Pinned')
|
|
571
|
+
expect(html).toContain('billing')
|
|
572
|
+
// The pinned group's toggle shows the app-specific filled Unpin affordance.
|
|
573
|
+
expect(html).toContain('aria-label="Unpin app"')
|
|
574
|
+
expect(html).toContain('fill-current')
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('synthesizes a quiet pinned header when the pinned app is absent (owner grouping)', () => {
|
|
578
|
+
const now = Date.now()
|
|
579
|
+
const ev = (name: string): TimelineEvent => ({
|
|
580
|
+
id: name, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
581
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
582
|
+
})
|
|
583
|
+
const html = renderToString(
|
|
584
|
+
<TimelineSwimlanes
|
|
585
|
+
events={[ev('unrelated')]}
|
|
586
|
+
grouping="owner"
|
|
587
|
+
pinnedLanes={[{ type: 'appGroup', id: 'app:team-a/app/gone', appKey: 'team-a/app/gone', appName: 'gone' }]}
|
|
588
|
+
onTogglePin={() => {}}
|
|
589
|
+
/>,
|
|
590
|
+
)
|
|
591
|
+
expect(html).toContain('gone')
|
|
592
|
+
expect(html).toContain('app not present in the current view/grouping')
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe('app-group name → Applications link', () => {
|
|
597
|
+
const now = Date.now()
|
|
598
|
+
const ev = (kind: string, name: string): TimelineEvent => ({
|
|
599
|
+
id: `${kind}/${name}`, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
600
|
+
kind, namespace: 'team-a', name, eventType: 'update',
|
|
601
|
+
})
|
|
602
|
+
const index = {
|
|
603
|
+
byResource: new Map([
|
|
604
|
+
['Deployment/team-a/billing-api', { appKey: 'billing', appName: 'billing' }],
|
|
605
|
+
['Service/team-a/billing-api', { appKey: 'billing', appName: 'billing' }],
|
|
606
|
+
]),
|
|
607
|
+
byEvidence: new Map(),
|
|
608
|
+
} as any
|
|
609
|
+
|
|
610
|
+
it('renders the Open-in-Applications affordance for a real app group', () => {
|
|
611
|
+
const html = renderToString(
|
|
612
|
+
<TimelineSwimlanes
|
|
613
|
+
events={[ev('Deployment', 'billing-api'), ev('Service', 'billing-api')]}
|
|
614
|
+
grouping="app" appIndex={index} onAppClick={() => {}}
|
|
615
|
+
/>,
|
|
616
|
+
)
|
|
617
|
+
expect(html).toContain('Open in Applications')
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('renders no link without an onAppClick handler', () => {
|
|
621
|
+
const html = renderToString(
|
|
622
|
+
<TimelineSwimlanes
|
|
623
|
+
events={[ev('Deployment', 'billing-api'), ev('Service', 'billing-api')]}
|
|
624
|
+
grouping="app" appIndex={index}
|
|
625
|
+
/>,
|
|
626
|
+
)
|
|
627
|
+
expect(html).not.toContain('Open in Applications')
|
|
628
|
+
})
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
describe('resolveEventCluster (restore a persisted event id to its drawer state)', () => {
|
|
632
|
+
// A restored id opens the drawer in whatever mode the CURRENT render gives it:
|
|
633
|
+
// inside a ×N pill → cluster mode with that pill's members; standalone → single.
|
|
634
|
+
// Same window/filters → same clustering, so resolution is deterministic.
|
|
635
|
+
const WIN_START = NOW - HOUR
|
|
636
|
+
// Linear map of the 1h window onto 0-100%: start→0, end→100.
|
|
637
|
+
const timeToX = (t: number): number => ((t - WIN_START) / HOUR) * 100
|
|
638
|
+
const at = (id: string, tMs: number, eventType: EventType = 'update'): TimelineEvent => ({
|
|
639
|
+
id, timestamp: new Date(tMs).toISOString(), source: 'informer', kind: 'Pod', namespace: 'default', name: id, eventType,
|
|
640
|
+
})
|
|
641
|
+
const lane = (over: Partial<ResourceLane> & Pick<ResourceLane, 'id' | 'name' | 'events'>): ResourceLane => ({
|
|
642
|
+
kind: 'Pod', namespace: 'default', isWorkload: false, ...over,
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Two events ~10s apart at the window midpoint cluster together (< 1.4% gap);
|
|
646
|
+
// a third 20min later stands alone.
|
|
647
|
+
const near1 = at('near1', NOW - 30 * 60 * 1000)
|
|
648
|
+
const near2 = at('near2', NOW - 30 * 60 * 1000 + 10_000)
|
|
649
|
+
const solo = at('solo', NOW - 10 * 60 * 1000)
|
|
650
|
+
const flat = lane({ id: 'Pod/default/flat', name: 'flat', events: [near1, near2, solo] })
|
|
651
|
+
|
|
652
|
+
it('resolves an id inside a ×N pill to cluster mode carrying every member, that id selected', () => {
|
|
653
|
+
const out = resolveEventCluster([flat], new Set(), 'near2', timeToX)
|
|
654
|
+
expect(out).not.toBeNull()
|
|
655
|
+
expect(out!.events.map((e) => e.id).sort()).toEqual(['near1', 'near2'])
|
|
656
|
+
expect(out!.selectedId).toBe('near2')
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('resolves a standalone id to a single-event drawer', () => {
|
|
660
|
+
const out = resolveEventCluster([flat], new Set(), 'solo', timeToX)
|
|
661
|
+
expect(out).toEqual({ events: [solo], selectedId: 'solo' })
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('returns null for an id on no rendered row (pruned / filtered out)', () => {
|
|
665
|
+
expect(resolveEventCluster([flat], new Set(), 'ghost', timeToX)).toBeNull()
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('returns null for an id whose marker sits outside the view window', () => {
|
|
669
|
+
// Positioned before the window start → x < 0 → dropped like the markers do.
|
|
670
|
+
const past = lane({ id: 'Pod/default/past', name: 'past', events: [at('past', WIN_START - 5 * 60 * 1000)] })
|
|
671
|
+
expect(resolveEventCluster([past], new Set(), 'past', timeToX)).toBeNull()
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('respects expansion state: a collapsed parent rolls its child event into its own pill', () => {
|
|
675
|
+
const own = at('own', NOW - 30 * 60 * 1000)
|
|
676
|
+
const childEv = at('childEv', NOW - 30 * 60 * 1000 + 10_000)
|
|
677
|
+
const child = lane({ id: 'Pod/default/child', name: 'child', events: [childEv] })
|
|
678
|
+
const parent = lane({
|
|
679
|
+
id: 'Deployment/default/parent', kind: 'Deployment', name: 'parent',
|
|
680
|
+
events: [own], children: [child], allEventsSorted: [own, childEv],
|
|
681
|
+
})
|
|
682
|
+
// Collapsed: the child event paints on the parent's roll-up, clustered with
|
|
683
|
+
// the parent's own event → cluster mode.
|
|
684
|
+
const collapsed = resolveEventCluster([parent], new Set(), 'childEv', timeToX)
|
|
685
|
+
expect(collapsed!.events.map((e) => e.id).sort()).toEqual(['childEv', 'own'])
|
|
686
|
+
expect(collapsed!.selectedId).toBe('childEv')
|
|
687
|
+
// Expanded: the parent row paints only its own event; the child event resolves
|
|
688
|
+
// on the child's own (uncluttered) row → single-event drawer.
|
|
689
|
+
const expanded = resolveEventCluster([parent], new Set(['Deployment/default/parent']), 'childEv', timeToX)
|
|
690
|
+
expect(expanded).toEqual({ events: [childEv], selectedId: 'childEv' })
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('searches pinned rows before visible lanes, in render order', () => {
|
|
694
|
+
const pinned = lane({ id: 'Pod/default/pinned', name: 'pinned', events: [at('pinnedEv', NOW - 20 * 60 * 1000)] })
|
|
695
|
+
const out = resolveEventCluster([pinned, flat], new Set(), 'pinnedEv', timeToX)
|
|
696
|
+
expect(out).toEqual({ events: [at('pinnedEv', NOW - 20 * 60 * 1000)], selectedId: 'pinnedEv' })
|
|
697
|
+
})
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
describe('restoreIsPending (URL ↔ drawer reconciliation guard)', () => {
|
|
701
|
+
// [selectedEventId, drawerSelectedId, settledId, expectedPending, why]
|
|
702
|
+
// dismissedId defaults to null (3-arg call) — the pre-dismissal behavior.
|
|
703
|
+
const cases: [string | null, string | null, string | null, boolean, string][] = [
|
|
704
|
+
[null, null, null, false, 'no id to restore'],
|
|
705
|
+
['X', null, null, true, 'fresh id, drawer closed, never settled — restore must win over strip'],
|
|
706
|
+
['X', null, 'X', false, 'already resolved/ruled-out — report is free to sync the URL'],
|
|
707
|
+
['X', 'X', 'X', false, 'drawer already shows it'],
|
|
708
|
+
['Y', 'X', null, false, 'open drawer (showing X) is authoritative even for a different id'],
|
|
709
|
+
['Y', null, 'X', true, 'a NEW fresh id (Y) is pending even though a prior id (X) settled'],
|
|
710
|
+
]
|
|
711
|
+
it.each(cases)('selected=%s drawer=%s settled=%s → pending=%s (%s)', (sel, drawer, settled, want) => {
|
|
712
|
+
expect(restoreIsPending(sel, drawer, settled)).toBe(want)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
// [selectedEventId, drawerSelectedId, settledId, dismissedId, expectedPending, why]
|
|
716
|
+
const dismissedCases: [string | null, string | null, string | null, string | null, boolean, string][] = [
|
|
717
|
+
['X', null, null, 'X', false, 'user just dismissed X — the not-yet-stripped ?event=X must NOT read as a pending restore (no reopen; report free to strip)'],
|
|
718
|
+
['X', null, null, 'Y', true, 'a different id (Y) was dismissed — X is still a genuine pending restore'],
|
|
719
|
+
['X', 'X', null, 'X', false, 'open drawer already shows X (dismissed is moot while open)'],
|
|
720
|
+
['X', null, 'X', 'X', false, 'settled AND dismissed — still not pending'],
|
|
721
|
+
]
|
|
722
|
+
it.each(dismissedCases)(
|
|
723
|
+
'selected=%s drawer=%s settled=%s dismissed=%s → pending=%s (%s)',
|
|
724
|
+
(sel, drawer, settled, dismissed, want) => {
|
|
725
|
+
expect(restoreIsPending(sel, drawer, settled, dismissed)).toBe(want)
|
|
726
|
+
},
|
|
727
|
+
)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// The restore effect and the report effect share ONE decision: attempt a restore
|
|
731
|
+
// (open) exactly when restoreIsPending is true; report/strip the URL exactly when
|
|
732
|
+
// it's false. The dismissed-id ref set on a user close (and cleared when the
|
|
733
|
+
// selection settles to null) is what lets a user close win over the ?event= echo
|
|
734
|
+
// without losing deep-link / history-nav restore. This models the two ref rules
|
|
735
|
+
// the component applies (set-on-close, clear-on-null) around that shared predicate.
|
|
736
|
+
describe('drawer close vs echo state machine (dismissed-id)', () => {
|
|
737
|
+
// Faithful mirror of the component's two dismissed-id ref rules:
|
|
738
|
+
// - user close records the open id
|
|
739
|
+
// - selection settling to null clears it
|
|
740
|
+
const onClose = (openId: string): string => openId
|
|
741
|
+
const onSelectionSettledNull = (): string | null => null
|
|
742
|
+
|
|
743
|
+
it('open → close → echo(same id) stays closed across the not-yet-stripped ?event=', () => {
|
|
744
|
+
// Drawer open on D, URL ?event=D. User closes: dismissed := D, drawer closes,
|
|
745
|
+
// but selectedEventId is still D (the strip hasn't landed).
|
|
746
|
+
let dismissed: string | null = onClose('D')
|
|
747
|
+
// Restore effect must NOT reopen; report effect IS free to strip → both driven
|
|
748
|
+
// by restoreIsPending === false.
|
|
749
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(false)
|
|
750
|
+
// A live poll re-renders mid-strip with selectedEventId still echoing D — still
|
|
751
|
+
// suppressed (the echo can't ride the poll into a reopen).
|
|
752
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(false)
|
|
753
|
+
// Strip lands: selection settles to null → dismissed clears.
|
|
754
|
+
dismissed = onSelectionSettledNull()
|
|
755
|
+
expect(dismissed).toBeNull()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('close then genuinely re-select the SAME id → restore attempts (opens)', () => {
|
|
759
|
+
let dismissed: string | null = onClose('D')
|
|
760
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(false) // close wins
|
|
761
|
+
dismissed = onSelectionSettledNull() // strip settled → cleared
|
|
762
|
+
// User selects D again: fresh id, drawer closed, nothing dismissed → restore.
|
|
763
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(true)
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('back/forward to the SAME id after close → restore attempts (history is not an echo)', () => {
|
|
767
|
+
let dismissed: string | null = onClose('D')
|
|
768
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(false)
|
|
769
|
+
dismissed = onSelectionSettledNull() // the URL passed through clean, clearing dismissed
|
|
770
|
+
// History nav re-drives ?event=D → the cleared dismissal lets it restore.
|
|
771
|
+
expect(restoreIsPending('D', null, null, dismissed)).toBe(true)
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it('ruled-out-id retry (A2) is unaffected: a settled miss stays non-pending regardless of dismissal', () => {
|
|
775
|
+
// settledId === want (ruled out) keeps it non-pending; dismissal is orthogonal.
|
|
776
|
+
expect(restoreIsPending('D', null, 'D', null)).toBe(false)
|
|
777
|
+
expect(restoreIsPending('D', null, 'D', 'D')).toBe(false)
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
describe('clusterDrawerState (drawer payload a cluster opens into)', () => {
|
|
782
|
+
const mk = (id: string, type: EventType = 'update'): TimelineEvent => mkEvent(id, type)
|
|
783
|
+
|
|
784
|
+
it('opens a count-1 cluster as a single-event drawer', () => {
|
|
785
|
+
const only = mk('only')
|
|
786
|
+
expect(clusterDrawerState({ x: 5, events: [only], dominant: only, count: 1 }))
|
|
787
|
+
.toEqual({ events: [only], selectedId: 'only' })
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('orders a multi-event cluster most-severe-first and defaults to the dominant', () => {
|
|
791
|
+
const upd = mk('upd', 'update')
|
|
792
|
+
const warn = mk('warn', 'Warning')
|
|
793
|
+
const out = clusterDrawerState({ x: 5, events: [upd, warn], dominant: warn, count: 2 })
|
|
794
|
+
expect(out.events.map((e) => e.id)).toEqual(['warn', 'upd'])
|
|
795
|
+
expect(out.selectedId).toBe('warn')
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('prefers a valid preferId over the dominant', () => {
|
|
799
|
+
const upd = mk('upd', 'update')
|
|
800
|
+
const warn = mk('warn', 'Warning')
|
|
801
|
+
expect(clusterDrawerState({ x: 5, events: [upd, warn], dominant: warn, count: 2 }, 'upd').selectedId).toBe('upd')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('ignores a preferId absent from the cluster, falling back to the dominant', () => {
|
|
805
|
+
const upd = mk('upd', 'update')
|
|
806
|
+
const warn = mk('warn', 'Warning')
|
|
807
|
+
expect(clusterDrawerState({ x: 5, events: [upd, warn], dominant: warn, count: 2 }, 'ghost').selectedId).toBe('warn')
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
describe('empty state is pin-aware', () => {
|
|
812
|
+
const now = Date.now()
|
|
813
|
+
const ev = (name: string): TimelineEvent => ({
|
|
814
|
+
id: name, timestamp: new Date(now).toISOString(), source: 'informer',
|
|
815
|
+
kind: 'Deployment', namespace: 'team-a', name, eventType: 'update',
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('renders NO empty state when pinned rows carry the window content (list just ends)', () => {
|
|
819
|
+
const html = renderToString(
|
|
820
|
+
<TimelineSwimlanes
|
|
821
|
+
events={[ev('web')]}
|
|
822
|
+
pinnedLanes={[{ id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' }]}
|
|
823
|
+
onTogglePin={() => {}}
|
|
824
|
+
/>,
|
|
825
|
+
)
|
|
826
|
+
expect(html).not.toContain('No events in this window')
|
|
827
|
+
expect(html).not.toContain('No matching events')
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('keeps the full empty state when pinned rows are empty in-window too (whole canvas blank)', () => {
|
|
831
|
+
const html = renderToString(
|
|
832
|
+
<TimelineSwimlanes
|
|
833
|
+
events={[]}
|
|
834
|
+
pinnedLanes={[{ id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' }]}
|
|
835
|
+
onTogglePin={() => {}}
|
|
836
|
+
/>,
|
|
837
|
+
)
|
|
838
|
+
expect(html).toContain('aria-label="Unpin"')
|
|
839
|
+
})
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// --- auto-collapse by child count (item 2) ----------------------------------
|
|
843
|
+
describe('computeAutoExpandedLanes (count-based default expansion)', () => {
|
|
844
|
+
const mkLane = (id: string, children: ResourceLane[] = []): ResourceLane => ({
|
|
845
|
+
id, kind: 'Deployment', namespace: 'default', name: id, isWorkload: true, events: [], children,
|
|
846
|
+
})
|
|
847
|
+
const kids = (parentId: string, n: number): ResourceLane[] =>
|
|
848
|
+
Array.from({ length: n }, (_, i) => mkLane(`${parentId}-c${i}`))
|
|
849
|
+
|
|
850
|
+
it('auto-expands a small family (<= threshold children)', () => {
|
|
851
|
+
const lane = mkLane('web', kids('web', AUTO_COLLAPSE_THRESHOLD))
|
|
852
|
+
expect(computeAutoExpandedLanes([lane]).has('web')).toBe(true)
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('keeps a large family collapsed (> threshold children)', () => {
|
|
856
|
+
const lane = mkLane('api', kids('api', AUTO_COLLAPSE_THRESHOLD + 1))
|
|
857
|
+
expect(computeAutoExpandedLanes([lane]).has('api')).toBe(false)
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('never expands a childless leaf', () => {
|
|
861
|
+
expect(computeAutoExpandedLanes([mkLane('leaf')]).has('leaf')).toBe(false)
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
it('applies the rule at every depth (small Job under a large CronJob)', () => {
|
|
865
|
+
const job = mkLane('cron-job1', kids('cron-job1', 2)) // small → expand
|
|
866
|
+
const bigCron = mkLane('cron', [job, ...kids('cron', AUTO_COLLAPSE_THRESHOLD)]) // >threshold → collapse
|
|
867
|
+
const set = computeAutoExpandedLanes([bigCron])
|
|
868
|
+
expect(set.has('cron')).toBe(false)
|
|
869
|
+
expect(set.has('cron-job1')).toBe(true)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('is deterministic and pure (same input → same set, no throw on empty)', () => {
|
|
873
|
+
expect(computeAutoExpandedLanes([])).toEqual(new Set())
|
|
874
|
+
const lane = mkLane('web', kids('web', 2))
|
|
875
|
+
expect(computeAutoExpandedLanes([lane])).toEqual(computeAutoExpandedLanes([lane]))
|
|
876
|
+
})
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
describe('mergeLaneOrderById (live-mode order hysteresis)', () => {
|
|
880
|
+
it('keeps existing lanes in their PRIOR order when the fresh rank reshuffles them', () => {
|
|
881
|
+
// prior order a,b,c ; fresh rank flips to c,b,a — existing lanes must not move.
|
|
882
|
+
expect(mergeLaneOrderById(['a', 'b', 'c'], ['c', 'b', 'a'])).toEqual(['a', 'b', 'c'])
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('is identity when nothing changed', () => {
|
|
886
|
+
expect(mergeLaneOrderById(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
it('drops lanes absent from the fresh set (a resource left the window)', () => {
|
|
890
|
+
expect(mergeLaneOrderById(['a', 'b', 'c'], ['a', 'c'])).toEqual(['a', 'c'])
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('splices a NEW lane in right after the surviving lane that precedes it by rank', () => {
|
|
894
|
+
// fresh puts new "x" between a and b → it lands after a.
|
|
895
|
+
expect(mergeLaneOrderById(['a', 'b'], ['a', 'x', 'b'])).toEqual(['a', 'x', 'b'])
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
it('places a new top-ranked lane at the very front (no preceding survivor)', () => {
|
|
899
|
+
expect(mergeLaneOrderById(['a', 'b'], ['x', 'a', 'b'])).toEqual(['x', 'a', 'b'])
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('keeps multiple new lanes in fresh relative order after their shared anchor', () => {
|
|
903
|
+
expect(mergeLaneOrderById(['a', 'b'], ['a', 'x', 'y', 'b'])).toEqual(['a', 'x', 'y', 'b'])
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
it('anchors a new lane by its fresh predecessor even when survivors are reshuffled', () => {
|
|
907
|
+
// survivors keep prior order [a,b,c]; fresh ranks them c,a,new,b — "new" follows a.
|
|
908
|
+
expect(mergeLaneOrderById(['a', 'b', 'c'], ['c', 'a', 'new', 'b'])).toEqual(['a', 'new', 'b', 'c'])
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
it('adopts the fresh order wholesale from an empty prior (first live render)', () => {
|
|
912
|
+
expect(mergeLaneOrderById([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('is pure — does not mutate its inputs', () => {
|
|
916
|
+
const prev = ['a', 'b']
|
|
917
|
+
const fresh = ['a', 'x', 'b']
|
|
918
|
+
mergeLaneOrderById(prev, fresh)
|
|
919
|
+
expect(prev).toEqual(['a', 'b'])
|
|
920
|
+
expect(fresh).toEqual(['a', 'x', 'b'])
|
|
921
|
+
})
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
describe('reconcileAutoExpand (auto-expand default frozen once per lane id)', () => {
|
|
925
|
+
const mkLane = (id: string, children: ResourceLane[] = []): ResourceLane => ({
|
|
926
|
+
id, kind: 'Deployment', namespace: 'default', name: id, isWorkload: true, events: [], children,
|
|
927
|
+
})
|
|
928
|
+
const kids = (parentId: string, n: number): ResourceLane[] =>
|
|
929
|
+
Array.from({ length: n }, (_, i) => mkLane(`${parentId}-c${i}`))
|
|
930
|
+
|
|
931
|
+
it('freezes a small family OPEN even after it grows past the threshold on a later tick', () => {
|
|
932
|
+
// First sighting: 2 children (<= threshold) → default open.
|
|
933
|
+
const first = reconcileAutoExpand(new Map(), [mkLane('web', kids('web', 2))])
|
|
934
|
+
expect(first.get('web')).toBe(true)
|
|
935
|
+
// Later tick: same lane now has THRESHOLD+3 children (would collapse fresh) —
|
|
936
|
+
// but the frozen default must not flip, so arriving pods don't close the row.
|
|
937
|
+
const grown = reconcileAutoExpand(first, [mkLane('web', kids('web', AUTO_COLLAPSE_THRESHOLD + 3))])
|
|
938
|
+
expect(grown.get('web')).toBe(true)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('freezes a large family COLLAPSED even after it shrinks below the threshold', () => {
|
|
942
|
+
const first = reconcileAutoExpand(new Map(), [mkLane('api', kids('api', AUTO_COLLAPSE_THRESHOLD + 2))])
|
|
943
|
+
expect(first.get('api')).toBe(false)
|
|
944
|
+
const shrunk = reconcileAutoExpand(first, [mkLane('api', kids('api', 2))])
|
|
945
|
+
expect(shrunk.get('api')).toBe(false)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('gives a NEWLY-arrived lane its current structural default without touching existing lanes', () => {
|
|
949
|
+
const prev = reconcileAutoExpand(new Map(), [mkLane('web', kids('web', AUTO_COLLAPSE_THRESHOLD + 5))])
|
|
950
|
+
expect(prev.get('web')).toBe(false)
|
|
951
|
+
const withNew = reconcileAutoExpand(prev, [
|
|
952
|
+
mkLane('web', kids('web', AUTO_COLLAPSE_THRESHOLD + 5)),
|
|
953
|
+
mkLane('cache', kids('cache', 2)), // new small family → opens
|
|
954
|
+
])
|
|
955
|
+
expect(withNew.get('web')).toBe(false) // untouched
|
|
956
|
+
expect(withNew.get('cache')).toBe(true) // new lane gets its default
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it('does not mutate the previous map (pure accumulation into a fresh map)', () => {
|
|
960
|
+
const prev = reconcileAutoExpand(new Map(), [mkLane('web', kids('web', 2))])
|
|
961
|
+
const before = new Map(prev)
|
|
962
|
+
reconcileAutoExpand(prev, [mkLane('web', kids('web', 2)), mkLane('new', kids('new', 1))])
|
|
963
|
+
expect(prev).toEqual(before)
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('collectLaneIdsDeep enumerates parents and all descendants', () => {
|
|
967
|
+
const tree = [mkLane('p', [mkLane('c1', [mkLane('g1')]), mkLane('c2')])]
|
|
968
|
+
expect(collectLaneIdsDeep(tree)).toEqual(['p', 'c1', 'g1', 'c2'])
|
|
969
|
+
})
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
describe('group collision chip (same kind+ns+name, different API group)', () => {
|
|
973
|
+
const clusterEv = (id: string, apiVersion: string): TimelineEvent => ({
|
|
974
|
+
id, timestamp: new Date(NOW).toISOString(), source: 'informer',
|
|
975
|
+
kind: 'Cluster', namespace: 'prod', name: 'main', eventType: 'update', apiVersion,
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
it('renders a group chip on BOTH colliding lanes (SSR)', () => {
|
|
979
|
+
const html = renderToString(
|
|
980
|
+
<TimelineSwimlanes
|
|
981
|
+
events={[clusterEv('capi', 'cluster.x-k8s.io/v1beta1'), clusterEv('cnpg', 'postgresql.cnpg.io/v1')]}
|
|
982
|
+
grouping="flat"
|
|
983
|
+
viewWindow={{ fromMs: NOW - HOUR, toMs: NOW + HOUR }}
|
|
984
|
+
/>,
|
|
985
|
+
)
|
|
986
|
+
// The chip is the "API group <g>" title; both groups get one.
|
|
987
|
+
expect(html).toContain('API group cluster.x-k8s.io')
|
|
988
|
+
expect(html).toContain('API group postgresql.cnpg.io')
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it('renders NO group chip when the CRD lane is unique', () => {
|
|
992
|
+
const html = renderToString(
|
|
993
|
+
<TimelineSwimlanes
|
|
994
|
+
events={[clusterEv('cnpg', 'postgresql.cnpg.io/v1')]}
|
|
995
|
+
grouping="flat"
|
|
996
|
+
viewWindow={{ fromMs: NOW - HOUR, toMs: NOW + HOUR }}
|
|
997
|
+
/>,
|
|
998
|
+
)
|
|
999
|
+
expect(html).not.toContain('API group')
|
|
1000
|
+
// The group still rides the kind badge's title tooltip (non-intrusive).
|
|
1001
|
+
expect(html).toContain('postgresql.cnpg.io')
|
|
1002
|
+
})
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
describe('cluster span cap (a pill never spans far-apart events)', () => {
|
|
1006
|
+
it('splits a dense chain that exceeds the max span into separate pills', () => {
|
|
1007
|
+
// Each neighbor is within min-gap, but the chain crawls across 5× the gap.
|
|
1008
|
+
// Unbounded chaining would collapse an hour of distinct positions into one
|
|
1009
|
+
// pill; the cap breaks it at CLUSTER_MAX_SPAN_FACTOR × gap.
|
|
1010
|
+
const step = CLUSTER_MIN_GAP_PCT * 0.9
|
|
1011
|
+
const input = positioned(...Array.from({ length: 7 }, (_, i) => [mkEvent(`e${i}`), 10 + i * step] as [TimelineEvent, number]))
|
|
1012
|
+
const out = clusterEventsByPosition(input, CLUSTER_MIN_GAP_PCT)
|
|
1013
|
+
expect(out.length).toBeGreaterThan(1)
|
|
1014
|
+
for (const c of out) {
|
|
1015
|
+
const xs = c.events.map((e) => input.find((p) => p.event.id === e.id)!.x)
|
|
1016
|
+
expect(Math.max(...xs) - Math.min(...xs)).toBeLessThanOrEqual(CLUSTER_MIN_GAP_PCT * CLUSTER_MAX_SPAN_FACTOR)
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('keeps a tight burst as one pill', () => {
|
|
1021
|
+
const out = clusterEventsByPosition(
|
|
1022
|
+
positioned([mkEvent('a'), 10], [mkEvent('b'), 10.3], [mkEvent('c'), 10.6]),
|
|
1023
|
+
CLUSTER_MIN_GAP_PCT,
|
|
1024
|
+
)
|
|
1025
|
+
expect(out).toHaveLength(1)
|
|
1026
|
+
expect(out[0].count).toBe(3)
|
|
1027
|
+
})
|
|
1028
|
+
})
|