@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
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'
2
2
 
3
3
  import type { TimelineEvent, Topology } from '../types/core'
4
4
 
5
- import { buildResourceHierarchy } from './resource-hierarchy'
5
+ import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isPinnedLaneRef, laneTrackEvents, laneHasEventInWindow, isChildVisibleInWindow, subtreeEvents, getAllEventsFromHierarchy, collidingLaneKeys, laneCollisionKey, type ResourceLane } from './resource-hierarchy'
6
+ import type { AppMembershipIndex, AppMembership } from './applications'
6
7
 
7
8
  function svcEvent(namespace: string, name: string): TimelineEvent {
8
9
  return {
@@ -49,3 +50,991 @@ describe('buildResourceHierarchy app-label grouping', () => {
49
50
  expect(lanes[0].children).toHaveLength(1)
50
51
  })
51
52
  })
53
+
54
+ // --- app-membership grouping cascade ----------------------------------------
55
+
56
+ function changeEvent(kind: string, namespace: string, name: string, over: Partial<TimelineEvent> = {}): TimelineEvent {
57
+ return { id: `${kind}/${namespace}/${name}`, timestamp: '2024-01-01T00:00:00.000Z', source: 'informer', kind, namespace, name, eventType: 'update', ...over }
58
+ }
59
+
60
+ function index(byResource: Record<string, AppMembership>, byEvidence: Record<string, AppMembership> = {}): AppMembershipIndex {
61
+ return { byResource: new Map(Object.entries(byResource)), byEvidence: new Map(Object.entries(byEvidence)) }
62
+ }
63
+
64
+ describe('buildResourceHierarchy app-membership cascade (grouping=app)', () => {
65
+ const billing: AppMembership = { appKey: 'team-a/app/billing', appName: 'billing', env: 'prod', evidence: 'app.kubernetes.io/instance' }
66
+
67
+ it('tier 1: wraps resources that ARE known workloads under an app header', () => {
68
+ const events = [changeEvent('Deployment', 'team-a', 'billing-api'), changeEvent('Deployment', 'team-a', 'billing-worker')]
69
+ const appIndex = index({ 'Deployment/team-a/billing-api': billing, 'Deployment/team-a/billing-worker': billing })
70
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
71
+ expect(lanes).toHaveLength(1)
72
+ expect(lanes[0].isAppGroup).toBe(true)
73
+ expect(lanes[0].title).toBe('billing')
74
+ expect(lanes[0].env).toBe('prod')
75
+ expect(lanes[0].children).toHaveLength(2)
76
+ })
77
+
78
+ it('tier 2: a DELETED member joins its app via event-label evidence (matchKeys)', () => {
79
+ // billing-api is a live known workload; billing-worker was deleted before the
80
+ // snapshot and is unknown by resource, but its event carries the instance label.
81
+ const events = [
82
+ changeEvent('Deployment', 'team-a', 'billing-api'),
83
+ changeEvent('Deployment', 'team-a', 'billing-worker', { labels: { 'app.kubernetes.io/instance': 'billing-prod' } }),
84
+ ]
85
+ // Evidence keys are namespace-scoped: kind:namespace:value.
86
+ const appIndex = index({ 'Deployment/team-a/billing-api': billing }, { 'instance:team-a:billing-prod': billing })
87
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
88
+ expect(lanes).toHaveLength(1)
89
+ expect(lanes[0].isAppGroup).toBe(true)
90
+ expect(lanes[0].children?.map((c) => c.name).sort()).toEqual(['billing-api', 'billing-worker'])
91
+ })
92
+
93
+ it('tier 2: GitOps identity labels join argo:/helm: matchKeys (Argo-/Flux-primary apps)', () => {
94
+ // An Argo-primary app whose members lack the standard k8s labels: the
95
+ // deleted member's event carries only the Argo instance label, and a
96
+ // Flux-HelmRelease member carries only the Flux name label. Both must
97
+ // re-join through the server's argo:/helm: evidence keys.
98
+ const argoApp: AppMembership = { appKey: 'team-a/argo/shop', appName: 'shop', evidence: 'argocd.argoproj.io/instance' }
99
+ const events = [
100
+ changeEvent('Deployment', 'team-a', 'shop-api'),
101
+ changeEvent('Deployment', 'team-a', 'shop-worker', { labels: { 'argocd.argoproj.io/instance': 'shop' } }),
102
+ changeEvent('Deployment', 'team-a', 'shop-cache', { labels: { 'helm.toolkit.fluxcd.io/name': 'shop' } }),
103
+ ]
104
+ const appIndex = index(
105
+ { 'Deployment/team-a/shop-api': argoApp },
106
+ { 'argo:team-a:shop': argoApp, 'helm:team-a:shop': argoApp },
107
+ )
108
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
109
+ expect(lanes).toHaveLength(1)
110
+ expect(lanes[0].isAppGroup).toBe(true)
111
+ expect(lanes[0].children?.map((c) => c.name).sort()).toEqual(['shop-api', 'shop-cache', 'shop-worker'])
112
+ })
113
+
114
+ it('namespace isolation: the same label value in another namespace does NOT cross-join', () => {
115
+ // Two apps in two namespaces share the instance label value "shared". The
116
+ // evidence index is scoped per namespace, so a deleted member's event in
117
+ // team-b must NOT join team-a's app (and vice-versa).
118
+ const appA: AppMembership = { appKey: 'team-a/app/svc', appName: 'svc-a', env: 'prod', evidence: 'app.kubernetes.io/instance' }
119
+ const events = [
120
+ changeEvent('Deployment', 'team-a', 'svc-a-api'),
121
+ // Deleted member in team-b carrying the SAME label value as team-a's app.
122
+ changeEvent('Deployment', 'team-b', 'svc-b-worker', { labels: { 'app.kubernetes.io/instance': 'shared' } }),
123
+ ]
124
+ // Index knows svc-a's live workload + only the team-a-scoped evidence key.
125
+ const appIndex = index({ 'Deployment/team-a/svc-a-api': appA }, { 'instance:team-a:shared': appA })
126
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
127
+
128
+ // team-a's app is a single-member group (only svc-a-api) → renders as a plain
129
+ // lane; the team-b event must NOT have joined it.
130
+ const svcAApi = lanes.find((l) => l.name === 'svc-a-api' || l.children?.some((c) => c.name === 'svc-a-api'))
131
+ expect(svcAApi).toBeTruthy()
132
+ const joinedTeamB = (l: typeof lanes[number]) =>
133
+ l.name === 'svc-b-worker' || (l.children ?? []).some((c) => c.name === 'svc-b-worker')
134
+ const teamBUnderA = lanes.some((l) => (l.appKey === appA.appKey || l.name === 'svc-a-api') && joinedTeamB(l))
135
+ expect(teamBUnderA).toBe(false)
136
+ // svc-b-worker exists as its own lane, not folded into svc-a.
137
+ expect(lanes.some((l) => l.name === 'svc-b-worker' || joinedTeamB(l))).toBe(true)
138
+ })
139
+
140
+ it('label evidence but no AppRow → no synthetic group; resources stay ungrouped', () => {
141
+ // The server is the only grouping authority: raw label evidence with no
142
+ // server-declared app behind it must NOT invent a client-side group. These
143
+ // two label-bearing resources render as their own plain lanes.
144
+ const events = [
145
+ changeEvent('Deployment', 'team-a', 'ghost-api', { labels: { 'app.kubernetes.io/name': 'ghost' } }),
146
+ changeEvent('Deployment', 'team-a', 'ghost-worker', { labels: { 'app.kubernetes.io/name': 'ghost' } }),
147
+ ]
148
+ const appIndex = index({}, {}) // no matching AppRow
149
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
150
+ expect(lanes).toHaveLength(2)
151
+ expect(lanes.every((l) => !l.isAppGroup)).toBe(true)
152
+ expect(lanes.map((l) => l.name).sort()).toEqual(['ghost-api', 'ghost-worker'])
153
+ })
154
+
155
+ it('fallback: ungrouped resources render as their own lane', () => {
156
+ const events = [changeEvent('ConfigMap', 'team-a', 'orphan-cm')]
157
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex: index({}) })
158
+ expect(lanes).toHaveLength(1)
159
+ expect(lanes[0].isAppGroup).toBeFalsy()
160
+ expect(lanes[0].name).toBe('orphan-cm')
161
+ })
162
+
163
+ it('a single-member app does not earn a header (renders as a plain lane)', () => {
164
+ const events = [changeEvent('Deployment', 'team-a', 'billing-api')]
165
+ const appIndex = index({ 'Deployment/team-a/billing-api': billing })
166
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
167
+ expect(lanes).toHaveLength(1)
168
+ expect(lanes[0].isAppGroup).toBeFalsy()
169
+ expect(lanes[0].name).toBe('billing-api')
170
+ })
171
+
172
+ // --- tier 2.5: member name-prefix fallback -------------------------------
173
+ const koala: AppMembership = { appKey: 'autopush/app/koala-backend', appName: 'koala-backend', env: 'prod', evidence: 'app.kubernetes.io/instance' }
174
+
175
+ it('tier 2.5: a Pod with owner:null + labels:null joins its app by name-prefix', () => {
176
+ // The Deployment member is present; a deletion-time Pod event shipped with no
177
+ // owner and no labels (connector cache miss), so it fails tiers 1-2 but its
178
+ // generated name still encodes the Deployment.
179
+ const events = [
180
+ changeEvent('Deployment', 'autopush', 'koala-backend'),
181
+ changeEvent('Pod', 'autopush', 'koala-backend-6dc59db657-6jm4n'),
182
+ ]
183
+ const appIndex = index({ 'Deployment/autopush/koala-backend': koala })
184
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
185
+ expect(lanes).toHaveLength(1)
186
+ expect(lanes[0].isAppGroup).toBe(true)
187
+ expect(lanes[0].title).toBe('koala-backend')
188
+ const pod = lanes[0].children?.find((c) => c.kind === 'Pod')
189
+ expect(pod?.name).toBe('koala-backend-6dc59db657-6jm4n')
190
+ expect(pod?.matchedByName).toBe(true)
191
+ // The Deployment member joined by resource, not by name.
192
+ expect(lanes[0].children?.find((c) => c.kind === 'Deployment')?.matchedByName).toBeFalsy()
193
+ })
194
+
195
+ it('contract nesting supersedes tier 2.5 when the parent lanes are present (RS+Pod nest, not flat)', () => {
196
+ // With the ReplicaSet present, naming contracts nest the Pod under the RS and
197
+ // the RS under the Deployment — the correct hierarchy — instead of tier-2.5
198
+ // flattening all three as app-group siblings. The Deployment is then the sole
199
+ // member, so it renders as a plain lane (single-member app earns no header).
200
+ const events = [
201
+ changeEvent('Deployment', 'autopush', 'koala-backend'),
202
+ changeEvent('ReplicaSet', 'autopush', 'koala-backend-6dc59db657'),
203
+ changeEvent('Pod', 'autopush', 'koala-backend-6dc59db657-6jm4n'),
204
+ ]
205
+ const appIndex = index({ 'Deployment/autopush/koala-backend': koala })
206
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
207
+ expect(lanes).toHaveLength(1)
208
+ expect(lanes[0].isAppGroup).toBeFalsy()
209
+ expect(lanes[0].id).toBe('Deployment/autopush/koala-backend')
210
+ const rs = lanes[0].children!.find((c) => c.kind === 'ReplicaSet')!
211
+ expect(rs.nestedByContract).toBe(true)
212
+ const pod = rs.children!.find((c) => c.kind === 'Pod')!
213
+ expect(pod.nestedByContract).toBe(true)
214
+ })
215
+
216
+ it('tier 2.5 namespace isolation: same member name in another namespace does NOT join', () => {
217
+ const events = [
218
+ changeEvent('Deployment', 'autopush', 'koala-backend'),
219
+ // Pod in a DIFFERENT namespace whose name would prefix-match koala-backend.
220
+ changeEvent('Pod', 'other-ns', 'koala-backend-6dc59db657-6jm4n'),
221
+ ]
222
+ const appIndex = index({ 'Deployment/autopush/koala-backend': koala })
223
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
224
+ // koala-backend is a single-member group → plain lane; the other-ns Pod stands alone.
225
+ expect(lanes.map((l) => l.name).sort()).toEqual(['koala-backend', 'koala-backend-6dc59db657-6jm4n'])
226
+ expect(lanes.every((l) => !l.isAppGroup)).toBe(true)
227
+ })
228
+
229
+ it('tier 2.5 longest-wins: web-admin claims web-admin-* over web', () => {
230
+ const web: AppMembership = { appKey: 'team-a/app/web', appName: 'web', evidence: 'name' }
231
+ const webAdmin: AppMembership = { appKey: 'team-a/app/web-admin', appName: 'web-admin', evidence: 'name' }
232
+ const events = [
233
+ changeEvent('Deployment', 'team-a', 'web'),
234
+ changeEvent('Deployment', 'team-a', 'web-admin'),
235
+ // Single-segment suffix so BOTH "web" (suffix admin-abc12) and "web-admin"
236
+ // (suffix abc12) are candidates — longest member name must win.
237
+ changeEvent('Pod', 'team-a', 'web-admin-abc12'),
238
+ ]
239
+ const appIndex = index({
240
+ 'Deployment/team-a/web': web,
241
+ 'Deployment/team-a/web-admin': webAdmin,
242
+ })
243
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
244
+ const webAdminGroup = lanes.find((l) => l.isAppGroup && l.title === 'web-admin')
245
+ expect(webAdminGroup).toBeTruthy()
246
+ expect(webAdminGroup?.children?.some((c) => c.name === 'web-admin-abc12')).toBe(true)
247
+ // web stayed a single-member plain lane (the pod did not join it).
248
+ const webLane = lanes.find((l) => l.name === 'web' && !l.isAppGroup)
249
+ expect(webLane).toBeTruthy()
250
+ })
251
+
252
+ it('tier 2.5 suffix strictness: a word suffix (canary) does NOT match', () => {
253
+ const events = [
254
+ changeEvent('Deployment', 'autopush', 'koala-backend'),
255
+ changeEvent('Pod', 'autopush', 'koala-backend-canary'),
256
+ ]
257
+ const appIndex = index({ 'Deployment/autopush/koala-backend': koala })
258
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
259
+ // No join: koala-backend renders as a plain lane, canary Pod stands alone.
260
+ expect(lanes.every((l) => !l.isAppGroup)).toBe(true)
261
+ expect(lanes.map((l) => l.name).sort()).toEqual(['koala-backend', 'koala-backend-canary'])
262
+ })
263
+
264
+ it('tier 2.5 kind gate: a Service named like a member + suffix does NOT match', () => {
265
+ const events = [
266
+ changeEvent('Deployment', 'autopush', 'koala-backend'),
267
+ // A Service, not a generated-name kind — a real name collision, not a child.
268
+ changeEvent('Service', 'autopush', 'koala-backend-6dc59db657'),
269
+ ]
270
+ const appIndex = index({ 'Deployment/autopush/koala-backend': koala })
271
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
272
+ expect(lanes.every((l) => !l.isAppGroup)).toBe(true)
273
+ expect(lanes.map((l) => l.name).sort()).toEqual(['koala-backend', 'koala-backend-6dc59db657'])
274
+ })
275
+ })
276
+
277
+ // --- kind-contract nesting (parent-driven naming) ---------------------------
278
+ //
279
+ // A resource whose only in-window events are ownerless (deletion-time cache miss)
280
+ // can still nest under a PRESENT parent lane when its generated name encodes the
281
+ // parent by a Kubernetes naming contract. Confidence: ownerRef > kind-contract >
282
+ // name-stem. Nested lanes ride their chain root into app membership rather than
283
+ // joining an app flat as siblings via the weaker tier-2.5 name-prefix fallback.
284
+
285
+ describe('buildResourceHierarchy kind-contract nesting', () => {
286
+ it('nests an ownerless Job under its CronJob (numeric schedule stamp)', () => {
287
+ const events = [
288
+ changeEvent('CronJob', 'ops', 'backup'),
289
+ changeEvent('Job', 'ops', 'backup-27700001'),
290
+ ]
291
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
292
+ expect(lanes.map((l) => l.id)).toEqual(['CronJob/ops/backup'])
293
+ const job = lanes[0].children!.find((c) => c.kind === 'Job')
294
+ expect(job?.id).toBe('Job/ops/backup-27700001')
295
+ expect(job?.nestedByContract).toBe(true)
296
+ })
297
+
298
+ it('nests an ownerless Pod under its Job (5-char generateName tail)', () => {
299
+ const events = [
300
+ changeEvent('Job', 'ops', 'backup-27700001'),
301
+ changeEvent('Pod', 'ops', 'backup-27700001-abc12'),
302
+ ]
303
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
304
+ expect(lanes.map((l) => l.id)).toEqual(['Job/ops/backup-27700001'])
305
+ const pod = lanes[0].children!.find((c) => c.kind === 'Pod')
306
+ expect(pod?.nestedByContract).toBe(true)
307
+ })
308
+
309
+ it('nests an ownerless ReplicaSet under its Deployment (pod-template-hash)', () => {
310
+ const events = [
311
+ changeEvent('Deployment', 'ns', 'web'),
312
+ changeEvent('ReplicaSet', 'ns', 'web-6dc59db657'),
313
+ ]
314
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
315
+ expect(lanes.map((l) => l.id)).toEqual(['Deployment/ns/web'])
316
+ const rs = lanes[0].children!.find((c) => c.kind === 'ReplicaSet')
317
+ expect(rs?.nestedByContract).toBe(true)
318
+ })
319
+
320
+ it('negative: a word suffix (canary) is not a schedule stamp — no nesting', () => {
321
+ const events = [
322
+ changeEvent('CronJob', 'ops', 'backup'),
323
+ changeEvent('Job', 'ops', 'backup-canary'),
324
+ ]
325
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
326
+ expect(lanes.map((l) => l.id).sort()).toEqual(['CronJob/ops/backup', 'Job/ops/backup-canary'])
327
+ })
328
+
329
+ it('negative: a parent in another namespace does not adopt the child', () => {
330
+ const events = [
331
+ changeEvent('CronJob', 'ops', 'backup'),
332
+ changeEvent('Job', 'other', 'backup-27700001'),
333
+ ]
334
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
335
+ expect(lanes.map((l) => l.id).sort()).toEqual(['CronJob/ops/backup', 'Job/other/backup-27700001'])
336
+ })
337
+
338
+ it('negative: wrong kind pairing (a Pod does not adopt onto a CronJob)', () => {
339
+ const events = [
340
+ changeEvent('CronJob', 'ops', 'backup'),
341
+ changeEvent('Pod', 'ops', 'backup-abc12'),
342
+ ]
343
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
344
+ expect(lanes.map((l) => l.id).sort()).toEqual(['CronJob/ops/backup', 'Pod/ops/backup-abc12'])
345
+ })
346
+
347
+ it('two-hyphen ambiguity: the exact present parent wins (a-b, not a)', () => {
348
+ const events = [
349
+ changeEvent('CronJob', 'ops', 'a'),
350
+ changeEvent('CronJob', 'ops', 'a-b'),
351
+ changeEvent('Job', 'ops', 'a-b-123'),
352
+ ]
353
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
354
+ const ab = lanes.find((l) => l.id === 'CronJob/ops/a-b')
355
+ expect(ab?.children?.some((c) => c.id === 'Job/ops/a-b-123')).toBe(true)
356
+ const a = lanes.find((l) => l.id === 'CronJob/ops/a')
357
+ expect(a?.children ?? []).toHaveLength(0)
358
+ })
359
+
360
+ it('cycle guard: never reparent a lane under its own descendant', () => {
361
+ // An owner ref already nests the Job under the Pod; the naming contract must
362
+ // NOT then nest the Pod under that Job (the candidate parent is in the Pod's
363
+ // own subtree).
364
+ const events = [
365
+ changeEvent('Pod', 'ops', 'parent-abc12', { id: 'pod' }),
366
+ changeEvent('Job', 'ops', 'parent', { id: 'job', owner: { kind: 'Pod', name: 'parent-abc12' } }),
367
+ ]
368
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
369
+ expect(lanes.map((l) => l.id)).toEqual(['Pod/ops/parent-abc12'])
370
+ expect(lanes[0].nestedByContract).toBeFalsy()
371
+ expect(lanes[0].children!.map((c) => c.id)).toEqual(['Job/ops/parent'])
372
+ })
373
+
374
+ it('app membership via chain root: a contract-nested orphan is not a flat member', () => {
375
+ const batch: AppMembership = { appKey: 'ops/app/batch', appName: 'batch' }
376
+ const events = [
377
+ changeEvent('CronJob', 'ops', 'backup'),
378
+ changeEvent('Deployment', 'ops', 'web'),
379
+ changeEvent('Job', 'ops', 'backup-27700001'),
380
+ ]
381
+ const appIndex = index({
382
+ 'CronJob/ops/backup': batch,
383
+ 'Deployment/ops/web': batch,
384
+ })
385
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
386
+ const app = lanes.find((l) => l.isAppGroup)!
387
+ // The Job did NOT join the app flat as a sibling of the CronJob.
388
+ expect(app.children!.some((c) => c.id === 'Job/ops/backup-27700001')).toBe(false)
389
+ // It rides the CronJob member instead, marked contract-nested (not name-matched).
390
+ const cron = app.children!.find((c) => c.id === 'CronJob/ops/backup')!
391
+ const job = cron.children!.find((c) => c.id === 'Job/ops/backup-27700001')!
392
+ expect(job.nestedByContract).toBe(true)
393
+ expect(job.matchedByName).toBeFalsy()
394
+ })
395
+ })
396
+
397
+ // --- window-filtered group members ------------------------------------------
398
+
399
+ describe('laneHasEventInWindow / isChildVisibleInWindow', () => {
400
+ const START = Date.parse('2024-06-01T00:00:00.000Z')
401
+ const END = Date.parse('2024-06-01T01:00:00.000Z')
402
+ const IN = '2024-06-01T00:30:00.000Z'
403
+ const OUT = '2024-05-01T00:00:00.000Z'
404
+ const leaf = (id: string, iso: string, over: Partial<ResourceLane> = {}): ResourceLane => ({
405
+ id, kind: 'Pod', namespace: 'ns', name: id, isWorkload: true,
406
+ events: [changeEvent('Pod', 'ns', id, { id: `${id}-ev`, timestamp: iso })], children: [], ...over,
407
+ })
408
+
409
+ it('true when the lane owns an in-window event', () => {
410
+ expect(laneHasEventInWindow(leaf('a', IN), START, END)).toBe(true)
411
+ })
412
+
413
+ it('true when a descendant owns an in-window event', () => {
414
+ const parent: ResourceLane = { id: 'Job/ns/j', kind: 'Job', namespace: 'ns', name: 'j', isWorkload: true, events: [], children: [leaf('a', IN)] }
415
+ expect(laneHasEventInWindow(parent, START, END)).toBe(true)
416
+ })
417
+
418
+ it('false when every subtree event is outside the window', () => {
419
+ const parent: ResourceLane = { id: 'Job/ns/j', kind: 'Job', namespace: 'ns', name: 'j', isWorkload: true, events: [], children: [leaf('a', OUT)] }
420
+ expect(laneHasEventInWindow(parent, START, END)).toBe(false)
421
+ })
422
+
423
+ it('prefers the precomputed allEventsSorted roll-up when present', () => {
424
+ const lane = leaf('a', OUT, { events: [], allEventsSorted: [changeEvent('Pod', 'ns', 'a', { id: 'roll', timestamp: IN })] })
425
+ expect(laneHasEventInWindow(lane, START, END)).toBe(true)
426
+ })
427
+
428
+ it('hides an out-of-window child; shows an in-window one', () => {
429
+ expect(isChildVisibleInWindow(leaf('a', OUT), START, END, { pinned: false, userExpanded: false })).toBe(false)
430
+ expect(isChildVisibleInWindow(leaf('b', IN), START, END, { pinned: false, userExpanded: false })).toBe(true)
431
+ })
432
+
433
+ it('pinned exemption keeps an out-of-window child', () => {
434
+ expect(isChildVisibleInWindow(leaf('a', OUT), START, END, { pinned: true, userExpanded: false })).toBe(true)
435
+ })
436
+
437
+ it('user-expanded exemption keeps an out-of-window child (auto-expand does not)', () => {
438
+ expect(isChildVisibleInWindow(leaf('a', OUT), START, END, { pinned: false, userExpanded: true })).toBe(true)
439
+ })
440
+ })
441
+
442
+ describe('buildResourceHierarchy flat mode', () => {
443
+ it('skips ALL parenting — every resource is its own lane', () => {
444
+ const events = [
445
+ changeEvent('Deployment', 'team-a', 'api'),
446
+ changeEvent('Pod', 'team-a', 'api-7d4f-x2p', { owner: { kind: 'ReplicaSet', name: 'api-7d4f' } }),
447
+ ]
448
+ const lanes = buildResourceHierarchy({ events, grouping: 'flat' })
449
+ expect(lanes.every((l) => (l.children ?? []).length === 0)).toBe(true)
450
+ // The Pod owner-ref did NOT create/attach a ReplicaSet parent.
451
+ expect(lanes.some((l) => l.kind === 'ReplicaSet')).toBe(false)
452
+ })
453
+
454
+ it('still attaches K8s events to their owner lane (identity, not grouping)', () => {
455
+ const events = [
456
+ changeEvent('Deployment', 'team-a', 'api'),
457
+ { id: 'ev', timestamp: '2024-01-01T00:00:00.000Z', source: 'k8s_event', kind: 'Event', namespace: 'team-a', name: 'evt', eventType: 'Warning', owner: { kind: 'Deployment', name: 'api' } } as TimelineEvent,
458
+ ]
459
+ const lanes = buildResourceHierarchy({ events, grouping: 'flat' })
460
+ // One lane (the Deployment) carrying both its own event and the attached K8s event.
461
+ const dep = lanes.find((l) => l.id === 'Deployment/team-a/api')
462
+ expect(dep).toBeTruthy()
463
+ expect(dep!.events.length).toBe(2)
464
+ })
465
+ })
466
+
467
+ // --- CronJob → Job → Pod attribution (the swimlane empty-child bug) ----------
468
+ //
469
+ // Each level owns events. The chain must nest (Pod under Job under CronJob) with
470
+ // every event landing on ITS OWN lane — the k8s-event→owner attachment rule must
471
+ // NOT strip a Pod's lifecycle events onto an ancestor. Then `laneTrackEvents`
472
+ // pins the render slice: a collapsed parent paints its subtree roll-up, an
473
+ // expanded parent (or a leaf) paints only its own events.
474
+
475
+ function cronChainEvents(): TimelineEvent[] {
476
+ return [
477
+ // CronJob owns 2
478
+ changeEvent('CronJob', 'ops', 'backup', { eventType: 'add' }),
479
+ changeEvent('CronJob', 'ops', 'backup', { id: 'cj-k8s', source: 'k8s_event', eventType: 'Normal', reason: 'SuccessfulCreate' }),
480
+ // Job owns 2 (owner = CronJob)
481
+ changeEvent('Job', 'ops', 'backup-1700', { id: 'job-add', eventType: 'add', owner: { kind: 'CronJob', name: 'backup' } }),
482
+ changeEvent('Job', 'ops', 'backup-1700', { id: 'job-comp', source: 'k8s_event', eventType: 'Normal', reason: 'Completed', owner: { kind: 'CronJob', name: 'backup' } }),
483
+ // Pod owns 3 — informer add (owner = Job) + two k8s lifecycle events. The
484
+ // backend enriches a Pod k8s event's owner to the Pod's controller (Job), so
485
+ // these carry owner:Job while their kind stays 'Pod' (involvedObject).
486
+ changeEvent('Pod', 'ops', 'backup-1700-abc12', { id: 'pod-add', eventType: 'add', owner: { kind: 'Job', name: 'backup-1700' } }),
487
+ changeEvent('Pod', 'ops', 'backup-1700-abc12', { id: 'pod-sched', source: 'k8s_event', eventType: 'Normal', reason: 'Scheduled', owner: { kind: 'Job', name: 'backup-1700' } }),
488
+ changeEvent('Pod', 'ops', 'backup-1700-abc12', { id: 'pod-start', source: 'k8s_event', eventType: 'Normal', reason: 'Started', owner: { kind: 'Job', name: 'backup-1700' } }),
489
+ ]
490
+ }
491
+
492
+ describe('buildResourceHierarchy CronJob → Job → Pod attribution', () => {
493
+ it('nests the chain and keeps every event on its own lane (owner mode)', () => {
494
+ const lanes = buildResourceHierarchy({ events: cronChainEvents(), grouping: 'owner' })
495
+ expect(lanes).toHaveLength(1)
496
+ const cron = lanes[0]
497
+ expect(cron.id).toBe('CronJob/ops/backup')
498
+ // Two-level chain: CronJob's only direct child is the Job; the Pod hangs
499
+ // under the Job (a grandchild), not as a sibling of the Job.
500
+ expect(cron.children!.map((c) => c.kind)).toEqual(['Job'])
501
+ const job = cron.children!.find((c) => c.kind === 'Job')!
502
+ const pod = job.children!.find((c) => c.kind === 'Pod')!
503
+ // Each resource owns exactly its own events — nothing stripped upward.
504
+ expect(cron.events).toHaveLength(2)
505
+ expect(job.events).toHaveLength(2)
506
+ expect(pod.events).toHaveLength(3)
507
+ // The Pod's lifecycle k8s events stay on the Pod's own lane.
508
+ expect(pod.events.map((e) => e.id).sort()).toEqual(['pod-add', 'pod-sched', 'pod-start'])
509
+ // No event was double-attached to an ancestor.
510
+ expect(cron.events.some((e) => e.id.startsWith('pod-') || e.id.startsWith('job-'))).toBe(false)
511
+ })
512
+
513
+ it('getAllEventsFromHierarchy flattens the FULL depth — grandchild Pod events included', () => {
514
+ const lanes = buildResourceHierarchy({ events: cronChainEvents(), grouping: 'owner' })
515
+ const all = getAllEventsFromHierarchy(lanes)
516
+ // CronJob (2) + Job (2) + grandchild Pod (3) = 7. A one-level walk would drop
517
+ // the Pod's 3, undercounting the events the swimlane (built from the full
518
+ // tree) shows — this pins the recursion that fixes that.
519
+ expect(all).toHaveLength(7)
520
+ expect(all.filter((e) => e.id.startsWith('pod-'))).toHaveLength(3)
521
+ expect(all.map((e) => e.id)).toContain('pod-start')
522
+ })
523
+
524
+ it('laneTrackEvents: collapsed parent = subtree aggregate, expanded parent = own only', () => {
525
+ const cron = buildResourceHierarchy({ events: cronChainEvents(), grouping: 'owner' })[0]
526
+ // Collapsed CronJob paints the whole subtree roll-up (2 + 2 + 3 = 7).
527
+ expect(laneTrackEvents(cron, false)).toHaveLength(7)
528
+ // Expanded CronJob paints ONLY its own 2 events (children render their own).
529
+ expect(laneTrackEvents(cron, true)).toHaveLength(2)
530
+ // The intermediate Job, collapsed, rolls up its own + Pod events (2 + 3).
531
+ const job = cron.children!.find((c) => c.kind === 'Job')!
532
+ expect(laneTrackEvents(job, false)).toHaveLength(5)
533
+ expect(laneTrackEvents(job, true)).toHaveLength(2)
534
+ // A leaf child paints its own events whether "expanded" or not.
535
+ const pod = job.children!.find((c) => c.kind === 'Pod')!
536
+ expect(laneTrackEvents(pod, false)).toHaveLength(3)
537
+ expect(laneTrackEvents(pod, true)).toHaveLength(3)
538
+ })
539
+
540
+ it('no event appears on two visible rows once the parent is expanded', () => {
541
+ const cron = buildResourceHierarchy({ events: cronChainEvents(), grouping: 'owner' })[0]
542
+ const parentSlice = laneTrackEvents(cron, true) // expanded → own
543
+ const childSlices = cron.children!.flatMap((c) => laneTrackEvents(c, false))
544
+ const ids = [...parentSlice, ...childSlices].map((e) => e.id)
545
+ expect(new Set(ids).size).toBe(ids.length) // no duplicate id across visible rows
546
+ expect(ids).toHaveLength(7) // and every event is shown exactly once
547
+ })
548
+
549
+ it('subtreeEvents rolls up a nested parent that carries no precomputed aggregate', () => {
550
+ // A hand-built Job with a Pod child (no allEventsSorted) still rolls up.
551
+ const pod: ResourceLane = { id: 'Pod/ops/p', kind: 'Pod', namespace: 'ops', name: 'p', isWorkload: true, events: [changeEvent('Pod', 'ops', 'p', { id: 'p1' })], children: [] }
552
+ const job: ResourceLane = { id: 'Job/ops/j', kind: 'Job', namespace: 'ops', name: 'j', isWorkload: true, events: [changeEvent('Job', 'ops', 'j', { id: 'j1' })], children: [pod] }
553
+ expect(subtreeEvents(job).map((e) => e.id).sort()).toEqual(['j1', 'p1'])
554
+ })
555
+
556
+ it('app-group mode: a CronJob MEMBER keeps its Job/Pod as drill-down rows (grandchildren, own slices)', () => {
557
+ const events = [...cronChainEvents(), changeEvent('Deployment', 'ops', 'web', { id: 'dep-add', eventType: 'add' })]
558
+ const appIndex = index({
559
+ 'CronJob/ops/backup': { appKey: 'batch', appName: 'batch' } as AppMembership,
560
+ 'Deployment/ops/web': { appKey: 'batch', appName: 'batch' } as AppMembership,
561
+ })
562
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex })
563
+ const app = lanes.find((l) => l.isAppGroup)!
564
+ const cron = app.children!.find((c) => c.kind === 'CronJob')!
565
+ // The member is NOT flattened to an aggregate leaf — its Job (direct child)
566
+ // and Pod (grandchild under the Job) survive so they can be drilled into
567
+ // with their own event slices.
568
+ expect(cron.children!.map((c) => c.kind)).toEqual(['Job'])
569
+ const job = cron.children!.find((c) => c.kind === 'Job')!
570
+ expect(job.children!.map((c) => c.kind)).toEqual(['Pod'])
571
+ // Collapsed member = aggregate (all pills); expanded member = own only.
572
+ expect(laneTrackEvents(cron, false)).toHaveLength(7)
573
+ expect(laneTrackEvents(cron, true)).toHaveLength(2)
574
+ const pod = job.children!.find((c) => c.kind === 'Pod')!
575
+ expect(laneTrackEvents(pod, false)).toHaveLength(3)
576
+ })
577
+ })
578
+
579
+ describe('extractPinnedLanes', () => {
580
+ it('matches a top-level root and clones it as a pinned lane (subtree events preserved)', () => {
581
+ const events = [
582
+ changeEvent('Deployment', 'team-a', 'web'),
583
+ changeEvent('Pod', 'team-a', 'web-7d4f-x2p', { owner: { kind: 'Deployment', name: 'web' } }),
584
+ ]
585
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
586
+ const pinned = extractPinnedLanes(lanes, [
587
+ { id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' },
588
+ ])
589
+ expect(pinned).toHaveLength(1)
590
+ expect(pinned[0].id).toBe('Deployment/team-a/web')
591
+ // Root's merged track carries its own + the child Pod's events.
592
+ const ids = (pinned[0].allEventsSorted ?? []).map((e) => e.id).sort()
593
+ expect(ids).toEqual(['Deployment/team-a/web', 'Pod/team-a/web-7d4f-x2p'])
594
+ })
595
+
596
+ it('matches a nested child lane and clones it as its own top-level pinned lane', () => {
597
+ const events = [
598
+ changeEvent('Deployment', 'team-a', 'web'),
599
+ changeEvent('Pod', 'team-a', 'web-7d4f-x2p', { owner: { kind: 'Deployment', name: 'web' } }),
600
+ ]
601
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
602
+ // The Pod is a child of the Deployment in the built hierarchy.
603
+ expect(lanes.find((l) => l.id === 'Pod/team-a/web-7d4f-x2p')).toBeFalsy()
604
+ const pinned = extractPinnedLanes(lanes, [
605
+ { id: 'Pod/team-a/web-7d4f-x2p', kind: 'Pod', namespace: 'team-a', name: 'web-7d4f-x2p' },
606
+ ])
607
+ expect(pinned).toHaveLength(1)
608
+ expect(pinned[0].id).toBe('Pod/team-a/web-7d4f-x2p')
609
+ expect((pinned[0].allEventsSorted ?? []).map((e) => e.id)).toEqual(['Pod/team-a/web-7d4f-x2p'])
610
+ })
611
+
612
+ it('synthesizes an empty lane from the record when the resource is absent', () => {
613
+ const lanes = buildResourceHierarchy({ events: [changeEvent('Deployment', 'team-a', 'web')], grouping: 'owner' })
614
+ const pinned = extractPinnedLanes(lanes, [
615
+ { id: 'Pod/team-a/ghost', kind: 'Pod', namespace: 'team-a', name: 'ghost' },
616
+ ])
617
+ expect(pinned).toHaveLength(1)
618
+ expect(pinned[0]).toMatchObject({ id: 'Pod/team-a/ghost', kind: 'Pod', namespace: 'team-a', name: 'ghost' })
619
+ expect(pinned[0].allEventsSorted).toEqual([])
620
+ expect(pinned[0].events).toEqual([])
621
+ })
622
+
623
+ it('returns pinned lanes in pin order, not hierarchy order', () => {
624
+ const lanes = buildResourceHierarchy({
625
+ events: [changeEvent('Deployment', 'team-a', 'aaa'), changeEvent('Deployment', 'team-a', 'zzz')],
626
+ grouping: 'owner',
627
+ })
628
+ const pinned = extractPinnedLanes(lanes, [
629
+ { id: 'Deployment/team-a/zzz', kind: 'Deployment', namespace: 'team-a', name: 'zzz' },
630
+ { id: 'Deployment/team-a/aaa', kind: 'Deployment', namespace: 'team-a', name: 'aaa' },
631
+ ])
632
+ expect(pinned.map((l) => l.name)).toEqual(['zzz', 'aaa'])
633
+ })
634
+
635
+ it('returns an empty array for no pins', () => {
636
+ const lanes = buildResourceHierarchy({ events: [changeEvent('Deployment', 'team-a', 'web')], grouping: 'owner' })
637
+ expect(extractPinnedLanes(lanes, [])).toEqual([])
638
+ })
639
+
640
+ // --- app-group pins ------------------------------------------------------
641
+ const billingApp: AppMembership = { appKey: 'team-a/app/billing', appName: 'billing', env: 'prod', evidence: 'app.kubernetes.io/instance' }
642
+ const billingIndex = index({
643
+ 'Deployment/team-a/billing-api': billingApp,
644
+ 'Deployment/team-a/billing-worker': billingApp,
645
+ })
646
+
647
+ it('resolves an app-group ref to the live group lane (members re-resolve)', () => {
648
+ const events = [changeEvent('Deployment', 'team-a', 'billing-api'), changeEvent('Deployment', 'team-a', 'billing-worker')]
649
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex: billingIndex })
650
+ const pinned = extractPinnedLanes(lanes, [
651
+ { type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
652
+ ])
653
+ expect(pinned).toHaveLength(1)
654
+ expect(pinned[0].isAppGroup).toBe(true)
655
+ expect(pinned[0].appKey).toBe('team-a/app/billing')
656
+ expect(pinned[0].absentPinnedApp).toBeFalsy()
657
+ // Live members re-resolved from the current lanes, not the stored record.
658
+ expect(pinned[0].children?.map((c) => c.name).sort()).toEqual(['billing-api', 'billing-worker'])
659
+ })
660
+
661
+ it('synthesizes a quiet header for an app-group ref with no matching group lane (owner mode)', () => {
662
+ // Owner grouping never builds the group lane by construction — the pin still
663
+ // renders as a dimmed "not present" header from the record.
664
+ const events = [changeEvent('Deployment', 'team-a', 'billing-api'), changeEvent('Deployment', 'team-a', 'billing-worker')]
665
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
666
+ const pinned = extractPinnedLanes(lanes, [
667
+ { type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
668
+ ])
669
+ expect(pinned).toHaveLength(1)
670
+ expect(pinned[0]).toMatchObject({ id: 'app:team-a/app/billing', isAppGroup: true, appKey: 'team-a/app/billing', name: 'billing', absentPinnedApp: true })
671
+ expect(pinned[0].children).toEqual([])
672
+ expect(pinned[0].allEventsSorted).toEqual([])
673
+ })
674
+
675
+ it('synthesizes a quiet header when the app has vanished entirely', () => {
676
+ const lanes = buildResourceHierarchy({ events: [changeEvent('Deployment', 'team-a', 'other')], grouping: 'app', appIndex: index({}) })
677
+ const pinned = extractPinnedLanes(lanes, [
678
+ { type: 'appGroup', id: 'app:team-a/app/gone', appKey: 'team-a/app/gone', appName: 'gone' },
679
+ ])
680
+ expect(pinned[0].absentPinnedApp).toBe(true)
681
+ expect(pinned[0].title).toBe('gone')
682
+ })
683
+
684
+ it('preserves pin order across mixed resource + app-group refs', () => {
685
+ const events = [
686
+ changeEvent('Deployment', 'team-a', 'billing-api'),
687
+ changeEvent('Deployment', 'team-a', 'billing-worker'),
688
+ changeEvent('Deployment', 'team-a', 'standalone'),
689
+ ]
690
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex: billingIndex })
691
+ const pinned = extractPinnedLanes(lanes, [
692
+ { id: 'Deployment/team-a/standalone', kind: 'Deployment', namespace: 'team-a', name: 'standalone' },
693
+ { type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
694
+ ])
695
+ expect(pinned.map((l) => l.id)).toEqual(['Deployment/team-a/standalone', 'app:team-a/app/billing'])
696
+ })
697
+
698
+ it('resolves a legacy resource ref that carries no `type` (back-compat)', () => {
699
+ const lanes = buildResourceHierarchy({ events: [changeEvent('Deployment', 'team-a', 'web')], grouping: 'owner' })
700
+ const pinned = extractPinnedLanes(lanes, [
701
+ { id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' },
702
+ ])
703
+ expect(pinned).toHaveLength(1)
704
+ expect(pinned[0].id).toBe('Deployment/team-a/web')
705
+ expect(pinned[0].isAppGroup).toBeFalsy()
706
+ })
707
+
708
+ // Dedup: a pinned app group subsumes a separately-pinned member of that group,
709
+ // regardless of pin order — the app-group wins so the member renders once
710
+ // (inside the group's roll-up), never a second standalone row that would
711
+ // double-count its events.
712
+ it.each([
713
+ {
714
+ name: 'app pinned FIRST, then member → member folded into group only',
715
+ refs: [
716
+ { type: 'appGroup' as const, id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
717
+ { id: 'Deployment/team-a/billing-api', kind: 'Deployment', namespace: 'team-a', name: 'billing-api' },
718
+ ],
719
+ expectedIds: ['app:team-a/app/billing'],
720
+ },
721
+ {
722
+ name: 'member pinned FIRST, then app → same outcome (app-group wins)',
723
+ refs: [
724
+ { id: 'Deployment/team-a/billing-api', kind: 'Deployment', namespace: 'team-a', name: 'billing-api' },
725
+ { type: 'appGroup' as const, id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
726
+ ],
727
+ expectedIds: ['app:team-a/app/billing'],
728
+ },
729
+ ])('$name', ({ refs, expectedIds }) => {
730
+ const events = [changeEvent('Deployment', 'team-a', 'billing-api'), changeEvent('Deployment', 'team-a', 'billing-worker')]
731
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex: billingIndex })
732
+ const pinned = extractPinnedLanes(lanes, refs)
733
+ // The member appears exactly once — inside the group roll-up, not as its own row.
734
+ expect(pinned.map((l) => l.id)).toEqual(expectedIds)
735
+ const group = pinned.find((l) => l.isAppGroup)
736
+ expect(group?.children?.map((c) => c.name).sort()).toEqual(['billing-api', 'billing-worker'])
737
+ // Counts don't double: the member's event id appears once across all pinned lanes.
738
+ const allEventIds = pinned.flatMap((l) => (l.allEventsSorted ?? []).map((e) => e.id))
739
+ expect(allEventIds.filter((id) => id === 'Deployment/team-a/billing-api')).toHaveLength(1)
740
+ })
741
+
742
+ it('keeps a standalone member pin that is NOT part of any pinned app group', () => {
743
+ const events = [
744
+ changeEvent('Deployment', 'team-a', 'billing-api'),
745
+ changeEvent('Deployment', 'team-a', 'billing-worker'),
746
+ changeEvent('Deployment', 'team-a', 'standalone'),
747
+ ]
748
+ const lanes = buildResourceHierarchy({ events, grouping: 'app', appIndex: billingIndex })
749
+ const pinned = extractPinnedLanes(lanes, [
750
+ { type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' },
751
+ { id: 'Deployment/team-a/standalone', kind: 'Deployment', namespace: 'team-a', name: 'standalone' },
752
+ ])
753
+ expect(pinned.map((l) => l.id)).toEqual(['app:team-a/app/billing', 'Deployment/team-a/standalone'])
754
+ })
755
+ })
756
+
757
+ describe('isPinnedLaneRef (localStorage validation)', () => {
758
+ it('accepts a legacy resource ref with no type', () => {
759
+ expect(isPinnedLaneRef({ id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' })).toBe(true)
760
+ })
761
+ it('accepts an explicit resource ref', () => {
762
+ expect(isPinnedLaneRef({ type: 'resource', id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web' })).toBe(true)
763
+ })
764
+ it('accepts an app-group ref', () => {
765
+ expect(isPinnedLaneRef({ type: 'appGroup', id: 'app:team-a/app/billing', appKey: 'team-a/app/billing', appName: 'billing' })).toBe(true)
766
+ })
767
+ it('rejects an app-group ref missing appKey/appName', () => {
768
+ expect(isPinnedLaneRef({ type: 'appGroup', id: 'app:x' })).toBe(false)
769
+ })
770
+ it('rejects a resource ref missing required fields', () => {
771
+ expect(isPinnedLaneRef({ id: 'Deployment/team-a/web', kind: 'Deployment' })).toBe(false)
772
+ })
773
+ it('rejects non-objects and null', () => {
774
+ expect(isPinnedLaneRef(null)).toBe(false)
775
+ expect(isPinnedLaneRef('nope')).toBe(false)
776
+ })
777
+ })
778
+
779
+ describe('removePinnedLanes (pin = move, not copy)', () => {
780
+ const lane = (id: string, children?: any[], events: any[] = []) => {
781
+ const [kind, namespace, name] = id.split('/')
782
+ return { id, kind, namespace, name, events, children, isWorkload: false } as any
783
+ }
784
+ it('removes a pinned root lane', () => {
785
+ const out = removePinnedLanes([lane('Deployment/team-a/billing-api'), lane('Service/team-a/web')], new Set(['Deployment/team-a/billing-api']))
786
+ expect(out.map((l: any) => l.id)).toEqual(['Service/team-a/web'])
787
+ })
788
+ it('prunes a pinned child and keeps the parent when it still has content', () => {
789
+ const parent = lane('Service/team-a/web', [lane('Deployment/team-a/web'), lane('Pod/team-a/web-1')], [{ id: 'e1' }])
790
+ const out = removePinnedLanes([parent], new Set(['Pod/team-a/web-1']))
791
+ expect(out[0].children!.map((c: any) => c.id)).toEqual(['Deployment/team-a/web'])
792
+ })
793
+ it('drops a parent left with no children and no own events', () => {
794
+ const parent = lane('Service/team-a/web', [lane('Deployment/team-a/web')], [])
795
+ const out = removePinnedLanes([parent], new Set(['Deployment/team-a/web']))
796
+ expect(out).toEqual([])
797
+ })
798
+ it('no-ops when nothing is pinned', () => {
799
+ const input = [lane('Service/team-a/web')]
800
+ expect(removePinnedLanes(input, new Set())).toBe(input)
801
+ })
802
+
803
+ // --- two-level owner-chain nesting (CronJob → Job → Pod) -------------------
804
+ // Real shape: Pod events carry owner {kind:Job}, Job events carry owner
805
+ // {kind:CronJob}. The tree must nest each lane under its DIRECT owner, not
806
+ // flatten every descendant onto the chain root.
807
+ it('nests Pods under their Job, and the Job under the CronJob (not siblings)', () => {
808
+ const events: TimelineEvent[] = [
809
+ changeEvent('CronJob', 'team-a', 'aliaser', { id: 'cj' }),
810
+ changeEvent('Job', 'team-a', 'aliaser-27700001', { id: 'j1', owner: { kind: 'CronJob', name: 'aliaser' } }),
811
+ changeEvent('Job', 'team-a', 'aliaser-27700002', { id: 'j2', owner: { kind: 'CronJob', name: 'aliaser' } }),
812
+ changeEvent('Pod', 'team-a', 'aliaser-27700001-abc12', { id: 'p1', owner: { kind: 'Job', name: 'aliaser-27700001' } }),
813
+ changeEvent('Pod', 'team-a', 'aliaser-27700001-def34', { id: 'p2', owner: { kind: 'Job', name: 'aliaser-27700001' } }),
814
+ changeEvent('Pod', 'team-a', 'aliaser-27700002-ghi56', { id: 'p3', owner: { kind: 'Job', name: 'aliaser-27700002' } }),
815
+ ]
816
+ const lanes = buildResourceHierarchy({ events, grouping: 'owner' })
817
+
818
+ // One root: the CronJob.
819
+ expect(lanes.map((l) => l.id)).toEqual(['CronJob/team-a/aliaser'])
820
+ const cron = lanes[0]
821
+ // CronJob's direct children are the two Jobs — NO Pods flattened here.
822
+ expect((cron.children ?? []).map((c) => c.id).sort()).toEqual([
823
+ 'Job/team-a/aliaser-27700001',
824
+ 'Job/team-a/aliaser-27700002',
825
+ ])
826
+ const job1 = cron.children!.find((c) => c.id === 'Job/team-a/aliaser-27700001')!
827
+ const job2 = cron.children!.find((c) => c.id === 'Job/team-a/aliaser-27700002')!
828
+ // Pods hang under their own Job.
829
+ expect((job1.children ?? []).map((c) => c.id).sort()).toEqual([
830
+ 'Pod/team-a/aliaser-27700001-abc12',
831
+ 'Pod/team-a/aliaser-27700001-def34',
832
+ ])
833
+ expect((job2.children ?? []).map((c) => c.id)).toEqual(['Pod/team-a/aliaser-27700002-ghi56'])
834
+ })
835
+
836
+ it('collapsed root roll-up still spans every descendant depth (Pods included)', () => {
837
+ const events: TimelineEvent[] = [
838
+ changeEvent('CronJob', 'team-a', 'aliaser', { id: 'cj' }),
839
+ changeEvent('Job', 'team-a', 'aliaser-1', { id: 'j1', owner: { kind: 'CronJob', name: 'aliaser' } }),
840
+ changeEvent('Pod', 'team-a', 'aliaser-1-abc12', { id: 'p1', owner: { kind: 'Job', name: 'aliaser-1' } }),
841
+ ]
842
+ const [cron] = buildResourceHierarchy({ events, grouping: 'owner' })
843
+ // Collapsed parent paints its whole-subtree aggregate — the Pod (grandchild)
844
+ // must be in it, or a two-level chain's roll-up would drop a level.
845
+ const ids = laneTrackEvents(cron, false).map((e) => e.id)
846
+ expect(ids).toEqual(expect.arrayContaining(['cj', 'j1', 'p1']))
847
+ })
848
+ it('removes an app-group root matched by appKey', () => {
849
+ const group = { id: 'app:team-a/app/billing', kind: 'AppGroup', namespace: '', name: 'billing', events: [], isAppGroup: true, appKey: 'team-a/app/billing', children: [lane('Deployment/team-a/billing-api')] } as any
850
+ const out = removePinnedLanes([group, lane('Service/team-a/web')], new Set(), new Set(['team-a/app/billing']))
851
+ expect(out.map((l: any) => l.id)).toEqual(['Service/team-a/web'])
852
+ })
853
+ it('rebuilds the parent roll-up after pruning a pinned child (collapsed parent excludes the moved events)', () => {
854
+ const parentOwn = changeEvent('Service', 'team-a', 'web', { id: 'svc-own' })
855
+ const keepChildEv = changeEvent('Deployment', 'team-a', 'web', { id: 'dep-ev' })
856
+ const pinnedChildEv = changeEvent('Pod', 'team-a', 'web-1', { id: 'pod-ev' })
857
+ const parent: ResourceLane = {
858
+ id: 'Service/team-a/web', kind: 'Service', namespace: 'team-a', name: 'web', isWorkload: false,
859
+ events: [parentOwn],
860
+ children: [
861
+ { id: 'Deployment/team-a/web', kind: 'Deployment', namespace: 'team-a', name: 'web', isWorkload: true, events: [keepChildEv], children: [] },
862
+ { id: 'Pod/team-a/web-1', kind: 'Pod', namespace: 'team-a', name: 'web-1', isWorkload: true, events: [pinnedChildEv], children: [] },
863
+ ],
864
+ // Stale aggregate built WITH the pinned child included — the bug this guards.
865
+ allEventsSorted: [parentOwn, keepChildEv, pinnedChildEv],
866
+ childEventCount: 2,
867
+ }
868
+ const [pruned] = removePinnedLanes([parent], new Set(['Pod/team-a/web-1']))
869
+ const ids = laneTrackEvents(pruned, false).map((e) => e.id) // collapsed → roll-up
870
+ expect(ids).toContain('svc-own')
871
+ expect(ids).toContain('dep-ev')
872
+ expect(ids).not.toContain('pod-ev') // the pinned child's events must not double-paint
873
+ expect(pruned.childEventCount).toBe(1)
874
+ })
875
+ })
876
+
877
+ // --- group-qualified lane identity ------------------------------------------
878
+
879
+ describe('group-qualified lane identity', () => {
880
+ const capiCluster = (over: Partial<TimelineEvent> = {}) =>
881
+ changeEvent('Cluster', 'prod', 'main', { id: 'capi', apiVersion: 'cluster.x-k8s.io/v1beta1', ...over })
882
+ const cnpgCluster = (over: Partial<TimelineEvent> = {}) =>
883
+ changeEvent('Cluster', 'prod', 'main', { id: 'cnpg', apiVersion: 'postgresql.cnpg.io/v1', ...over })
884
+
885
+ it('keeps two same-kind CRDs from different groups as SEPARATE lanes', () => {
886
+ const lanes = buildResourceHierarchy({ events: [capiCluster(), cnpgCluster()], grouping: 'flat' })
887
+ expect(lanes).toHaveLength(2)
888
+ const ids = lanes.map((l) => l.id).sort()
889
+ expect(ids).toEqual([
890
+ 'Cluster.cluster.x-k8s.io/prod/main',
891
+ 'Cluster.postgresql.cnpg.io/prod/main',
892
+ ])
893
+ // Each lane carries its own group.
894
+ expect(new Set(lanes.map((l) => l.group))).toEqual(new Set(['cluster.x-k8s.io', 'postgresql.cnpg.io']))
895
+ })
896
+
897
+ it('keeps core/built-in resource ids bare (byte-stable)', () => {
898
+ const lanes = buildResourceHierarchy({
899
+ events: [
900
+ changeEvent('Pod', 'team-a', 'x', { apiVersion: 'v1' }),
901
+ changeEvent('Deployment', 'ns', 'web', { apiVersion: 'apps/v1' }),
902
+ ],
903
+ grouping: 'flat',
904
+ })
905
+ expect(lanes.map((l) => l.id).sort()).toEqual(['Deployment/ns/web', 'Pod/team-a/x'])
906
+ })
907
+
908
+ it('collidingLaneKeys flags a same-kind cross-group collision, nothing when unique', () => {
909
+ const colliding = buildResourceHierarchy({ events: [capiCluster(), cnpgCluster()], grouping: 'flat' })
910
+ const keys = collidingLaneKeys(colliding)
911
+ expect(keys.has(laneCollisionKey({ kind: 'Cluster', namespace: 'prod', name: 'main' }))).toBe(true)
912
+
913
+ const unique = buildResourceHierarchy({ events: [cnpgCluster()], grouping: 'flat' })
914
+ expect(collidingLaneKeys(unique).size).toBe(0)
915
+ })
916
+
917
+ it('a CRD lane still joins its app via the group-less byResource key', () => {
918
+ const db: AppMembership = { appKey: 'prod/app/shop', appName: 'shop', env: 'prod' }
919
+ const appIndex = index({
920
+ 'Cluster/prod/main': db, // group-less key, as AppRow workloads ship
921
+ 'Deployment/prod/web': db,
922
+ })
923
+ const lanes = buildResourceHierarchy({
924
+ events: [cnpgCluster(), changeEvent('Deployment', 'prod', 'web', { apiVersion: 'apps/v1' })],
925
+ grouping: 'app',
926
+ appIndex,
927
+ })
928
+ // Both roots fold under one app-group header.
929
+ expect(lanes).toHaveLength(1)
930
+ expect(lanes[0].isAppGroup).toBe(true)
931
+ const childIds = (lanes[0].children ?? []).map((c) => c.id).sort()
932
+ expect(childIds).toEqual(['Cluster.postgresql.cnpg.io/prod/main', 'Deployment/prod/web'])
933
+ })
934
+
935
+ it('owner parenting reconciles a CRD owner across the qualified/bare boundary', () => {
936
+ // A Pod owned by a CNPG Cluster: the owner ref carries no group, but it must
937
+ // nest under the Cluster's group-qualified lane, not fork a bare duplicate.
938
+ const lanes = buildResourceHierarchy({
939
+ events: [
940
+ cnpgCluster(),
941
+ changeEvent('Pod', 'prod', 'main-1', { apiVersion: 'v1', owner: { kind: 'Cluster', name: 'main' } }),
942
+ ],
943
+ grouping: 'owner',
944
+ })
945
+ expect(lanes).toHaveLength(1)
946
+ expect(lanes[0].id).toBe('Cluster.postgresql.cnpg.io/prod/main')
947
+ expect((lanes[0].children ?? []).map((c) => c.id)).toEqual(['Pod/prod/main-1'])
948
+ })
949
+
950
+ it('ignores an old-format (group-less) CRD pin gracefully — no crash, empty ghost row', () => {
951
+ const lanes = buildResourceHierarchy({ events: [cnpgCluster()], grouping: 'flat' })
952
+ // Stale pin written before group-qualified ids existed: bare id, no match.
953
+ const stale = { type: 'resource' as const, id: 'Cluster/prod/main', kind: 'Cluster', namespace: 'prod', name: 'main' }
954
+ expect(isPinnedLaneRef(stale)).toBe(true) // still a well-formed record
955
+ const pinned = extractPinnedLanes(lanes, [stale])
956
+ expect(pinned).toHaveLength(1)
957
+ expect(pinned[0].id).toBe('Cluster/prod/main')
958
+ expect(pinned[0].events).toHaveLength(0) // synthesized empty — did not hijack the live lane
959
+ // The live qualified lane is untouched by the stale pin.
960
+ expect(lanes[0].id).toBe('Cluster.postgresql.cnpg.io/prod/main')
961
+ })
962
+ })
963
+
964
+ describe('app membership outranks topology attachment (grouping=app)', () => {
965
+ const crashloop: AppMembership = { appKey: 'radar-netdiag/Deployment/crashloop', appName: 'crashloop' }
966
+
967
+ const exposesTopo = {
968
+ nodes: [
969
+ { id: 'service/radar-netdiag/crashloop', kind: 'Service', name: 'crashloop', data: { namespace: 'radar-netdiag' } },
970
+ { id: 'deployment/radar-netdiag/crashloop', kind: 'Deployment', name: 'crashloop', data: { namespace: 'radar-netdiag' } },
971
+ ],
972
+ edges: [{ source: 'service/radar-netdiag/crashloop', target: 'deployment/radar-netdiag/crashloop', type: 'exposes' }],
973
+ } as unknown as Topology
974
+
975
+ it('keeps two same-app members SIBLINGS under the app header despite an exposes edge', () => {
976
+ const events = [
977
+ changeEvent('Service', 'radar-netdiag', 'crashloop'),
978
+ changeEvent('Deployment', 'radar-netdiag', 'crashloop'),
979
+ ]
980
+ const appIndex = index({
981
+ 'Service/radar-netdiag/crashloop': crashloop,
982
+ 'Deployment/radar-netdiag/crashloop': crashloop,
983
+ })
984
+ const lanes = buildResourceHierarchy({ events, topology: exposesTopo, grouping: 'app', appIndex })
985
+
986
+ expect(lanes).toHaveLength(1)
987
+ expect(lanes[0].isAppGroup).toBe(true)
988
+ expect(lanes[0].name).toBe('crashloop')
989
+ const memberKinds = (lanes[0].children ?? []).map((c) => c.kind).sort()
990
+ expect(memberKinds).toEqual(['Deployment', 'Service'])
991
+ // Neither member nests under the other.
992
+ for (const m of lanes[0].children ?? []) {
993
+ expect((m.children ?? []).map((c) => c.kind)).not.toContain('Deployment')
994
+ }
995
+ })
996
+
997
+ it('still parents the Deployment under the Service when they belong to DIFFERENT apps', () => {
998
+ const other: AppMembership = { appKey: 'radar-netdiag/Service/other', appName: 'other' }
999
+ const events = [
1000
+ changeEvent('Service', 'radar-netdiag', 'crashloop'),
1001
+ changeEvent('Deployment', 'radar-netdiag', 'crashloop'),
1002
+ ]
1003
+ const appIndex = index({
1004
+ 'Service/radar-netdiag/crashloop': other,
1005
+ 'Deployment/radar-netdiag/crashloop': crashloop,
1006
+ })
1007
+ const lanes = buildResourceHierarchy({ events, topology: exposesTopo, grouping: 'app', appIndex })
1008
+
1009
+ const service = lanes.flatMap((l) => [l, ...(l.children ?? [])]).find((l) => l.kind === 'Service')
1010
+ expect((service?.children ?? []).some((c) => c.kind === 'Deployment')).toBe(true)
1011
+ })
1012
+
1013
+ it('keeps exposes parenting when no app index is supplied (owner grouping)', () => {
1014
+ const events = [
1015
+ changeEvent('Service', 'radar-netdiag', 'crashloop'),
1016
+ changeEvent('Deployment', 'radar-netdiag', 'crashloop'),
1017
+ ]
1018
+ const lanes = buildResourceHierarchy({ events, topology: exposesTopo, grouping: 'owner' })
1019
+ const service = lanes.find((l) => l.kind === 'Service')
1020
+ expect((service?.children ?? []).some((c) => c.kind === 'Deployment')).toBe(true)
1021
+ })
1022
+ })
1023
+
1024
+ describe('structural app members survive the window filter', () => {
1025
+ it('keeps a server-declared member visible with no events in the window', () => {
1026
+ const member: ResourceLane = {
1027
+ id: 'Service/radar-netdiag/crashloop', kind: 'Service', namespace: 'radar-netdiag',
1028
+ name: 'crashloop', events: [], isWorkload: false, structuralMember: true,
1029
+ }
1030
+ expect(isChildVisibleInWindow(member, 0, 1000, { pinned: false, userExpanded: false })).toBe(true)
1031
+ })
1032
+
1033
+ it('still filters an event-less lane that is NOT a structural member', () => {
1034
+ const stray: ResourceLane = {
1035
+ id: 'Pod/radar-netdiag/old-pod', kind: 'Pod', namespace: 'radar-netdiag',
1036
+ name: 'old-pod', events: [], isWorkload: false,
1037
+ }
1038
+ expect(isChildVisibleInWindow(stray, 0, 1000, { pinned: false, userExpanded: false })).toBe(false)
1039
+ })
1040
+ })