@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  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 +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ buildHealthSpans,
4
+ sweepAggregateHealth,
5
+ formatAggregateHealthTooltip,
6
+ buildLaneMemberSpans,
7
+ type MemberHealthSpans,
8
+ type AggregateHealthSegment,
9
+ } from './shared'
10
+ import type { ResourceLane } from '../../utils/resource-hierarchy'
11
+
12
+ describe('buildHealthSpans own-resource existence (child cleanup must not kill the lane)', () => {
13
+ const t = (min: number) => new Date(Date.UTC(2026, 0, 1, 10, min)).toISOString()
14
+ const start = Date.UTC(2026, 0, 1, 10, 0)
15
+ const now = Date.UTC(2026, 0, 1, 11, 0)
16
+ const cron = { kind: 'CronJob', namespace: 'team-a', name: 'aliaser' }
17
+ const ev = (over: Record<string, unknown>) => ({
18
+ id: String(Math.random()), source: 'informer', eventType: 'update',
19
+ kind: 'CronJob', namespace: 'team-a', name: 'aliaser',
20
+ timestamp: t(5), healthState: 'idle', ...over,
21
+ }) as any
22
+
23
+ it('a child Job delete does not truncate the parent lane existence', () => {
24
+ const events = [
25
+ ev({ timestamp: t(1) }),
26
+ ev({ kind: 'Job', name: 'aliaser-123', eventType: 'delete', timestamp: t(10), healthState: undefined }),
27
+ ev({ timestamp: t(20) }),
28
+ ]
29
+ const { spans } = buildHealthSpans(events, start, now, events, cron)
30
+ // strip must reach `now`, not stop at the child delete
31
+ expect(spans[spans.length - 1].end).toBe(now)
32
+ })
33
+
34
+ it('the lane resource own delete still ends its existence', () => {
35
+ const events = [
36
+ ev({ timestamp: t(1) }),
37
+ ev({ eventType: 'delete', timestamp: t(30), healthState: undefined }),
38
+ ]
39
+ const { spans } = buildHealthSpans(events, start, now, events, cron)
40
+ expect(spans[spans.length - 1].end).toBe(Date.UTC(2026, 0, 1, 10, 30))
41
+ })
42
+ })
43
+
44
+ describe('buildHealthSpans birth clamp (no pre-birth back-fill)', () => {
45
+ const start = Date.UTC(2026, 0, 1, 10, 0)
46
+ const now = Date.UTC(2026, 0, 1, 12, 40)
47
+ const pod = { kind: 'Pod', namespace: 'team-a', name: 'runner-abc12' }
48
+ const ev = (min: number, over: Record<string, unknown>) => ({
49
+ id: `${min}-${JSON.stringify(over).length}`, source: 'informer', kind: 'Pod', namespace: 'team-a', name: 'runner-abc12',
50
+ timestamp: new Date(Date.UTC(2026, 0, 1, 12, min)).toISOString(), eventType: 'update', ...over,
51
+ }) as any
52
+
53
+ it('a pod whose first event is its creation gets no span before birth', () => {
54
+ const events = [
55
+ ev(25, { reason: 'created', healthState: 'unknown' }),
56
+ ev(25, { eventType: 'add', healthState: 'healthy' }),
57
+ ev(30, { healthState: 'neutral' }),
58
+ ]
59
+ const { spans } = buildHealthSpans(events, start, now, events, pod)
60
+ expect(spans[0].start).toBe(Date.UTC(2026, 0, 1, 12, 25))
61
+ })
62
+
63
+ it('a resource first seen via a later mutation keeps the existed-before assumption', () => {
64
+ const events = [ev(25, { healthState: 'healthy' })]
65
+ const { spans } = buildHealthSpans(events, start, now, events, pod)
66
+ expect(spans[0].start).toBe(start)
67
+ })
68
+ })
69
+
70
+ // --- state-family aggregate health sweep (item 3) ---------------------------
71
+
72
+ const member = (name: string, ...spans: [number, number, string][]): MemberHealthSpans => ({
73
+ name,
74
+ spans: spans.map(([start, end, health]) => ({ start, end, health })),
75
+ })
76
+ // A member healthy across the whole [0,100] window.
77
+ const full = (name: string, health: string): MemberHealthSpans => member(name, [0, 100, health])
78
+
79
+ describe('sweepAggregateHealth: state families', () => {
80
+ it('uniform OK (all healthy) → one healthy segment, not mixed', () => {
81
+ const segs = sweepAggregateHealth([full('a', 'healthy'), full('b', 'healthy')], 0, 100)
82
+ expect(segs).toHaveLength(1)
83
+ expect(segs[0]).toMatchObject({ health: 'healthy', mixed: false, total: 2 })
84
+ expect(segs[0].byState).toEqual({ healthy: ['a', 'b'] })
85
+ })
86
+
87
+ it('uniform BAD (degraded + unhealthy) → one BAD family segment painting the most severe (unhealthy)', () => {
88
+ const segs = sweepAggregateHealth([full('a', 'degraded'), full('b', 'unhealthy')], 0, 100)
89
+ expect(segs).toHaveLength(1)
90
+ expect(segs[0]).toMatchObject({ health: 'unhealthy', mixed: false, total: 2 })
91
+ })
92
+
93
+ it('benign mix (healthy + idle) stays ONE family (OK) → NOT mixed [founder key case]', () => {
94
+ const segs = sweepAggregateHealth([full('a', 'healthy'), full('b', 'idle')], 0, 100)
95
+ expect(segs).toHaveLength(1)
96
+ expect(segs[0].mixed).toBe(false)
97
+ // Tie between healthy and idle → healthy.
98
+ expect(segs[0].health).toBe('healthy')
99
+ })
100
+
101
+ it('OK majority wins (2 idle vs 1 healthy → idle), no tie', () => {
102
+ const segs = sweepAggregateHealth([full('a', 'idle'), full('b', 'idle'), full('c', 'healthy')], 0, 100)
103
+ expect(segs[0]).toMatchObject({ health: 'idle', mixed: false })
104
+ })
105
+
106
+ it('true mix (healthy + unhealthy) → MIXED', () => {
107
+ const segs = sweepAggregateHealth([full('a', 'healthy'), full('b', 'unhealthy')], 0, 100)
108
+ expect(segs).toHaveLength(1)
109
+ expect(segs[0]).toMatchObject({ health: 'mixed', mixed: true })
110
+ })
111
+
112
+ it('rolling + healthy → MIXED (rolling is its own family)', () => {
113
+ const segs = sweepAggregateHealth([full('a', 'rolling'), full('b', 'healthy')], 0, 100)
114
+ expect(segs[0]).toMatchObject({ health: 'mixed', mixed: true })
115
+ })
116
+
117
+ it('uniform rolling → one rolling segment, not mixed', () => {
118
+ const segs = sweepAggregateHealth([full('a', 'rolling'), full('b', 'rolling')], 0, 100)
119
+ expect(segs[0]).toMatchObject({ health: 'rolling', mixed: false })
120
+ })
121
+ })
122
+
123
+ describe('sweepAggregateHealth: slice boundaries + attribution', () => {
124
+ it('splits at a member transition, painting each slice from that slice state set', () => {
125
+ // a healthy throughout; b healthy [0,60] then unhealthy [60,100].
126
+ const segs = sweepAggregateHealth(
127
+ [full('a', 'healthy'), member('b', [0, 60, 'healthy'], [60, 100, 'unhealthy'])],
128
+ 0, 100,
129
+ )
130
+ expect(segs).toHaveLength(2)
131
+ expect(segs[0]).toMatchObject({ start: 0, end: 60, health: 'healthy', mixed: false })
132
+ expect(segs[1]).toMatchObject({ start: 60, end: 100, health: 'mixed', mixed: true })
133
+ })
134
+
135
+ it('merges adjacent slices with identical state + attribution', () => {
136
+ // Boundary at 50 exists in the data but both slices resolve identically → merge.
137
+ const segs = sweepAggregateHealth(
138
+ [member('a', [0, 50, 'healthy'], [50, 100, 'healthy'])],
139
+ 0, 100,
140
+ )
141
+ expect(segs).toHaveLength(1)
142
+ expect(segs[0]).toMatchObject({ start: 0, end: 100, health: 'healthy' })
143
+ })
144
+
145
+ it('paints nothing where no member is alive (gap slice omitted)', () => {
146
+ const segs = sweepAggregateHealth([member('a', [0, 40, 'healthy'], [70, 100, 'healthy'])], 0, 100)
147
+ // Two live slices, the [40,70] gap is dropped.
148
+ expect(segs.map((s) => [s.start, s.end])).toEqual([[0, 40], [70, 100]])
149
+ })
150
+
151
+ it('records per-state member names for attribution', () => {
152
+ const segs = sweepAggregateHealth(
153
+ [full('u1', 'unhealthy'), full('h1', 'healthy'), full('h2', 'healthy')],
154
+ 0, 100,
155
+ )
156
+ expect(segs[0].byState).toEqual({ unhealthy: ['u1'], healthy: ['h1', 'h2'] })
157
+ expect(segs[0].total).toBe(3)
158
+ })
159
+ })
160
+
161
+ describe('formatAggregateHealthTooltip', () => {
162
+ const seg = (over: Partial<AggregateHealthSegment>): AggregateHealthSegment => ({
163
+ start: 0, end: 100, health: 'healthy', mixed: false, total: 1, byState: { healthy: ['a'] }, ...over,
164
+ })
165
+
166
+ it('uniform → "healthy · 23/23"', () => {
167
+ const names = Array.from({ length: 23 }, (_, i) => `p${i}`)
168
+ expect(formatAggregateHealthTooltip(seg({ total: 23, byState: { healthy: names } })))
169
+ .toBe('healthy · 23/23')
170
+ })
171
+
172
+ it('mixed → "mixed · 2/23 unhealthy: <names> · 21 healthy/idle"', () => {
173
+ const healthy = Array.from({ length: 21 }, (_, i) => `h${i}`)
174
+ const s = seg({ health: 'mixed', mixed: true, total: 23, byState: { unhealthy: ['name-a', 'name-b'], healthy } })
175
+ expect(formatAggregateHealthTooltip(s)).toBe('mixed · 2/23 unhealthy: name-a, name-b · 21 healthy/idle')
176
+ })
177
+
178
+ it('caps callout names at 3 + "+N more"', () => {
179
+ const s = seg({ health: 'mixed', mixed: true, total: 6, byState: { unhealthy: ['u1', 'u2', 'u3', 'u4', 'u5'], healthy: ['h1'] } })
180
+ expect(formatAggregateHealthTooltip(s)).toBe('mixed · 5/6 unhealthy: u1, u2, u3, +2 more · 1 healthy/idle')
181
+ })
182
+
183
+ it('rolling callout in a mixed slice is labelled rolling', () => {
184
+ const s = seg({ health: 'mixed', mixed: true, total: 3, byState: { rolling: ['r1'], healthy: ['h1', 'h2'] } })
185
+ expect(formatAggregateHealthTooltip(s)).toBe('mixed · 1/3 rolling: r1 · 2 healthy/idle')
186
+ })
187
+ })
188
+
189
+ describe('buildLaneMemberSpans (leaf gathering)', () => {
190
+ const win = { start: Date.UTC(2026, 0, 1, 10, 0), now: Date.UTC(2026, 0, 1, 11, 0) }
191
+ const ev = (kind: string, name: string, health: string): any => ({
192
+ id: `${name}-add`, source: 'informer', kind, namespace: 'ops', name,
193
+ timestamp: new Date(Date.UTC(2026, 0, 1, 10, 10)).toISOString(), eventType: 'add', healthState: health,
194
+ })
195
+ const leaf = (kind: string, name: string, health: string): ResourceLane => ({
196
+ id: `${kind}/ops/${name}`, kind, namespace: 'ops', name, isWorkload: true, events: [ev(kind, name, health)], children: [],
197
+ })
198
+
199
+ it('returns one member per LEAF resource (grandchildren, not intermediate nodes)', () => {
200
+ const job: ResourceLane = {
201
+ id: 'Job/ops/j', kind: 'Job', namespace: 'ops', name: 'j', isWorkload: true, events: [],
202
+ children: [leaf('Pod', 'j-abc12', 'healthy'), leaf('Pod', 'j-def34', 'unhealthy')],
203
+ }
204
+ const cron: ResourceLane = {
205
+ id: 'CronJob/ops/backup', kind: 'CronJob', namespace: 'ops', name: 'backup', isWorkload: true, events: [], children: [job],
206
+ }
207
+ const members = buildLaneMemberSpans(cron, win.start, win.now)
208
+ expect(members.map((m) => m.name).sort()).toEqual(['j-abc12', 'j-def34'])
209
+ // Feeding these into the sweep yields a MIXED strip (healthy pod vs unhealthy pod).
210
+ const segs = sweepAggregateHealth(members, win.start, win.now)
211
+ expect(segs.some((s) => s.mixed)).toBe(true)
212
+ })
213
+
214
+ it('a childless lane is its own single leaf member', () => {
215
+ const dep = leaf('Deployment', 'web', 'healthy')
216
+ const members = buildLaneMemberSpans(dep, win.start, win.now)
217
+ expect(members.map((m) => m.name)).toEqual(['web'])
218
+ })
219
+ })