@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.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. 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
+ })