@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
@@ -13,7 +13,15 @@
13
13
 
14
14
  import type { TimelineEvent, Topology } from '../types/core'
15
15
  import { isWorkloadKind } from '../types/core'
16
- import { apiVersionToGroup } from './navigation'
16
+ import { apiVersionToGroup, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
17
+ import type { AppMembership, AppMembershipIndex } from './applications'
18
+
19
+ /** Timeline grouping mode (replaces the legacy groupByApp boolean).
20
+ * - app = owner/topology parenting + the app-membership cascade (default)
21
+ * - owner = owner/topology parenting only (honest name for groupByApp=false)
22
+ * - flat = no parenting at all; every resource its own lane. K8s-event→owner
23
+ * attachment still applies (that's identity, not grouping). */
24
+ export type TimelineGrouping = 'app' | 'owner' | 'flat'
17
25
 
18
26
  /**
19
27
  * Resource lane representing a single resource and its timeline events.
@@ -35,6 +43,38 @@ export interface ResourceLane {
35
43
  children?: ResourceLane[]
36
44
  childEventCount?: number
37
45
  allEventsSorted?: TimelineEvent[]
46
+ /** App-group header lane (grouping='app'): a synthetic parent whose children
47
+ * are the member root lanes of one server-defined application. */
48
+ isAppGroup?: boolean
49
+ /** Stable per-AppRow key the members share (the server row key). */
50
+ appKey?: string
51
+ /** App display name shown on the header (== name, kept explicit for clarity). */
52
+ title?: string
53
+ /** This app instance's env token (dev|staging|prod|…), for the header chip. */
54
+ env?: string
55
+ /** Human-readable grouping evidence, for the header title tooltip. */
56
+ evidence?: string
57
+ /** A pinned app-group whose live group lane is absent from the current view
58
+ * (owner/flat grouping, or the app vanished): a header-only quiet row
59
+ * synthesized from the pin record, dimmed with a "not present" tooltip. */
60
+ absentPinnedApp?: boolean
61
+ /** Joined its app-group via the name-prefix fallback (cascade tier 2.5) rather
62
+ * than owner/topology/label evidence — the lane's generated name matched a
63
+ * member workload's name. The UI annotates this weaker join. */
64
+ matchedByName?: boolean
65
+ /** Tier-1 app member: the server's app identity DECLARES this resource (a
66
+ * workload or a related Service/Ingress/Route). Structural members stay
67
+ * visible even with no events in the window — hiding the app's own Service
68
+ * makes a matched app read as incomplete. Evidence/name-matched lanes
69
+ * (deleted pods, generated children) keep the window filter. */
70
+ structuralMember?: boolean
71
+ /** Reparented under a PRESENT parent lane via a Kubernetes naming contract
72
+ * (CronJob→Job schedule stamp, controller→Pod generateName tail,
73
+ * Deployment→ReplicaSet pod-template-hash) because the child's in-window
74
+ * events shipped ownerless (deletion-time cache miss). Establishes PARENTAGE —
75
+ * stronger than a name-stem membership match, weaker than a real ownerRef. The
76
+ * UI annotates the child. */
77
+ nestedByContract?: boolean
38
78
  }
39
79
 
40
80
  /**
@@ -45,8 +85,292 @@ export interface HierarchyOptions {
45
85
  topology?: Topology
46
86
  /** If provided, returns only the hierarchy rooted at this resource */
47
87
  rootResource?: { kind: string; namespace: string; name: string }
48
- /** Whether to group by app.kubernetes.io/name or app label (default: true) */
88
+ /** Legacy toggle. Kept for back-compat (WorkloadView): true→'app', false→'owner'
89
+ * when `grouping` is omitted. Prefer `grouping`. */
49
90
  groupByApp?: boolean
91
+ /** Grouping mode. Defaults from groupByApp (true→'app', false→'owner'). */
92
+ grouping?: TimelineGrouping
93
+ /** Server app-membership index. When present and grouping='app', the
94
+ * membership cascade replaces the legacy label grouping (which stays as the
95
+ * fallback when this is absent). */
96
+ appIndex?: AppMembershipIndex
97
+ }
98
+
99
+ // Event-label keys scanned for app-membership evidence, in pkg/subject tier
100
+ // order (TierFluxHelmRelease 1 > TierArgoInstance 4 > TierInstance 6 >
101
+ // TierPartOf 7 > TierAppName 8 > TierBareApp 9 — pkg/subject/overlay.go). The
102
+ // prefix matches the server's matchKeys kinds (applications.go
103
+ // signalMatchKind), so an event's label value keys straight into
104
+ // AppMembershipIndex.byEvidence. Events carry labels (not annotations), so a
105
+ // native-Helm 'helm:' matchKey (meta.helm.sh/release-name, an annotation) is
106
+ // matchable only through the Flux HelmRelease label below.
107
+ const EVENT_EVIDENCE_LABELS: readonly (readonly [string, string])[] = [
108
+ ['helm', 'helm.toolkit.fluxcd.io/name'],
109
+ ['argo', 'argocd.argoproj.io/instance'],
110
+ ['instance', 'app.kubernetes.io/instance'],
111
+ ['part-of', 'app.kubernetes.io/part-of'],
112
+ ['name', 'app.kubernetes.io/name'],
113
+ ['app', 'app'],
114
+ ]
115
+
116
+ /** Distinct evidence keys ("kind:namespace:value") a lane's events carry,
117
+ * highest tier first — the cascade's tier-2 lookup keys. Keys are
118
+ * namespace-scoped with the EVENT's own namespace so a shared label value in
119
+ * two namespaces can't cross-join to the wrong app (matches the server's
120
+ * scoped matchKeys form). */
121
+ function evidenceKeysForLane(lane: ResourceLane): string[] {
122
+ const out: string[] = []
123
+ const seen = new Set<string>()
124
+ const events = lane.allEventsSorted ?? lane.events
125
+ for (const [prefix, labelKey] of EVENT_EVIDENCE_LABELS) {
126
+ for (const e of events) {
127
+ const value = e.labels?.[labelKey]
128
+ if (!value) continue
129
+ const key = `${prefix}:${e.namespace}:${value}`
130
+ if (seen.has(key)) continue
131
+ seen.add(key)
132
+ out.push(key)
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ interface RootGroupAssignment {
139
+ membership: AppMembership
140
+ /** Set when the join came from the name-prefix fallback (tier 2.5) — a weaker
141
+ * match the UI annotates. */
142
+ matchedByName?: boolean
143
+ /** Set for tier-1 joins — the server's app identity declares this resource. */
144
+ structural?: boolean
145
+ }
146
+
147
+ // Workload kinds whose names a generated-name lane can prefix-match against.
148
+ // These are the app members that spawn hash-suffixed children (a Deployment owns
149
+ // ReplicaSets owns Pods; a Job owns Pods; a CronJob owns Jobs).
150
+ const NAME_PREFIX_MEMBER_KINDS = new Set([
151
+ 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Rollout',
152
+ ])
153
+
154
+ // Lane kinds that carry K8s generated names (parent + hash suffix) and so are
155
+ // safe to prefix-match. A Service/ConfigMap named like a member is a real name
156
+ // collision, NOT a generated child — never prefix-match those.
157
+ const GENERATED_NAME_LANE_KINDS = new Set(['Pod', 'ReplicaSet', 'Job'])
158
+
159
+ // One hash-ish suffix segment: a ReplicaSet pod-template-hash, a Pod's 5-char
160
+ // random suffix, or a Job's timestamp/hash. 4-11 lowercase alphanumerics.
161
+ const NAME_SUFFIX_SEGMENT = /^[a-z0-9]{4,11}$/
162
+
163
+ /** True when `laneName` looks like a K8s generated child of `memberName`:
164
+ * `memberName` + '-' + a suffix of 1 or 2 hash-ish segments. Each segment must
165
+ * match NAME_SUFFIX_SEGMENT AND the suffix must contain at least one digit — the
166
+ * digit gate is what separates a real hash ("6dc59db657", "6jm4n") from a word
167
+ * variant ("canary", "stable"), which the plain length regex would otherwise
168
+ * accept. K8s generated names always carry a digit (pod-template-hash, job
169
+ * timestamps), so requiring one anywhere in the suffix is safe. */
170
+ function isGeneratedNameChildOf(laneName: string, memberName: string): boolean {
171
+ const prefix = memberName + '-'
172
+ if (!laneName.startsWith(prefix)) return false
173
+ const suffix = laneName.slice(prefix.length)
174
+ if (!suffix) return false
175
+ const segs = suffix.split('-')
176
+ if (segs.length < 1 || segs.length > 2) return false
177
+ if (!segs.every((s) => NAME_SUFFIX_SEGMENT.test(s))) return false
178
+ return /[0-9]/.test(suffix)
179
+ }
180
+
181
+ /** Match a generated-name lane against the app-membership index by prefixing its
182
+ * name with a known workload member's name (cascade tier 2.5). Catches lanes
183
+ * that fail every other tier — e.g. a Pod whose deletion-time event shipped with
184
+ * owner:null + labels:null (connector cache miss) but whose name still encodes
185
+ * its Deployment. Scoped to the SAME namespace; only workload-ish member kinds
186
+ * count. Longest member-name wins (so "web-admin" beats "web" for a
187
+ * web-admin-xyz pod); ties resolve to the first key in index order. */
188
+ export function matchLaneByMemberNamePrefix(
189
+ lane: { kind: string; namespace: string; name: string },
190
+ index: AppMembershipIndex,
191
+ ): AppMembership | null {
192
+ if (!GENERATED_NAME_LANE_KINDS.has(lane.kind)) return null
193
+ let best: AppMembership | null = null
194
+ let bestLen = -1
195
+ for (const [key, membership] of index.byResource) {
196
+ // key form: 'Kind/namespace/name'. Namespaces + names can't contain '/', so
197
+ // the first two slashes bound the kind and namespace segments.
198
+ const slash1 = key.indexOf('/')
199
+ if (slash1 < 0) continue
200
+ const slash2 = key.indexOf('/', slash1 + 1)
201
+ if (slash2 < 0) continue
202
+ const kind = key.slice(0, slash1)
203
+ if (!NAME_PREFIX_MEMBER_KINDS.has(kind)) continue
204
+ const ns = key.slice(slash1 + 1, slash2)
205
+ if (ns !== lane.namespace) continue
206
+ const memberName = key.slice(slash2 + 1)
207
+ if (!isGeneratedNameChildOf(lane.name, memberName)) continue
208
+ if (memberName.length > bestLen) {
209
+ best = membership
210
+ bestLen = memberName.length
211
+ }
212
+ }
213
+ return best
214
+ }
215
+
216
+ // Parent-driven naming contracts. Confidence ladder: ownerRef (fact) >
217
+ // kind-contract naming (strong) > name-stem (weak, membership only, never
218
+ // parentage). Kubernetes controllers name their children deterministically, so a
219
+ // PRESENT parent lane of the contract kind lets us re-derive an edge for a child
220
+ // whose only in-window events shipped ownerless (a deletion-time cache miss drops
221
+ // owner + labels). Each contract fixes the child kind, the acceptable parent
222
+ // kinds, and the single generated-name suffix segment K8s appends.
223
+ interface KindContract {
224
+ childKind: string
225
+ /** Candidate parent kinds, in match preference (a Pod's Job beats its RS). */
226
+ parentKinds: readonly string[]
227
+ /** The one trailing name segment this contract appends (no hyphens — the
228
+ * boundary hyphen separating it from the parent stem is the ONLY hyphen). */
229
+ suffix: RegExp
230
+ }
231
+ const KIND_CONTRACTS: readonly KindContract[] = [
232
+ // CronJob names a Job `<cronjob>-<schedule-stamp>`; the stamp is strictly numeric.
233
+ { childKind: 'Job', parentKinds: ['CronJob'], suffix: /^\d+$/ },
234
+ // A controller (Job/ReplicaSet) names a Pod `<parent>-<5-char generateName tail>`.
235
+ { childKind: 'Pod', parentKinds: ['Job', 'ReplicaSet'], suffix: /^[a-z0-9]{5}$/ },
236
+ // A Deployment names a ReplicaSet `<deploy>-<8-10 char pod-template-hash>`.
237
+ { childKind: 'ReplicaSet', parentKinds: ['Deployment'], suffix: /^[a-z0-9]{8,10}$/ },
238
+ ]
239
+
240
+ /** True when walking `from` up the parent chain reaches `target` — i.e. `from`
241
+ * sits inside `target`'s subtree. Guards contract nesting against cycles: never
242
+ * reparent a lane under one of its own descendants. */
243
+ function chainReaches(from: string, target: string, laneParent: Map<string, string>): boolean {
244
+ let cur: string | undefined = from
245
+ const seen = new Set<string>()
246
+ while (cur && !seen.has(cur)) {
247
+ if (cur === target) return true
248
+ seen.add(cur)
249
+ cur = laneParent.get(cur)
250
+ }
251
+ return false
252
+ }
253
+
254
+ /** The parent lane id for a still-at-root lane derivable from a Kubernetes naming
255
+ * contract, or null. Splits the name on its LAST hyphen: the suffix shapes forbid
256
+ * hyphens, so that's the only decomposition that can match and it yields the
257
+ * longest/exact parent stem. The parent lane must already exist, share the
258
+ * namespace, and be a contract-appropriate kind; a two-hyphen name resolves to
259
+ * the exact present parent (`a-b-123` → CronJob `a-b`, never `a`). */
260
+ function contractParentId(
261
+ lane: ResourceLane,
262
+ laneMap: Map<string, ResourceLane>,
263
+ laneParent: Map<string, string>,
264
+ laneIdByResourceKey: Map<string, string>,
265
+ ): string | null {
266
+ const contract = KIND_CONTRACTS.find((c) => c.childKind === lane.kind)
267
+ if (!contract) return null
268
+ const cut = lane.name.lastIndexOf('-')
269
+ if (cut <= 0 || cut >= lane.name.length - 1) return null
270
+ const parentName = lane.name.slice(0, cut)
271
+ const suffix = lane.name.slice(cut + 1)
272
+ if (!contract.suffix.test(suffix)) return null
273
+ for (const parentKind of contract.parentKinds) {
274
+ // Resolve the parent's canonical (possibly group-qualified) lane id via the
275
+ // group-less registry — contract parent kinds are built-in, but going through
276
+ // the registry keeps this correct regardless of id shape.
277
+ const parentId = laneIdByResourceKey.get(laneResourceKey(parentKind, lane.namespace, parentName))
278
+ if (!parentId || parentId === lane.id) continue
279
+ if (!laneMap.has(parentId)) continue
280
+ if (chainReaches(parentId, lane.id, laneParent)) continue
281
+ return parentId
282
+ }
283
+ return null
284
+ }
285
+
286
+ /** The app-membership cascade for one lane ROOT (post owner/topology parenting):
287
+ * 1. byResource — the root IS a known workload/satellite.
288
+ * 2. byEvidence — the root's events carry a label matching a known app (catches
289
+ * resources DELETED before the snapshot; the live app still owns the key).
290
+ * 2.5 name-prefix — a generated-name lane (Pod/ReplicaSet/Job) whose name
291
+ * prefixes a known member workload's name in the same namespace (catches
292
+ * deletion-time events shipped with owner:null + labels:null).
293
+ * fallback — null; the root renders on its own (owner grouping / ungrouped).
294
+ *
295
+ * The server is the ONLY authority on what an application is: grouping keys off
296
+ * server-declared apps, never client-synthesized groups from raw event labels
297
+ * (those would be a second grouping definition, producing meaningless singleton
298
+ * groups for any label-bearing resource). Deleted MEMBERS of a live app still
299
+ * group via tier 2 (the live app owns the matchKey); whole-deleted-app history
300
+ * falls back to owner grouping until hub-side membership snapshots (the
301
+ * retention-grade fix) exist. */
302
+ function cascadeRootMembership(lane: ResourceLane, appIndex: AppMembershipIndex): RootGroupAssignment | null {
303
+ // byResource is keyed group-less (AppRow workloads carry no group), so a
304
+ // CRD lane's group-qualified id must join via its group-less resource key.
305
+ const direct = appIndex.byResource.get(laneResourceKey(lane.kind, lane.namespace, lane.name))
306
+ if (direct) return { membership: direct, structural: true }
307
+ for (const key of evidenceKeysForLane(lane)) {
308
+ const m = appIndex.byEvidence.get(key)
309
+ if (m) return { membership: m }
310
+ }
311
+ const byName = matchLaneByMemberNamePrefix(lane, appIndex)
312
+ if (byName) return { membership: byName, matchedByName: true }
313
+ return null
314
+ }
315
+
316
+ /** Build an app-group header lane over its member root lanes. The members keep
317
+ * their own sub-hierarchy; the header aggregates their events for the collapsed
318
+ * track and the interestingness sort. */
319
+ function makeAppGroupLane(m: AppMembership, members: ResourceLane[]): ResourceLane {
320
+ const allEvents = members.flatMap((c) => c.allEventsSorted ?? c.events)
321
+ const uniqueEvents = Array.from(new Map(allEvents.map((e) => [e.id, e])).values())
322
+ const nss = new Set(members.map((c) => c.namespace).filter(Boolean))
323
+ return {
324
+ id: `app:${m.appKey}`,
325
+ kind: 'AppGroup',
326
+ namespace: nss.size === 1 ? [...nss][0] : '',
327
+ name: m.appName,
328
+ events: [],
329
+ isWorkload: false,
330
+ isAppGroup: true,
331
+ appKey: m.appKey,
332
+ title: m.appName,
333
+ env: m.env,
334
+ evidence: m.evidence,
335
+ children: members,
336
+ childEventCount: members.reduce((n, c) => n + (c.allEventsSorted?.length ?? c.events.length), 0),
337
+ allEventsSorted: sortEventsForRendering(uniqueEvents),
338
+ }
339
+ }
340
+
341
+ /** Wrap top-level root lanes into app-group header lanes via the cascade. A group
342
+ * needs ≥2 member roots to earn a header (a single-resource app renders as its
343
+ * plain lane, same as today); headers emit at their first member's position so
344
+ * the caller's interestingness sort still governs placement. */
345
+ function applyAppGrouping(topLevelLanes: ResourceLane[], appIndex: AppMembershipIndex): ResourceLane[] {
346
+ const assignment = new Map<string, RootGroupAssignment>()
347
+ const membersByApp = new Map<string, ResourceLane[]>()
348
+ for (const lane of topLevelLanes) {
349
+ const g = cascadeRootMembership(lane, appIndex)
350
+ if (!g) continue
351
+ // Mark the lane so the UI can annotate the weaker name-prefix join. Only
352
+ // surfaced once the lane actually lands in a ≥2-member group (below).
353
+ if (g.matchedByName) lane.matchedByName = true
354
+ if (g.structural) lane.structuralMember = true
355
+ assignment.set(lane.id, g)
356
+ const arr = membersByApp.get(g.membership.appKey) ?? []
357
+ arr.push(lane)
358
+ membersByApp.set(g.membership.appKey, arr)
359
+ }
360
+ const out: ResourceLane[] = []
361
+ const emitted = new Set<string>()
362
+ for (const lane of topLevelLanes) {
363
+ const g = assignment.get(lane.id)
364
+ const members = g ? membersByApp.get(g.membership.appKey) : undefined
365
+ if (!g || !members || members.length < 2) {
366
+ out.push(lane)
367
+ continue
368
+ }
369
+ if (emitted.has(g.membership.appKey)) continue
370
+ emitted.add(g.membership.appKey)
371
+ out.push(makeAppGroupLane(g.membership, members))
372
+ }
373
+ return out
50
374
  }
51
375
 
52
376
  /**
@@ -182,48 +506,152 @@ function sortEventsForRendering(events: TimelineEvent[]): TimelineEvent[] {
182
506
  * @param options - Configuration options
183
507
  * @returns Array of top-level resource lanes with nested children
184
508
  */
509
+ // Child ordering within a lane: representative kinds first (Service/Ingress),
510
+ // then workloads, then their generated children (ReplicaSet/Pod), config last.
511
+ // Applied at EVERY depth so a Job's Pods sort the same way a root's children do.
512
+ const CHILD_SORT_KIND_PRIORITY: Record<string, number> = {
513
+ Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
514
+ Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
515
+ Job: 3, CronJob: 3,
516
+ ReplicaSet: 3, Pod: 4, ConfigMap: 5, Secret: 5,
517
+ KnativeService: 1, KnativeRoute: 2, Broker: 1, Channel: 1,
518
+ KnativeConfiguration: 2, KnativeRevision: 3, Trigger: 2,
519
+ PingSource: 3, ApiServerSource: 3, ContainerSource: 3, SinkBinding: 3,
520
+ IngressRoute: 1, IngressRouteTCP: 1, IngressRouteUDP: 1,
521
+ TraefikService: 2, Middleware: 3, MiddlewareTCP: 3,
522
+ HTTPProxy: 1, // Contour
523
+ }
524
+
525
+ /** Sort a lane's children by kind priority then most-recent-event, recursing into
526
+ * every descendant so a multi-level chain (CronJob → Job → Pod) is ordered at
527
+ * each depth, not just the top level. */
528
+ function sortLaneChildrenDeep(lane: ResourceLane): void {
529
+ const children = lane.children
530
+ if (!children || children.length === 0) return
531
+ // Precompute each child's latest event time once — otherwise the comparator
532
+ // reparses every child's timestamps on every comparison.
533
+ const latestByChildId = new Map<string, number>()
534
+ for (const c of children) {
535
+ let latest = 0
536
+ for (const e of c.events) {
537
+ const t = new Date(e.timestamp).getTime()
538
+ if (t > latest) latest = t
539
+ }
540
+ latestByChildId.set(c.id, latest)
541
+ }
542
+ children.sort((a, b) => {
543
+ const aPriority = CHILD_SORT_KIND_PRIORITY[a.kind] || 10
544
+ const bPriority = CHILD_SORT_KIND_PRIORITY[b.kind] || 10
545
+ if (aPriority !== bPriority) return aPriority - bPriority
546
+ return (latestByChildId.get(b.id) ?? 0) - (latestByChildId.get(a.id) ?? 0)
547
+ })
548
+ for (const c of children) sortLaneChildrenDeep(c)
549
+ }
550
+
185
551
  export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[] {
186
- const { events, topology, rootResource, groupByApp = true } = options
552
+ const { events, topology, rootResource, appIndex } = options
553
+ // grouping defaults from the legacy boolean: true (or unset) → 'app', false → 'owner'.
554
+ const grouping: TimelineGrouping = options.grouping ?? (options.groupByApp === false ? 'owner' : 'app')
187
555
  const laneMap = new Map<string, ResourceLane>()
188
556
 
189
- // API group lookup by lane ID (kind/namespace/name) sourced from topology nodes.
190
- // Lanes built from events (which carry apiVersion) take precedence over this fallback,
191
- // but parent lanes that exist only as edge endpoints still need a group.
192
- const topoGroupByLaneId = new Map<string, string>()
557
+ // API group lookup by group-less resource key (Kind/ns/name) sourced from
558
+ // topology nodes — the fallback group for lanes/refs whose events don't carry
559
+ // apiVersion (owner refs and topology endpoints never do).
560
+ const topoGroupByKey = new Map<string, string>()
193
561
  if (topology?.nodes) {
194
562
  for (const node of topology.nodes) {
195
- const laneId = nodeIdToLaneId(node.id)
196
- if (!laneId) continue
197
- const apiVersion = node.data?.apiVersion as string | undefined
198
- const group = apiVersionToGroup(apiVersion)
199
- if (group) topoGroupByLaneId.set(laneId, group)
563
+ const key = nodeIdToLaneId(node.id)
564
+ if (!key) continue
565
+ const group = apiVersionToGroup(node.data?.apiVersion as string | undefined)
566
+ if (group) topoGroupByKey.set(key, group)
200
567
  }
201
568
  }
202
- const resolveGroup = (laneId: string, event?: TimelineEvent): string => {
203
- const fromEvent = apiVersionToGroup(event?.apiVersion)
204
- if (fromEvent) return fromEvent
205
- return topoGroupByLaneId.get(laneId) ?? ''
569
+ // First apiVersion-derived group seen per resource key — the fallback used to
570
+ // attribute a same-resource event that shipped WITHOUT apiVersion (a stored
571
+ // event) onto the group-qualified lane its apiVersion-carrying siblings built,
572
+ // instead of forking a bare duplicate. It does NOT collapse a genuine
573
+ // collision: colliding events differ in apiVersion, so each still qualifies to
574
+ // its own group; only truly group-less events fall back here.
575
+ const groupByKey = new Map<string, string>()
576
+ for (const event of events) {
577
+ if (event.kind === 'Event' && event.owner) continue
578
+ const g = apiVersionToGroup(event.apiVersion)
579
+ if (g) {
580
+ const rk = laneResourceKey(event.kind, event.namespace, event.name)
581
+ if (!groupByKey.has(rk)) groupByKey.set(rk, g)
582
+ }
583
+ }
584
+ // The group for a resource key when the reference itself carries none.
585
+ const fallbackGroup = (rk: string): string => groupByKey.get(rk) ?? topoGroupByKey.get(rk) ?? ''
586
+
587
+ // Group-less resource key → canonical (possibly group-qualified) lane id. Lets
588
+ // group-less references (owner refs, K8s-event involvedObject, topology node
589
+ // ids — none carry a group) reconcile onto the real lane instead of forking a
590
+ // bare duplicate. First writer wins: when two CRDs collide on kind+ns+name the
591
+ // map holds one, and a group-agnostic owner/topology match resolving to it is
592
+ // the accepted residual (see the confidence-ladder note above).
593
+ const laneIdByKey = new Map<string, string>()
594
+ const registerLaneKey = (rk: string, id: string): void => {
595
+ if (!laneIdByKey.has(rk)) laneIdByKey.set(rk, id)
596
+ }
597
+ // Get-or-create the lane for a group-less reference, returning its canonical id.
598
+ const ensureRefLane = (rk: string, seedEvent?: TimelineEvent): string => {
599
+ const existing = laneIdByKey.get(rk)
600
+ if (existing) {
601
+ if (seedEvent) laneMap.get(existing)!.events.push(seedEvent)
602
+ return existing
603
+ }
604
+ const p = parseLaneId(rk)!
605
+ const group = fallbackGroup(rk)
606
+ const id = laneId(p.kind, group, p.namespace, p.name)
607
+ laneMap.set(id, {
608
+ id,
609
+ kind: p.kind,
610
+ group,
611
+ namespace: p.namespace,
612
+ name: p.name,
613
+ events: seedEvent ? [seedEvent] : [],
614
+ isWorkload: isWorkloadKind(p.kind),
615
+ children: [],
616
+ childEventCount: 0,
617
+ })
618
+ registerLaneKey(rk, id)
619
+ return id
620
+ }
621
+ // The canonical id a group-less key resolves to, WITHOUT creating a lane —
622
+ // agrees with ensureRefLane so guards keyed by canonical id line up.
623
+ const resolveId = (rk: string): string => {
624
+ const existing = laneIdByKey.get(rk)
625
+ if (existing) return existing
626
+ const p = parseLaneId(rk)!
627
+ return laneId(p.kind, fallbackGroup(rk), p.namespace, p.name)
206
628
  }
207
629
 
208
630
  // Track events that should be attached to their owner instead of their own lane
209
- const eventsToAttach: { event: TimelineEvent; ownerLaneId: string }[] = []
631
+ const eventsToAttach: { event: TimelineEvent; ownerKey: string }[] = []
210
632
 
211
633
  // First pass: create lanes from events (but not for Events with owners)
212
634
  for (const event of events) {
213
635
  // K8s Events with an owner (involvedObject) should attach to that resource, not get their own lane
214
636
  if (event.kind === 'Event' && event.owner) {
215
- const ownerLaneId = `${event.owner.kind}/${event.namespace}/${event.owner.name}`
216
- eventsToAttach.push({ event, ownerLaneId })
637
+ eventsToAttach.push({ event, ownerKey: laneResourceKey(event.owner.kind, event.namespace, event.owner.name) })
217
638
  continue
218
639
  }
219
640
 
220
- const laneId = `${event.kind}/${event.namespace}/${event.name}`
221
- const existing = laneMap.get(laneId)
641
+ const rk = laneResourceKey(event.kind, event.namespace, event.name)
642
+ // Per-event group qualifies the id, so two same-kind CRDs from different
643
+ // vendors (CAPI vs CNPG `Cluster`) never merge. A group-less event reconciles
644
+ // onto whatever lane already exists for the resource (or a bare one).
645
+ const group = apiVersionToGroup(event.apiVersion) || fallbackGroup(rk)
646
+ const id = groupQualifiesLaneId(group)
647
+ ? laneId(event.kind, group, event.namespace, event.name)
648
+ : (laneIdByKey.get(rk) ?? laneResourceKey(event.kind, event.namespace, event.name))
649
+ const existing = laneMap.get(id)
222
650
  if (!existing) {
223
- laneMap.set(laneId, {
224
- id: laneId,
651
+ laneMap.set(id, {
652
+ id,
225
653
  kind: event.kind,
226
- group: resolveGroup(laneId, event),
654
+ group,
227
655
  namespace: event.namespace,
228
656
  name: event.name,
229
657
  events: [event],
@@ -231,224 +659,144 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
231
659
  children: [],
232
660
  childEventCount: 0,
233
661
  })
662
+ registerLaneKey(rk, id)
234
663
  } else {
235
- // Stored events may lack apiVersion; upgrade the lane's group from any
236
- // later event that carries it.
237
- if (!existing.group) {
238
- const fromEvent = apiVersionToGroup(event.apiVersion)
239
- if (fromEvent) existing.group = fromEvent
240
- }
664
+ if (!existing.group && group) existing.group = group
241
665
  existing.events.push(event)
242
666
  }
243
667
  }
244
668
 
245
- // Attach K8s Events to their owner lanes
246
- for (const { event, ownerLaneId } of eventsToAttach) {
247
- if (laneMap.has(ownerLaneId)) {
248
- // Owner exists, attach event to it
249
- laneMap.get(ownerLaneId)!.events.push(event)
250
- } else {
251
- // Owner doesn't exist yet, create lane for it
252
- const parts = ownerLaneId.split('/')
253
- laneMap.set(ownerLaneId, {
254
- id: ownerLaneId,
255
- kind: parts[0],
256
- group: resolveGroup(ownerLaneId),
257
- namespace: parts[1],
258
- name: parts.slice(2).join('/'),
259
- events: [event],
260
- isWorkload: isWorkloadKind(parts[0]),
261
- children: [],
262
- childEventCount: 0,
263
- })
264
- }
669
+ // Attach K8s Events to their owner lanes (group-agnostic — the involvedObject
670
+ // ref carries no group, so it resolves onto the first lane for that key).
671
+ for (const { event, ownerKey } of eventsToAttach) {
672
+ ensureRefLane(ownerKey, event)
265
673
  }
266
674
 
267
675
  // Build parent map from BOTH owner references AND topology edges
268
676
  const laneParent = new Map<string, string>() // childLaneId -> parentLaneId
269
677
 
270
- // Source 1: Owner references from events (most reliable for Deployment→RS→Pod)
271
- for (const [laneId, lane] of laneMap) {
678
+ // Flat mode skips ALL parenting (sources 1-3): every resource is its own lane.
679
+ // The K8s-event→owner attachment above still applies — that's identity, not
680
+ // grouping, so a Pod event stays on its Pod's lane regardless.
681
+ if (grouping !== 'flat') {
682
+
683
+ // Source 1: Owner references from events (most reliable for Deployment→RS→Pod).
684
+ // Owner refs carry no group, so parenting is group-agnostic: the child resolves
685
+ // onto the FIRST lane registered for the owner's key. The residual (same
686
+ // kind+name+ns colliding across groups within an ownership match) is accepted —
687
+ // see the confidence-ladder note above the KIND_CONTRACTS block.
688
+ for (const [id, lane] of laneMap) {
272
689
  const eventWithOwner = lane.events.find(e => e.owner)
273
690
  if (eventWithOwner?.owner) {
274
- const ownerLaneId = `${eventWithOwner.owner.kind}/${lane.namespace}/${eventWithOwner.owner.name}`
275
-
276
- // Create parent lane if it doesn't exist (parent may have no events)
277
- if (!laneMap.has(ownerLaneId)) {
278
- laneMap.set(ownerLaneId, {
279
- id: ownerLaneId,
280
- kind: eventWithOwner.owner.kind,
281
- group: resolveGroup(ownerLaneId),
282
- namespace: lane.namespace,
283
- name: eventWithOwner.owner.name,
284
- events: [],
285
- isWorkload: isWorkloadKind(eventWithOwner.owner.kind),
286
- children: [],
287
- childEventCount: 0,
288
- })
289
- }
290
- laneParent.set(laneId, ownerLaneId)
691
+ const ownerId = ensureRefLane(laneResourceKey(eventWithOwner.owner.kind, lane.namespace, eventWithOwner.owner.name))
692
+ laneParent.set(id, ownerId)
291
693
  }
292
694
  }
293
695
 
294
- // Source 2: Topology edges (for Service→Deployment, Ingress→Service, ConfigMap→Deployment)
696
+ // Source 2: Topology edges (for Service→Deployment, Ingress→Service, ConfigMap→Deployment).
697
+ // Node ids are group-less keys; endpoints resolve onto their canonical lanes via
698
+ // the registry (parenting a child under a possibly group-qualified parent), and a
699
+ // missing endpoint is materialized with its topology-known group.
295
700
  if (topology?.edges) {
296
701
  for (const edge of topology.edges) {
297
- const sourceLaneId = nodeIdToLaneId(edge.source)
298
- const targetLaneId = nodeIdToLaneId(edge.target)
299
- if (!sourceLaneId || !targetLaneId) continue
702
+ const sourceKey = nodeIdToLaneId(edge.source)
703
+ const targetKey = nodeIdToLaneId(edge.target)
704
+ if (!sourceKey || !targetKey) continue
300
705
 
301
706
  // manages: Deployment→RS→Pod (already covered by owner refs, skip)
302
707
  if (edge.type === 'manages') continue
303
708
 
304
709
  // At least one side must have events
305
- const sourceExists = laneMap.has(sourceLaneId)
306
- const targetExists = laneMap.has(targetLaneId)
710
+ const sourceExists = laneIdByKey.has(sourceKey)
711
+ const targetExists = laneIdByKey.has(targetKey)
307
712
  if (!sourceExists && !targetExists) continue
308
713
 
714
+ // App membership outranks topology attachment: two members of the SAME
715
+ // application (its Service and its Deployment) are siblings under the
716
+ // app's group header. Parenting one member under the other would swallow
717
+ // a root, drop the group below the 2-member header threshold, and front
718
+ // the whole stack with the attachment (a health-less Service) instead of
719
+ // the app. Only the byResource tier applies — both endpoints here are
720
+ // concrete resources the server either claims or doesn't.
721
+ const sameAppMembers = (aKey: string, bKey: string): boolean => {
722
+ if (grouping !== 'app' || !appIndex) return false
723
+ const toResourceKey = (key: string): string | null => {
724
+ const parsed = parseLaneId(key)
725
+ return parsed ? laneResourceKey(parsed.kind, parsed.namespace, parsed.name) : null
726
+ }
727
+ const aRes = toResourceKey(aKey)
728
+ const a = aRes ? appIndex.byResource.get(aRes) : undefined
729
+ if (!a) return false
730
+ const bRes = toResourceKey(bKey)
731
+ const b = bRes ? appIndex.byResource.get(bRes) : undefined
732
+ return b != null && a.appKey === b.appKey
733
+ }
734
+
735
+ // Parent `child` under `parent`, materializing `parent`'s lane if absent.
736
+ // `childHasEvents` mirrors the old guard (only parent an endpoint that
737
+ // actually exists). Guards read canonical ids so laneParent lines up.
738
+ const link = (childKey: string, parentKey: string, childHasEvents: boolean): void => {
739
+ if (!childHasEvents) return
740
+ if (sameAppMembers(childKey, parentKey)) return
741
+ const childId = resolveId(childKey)
742
+ if (laneParent.has(childId)) return
743
+ laneParent.set(childId, ensureRefLane(parentKey))
744
+ }
745
+
309
746
  // exposes: Service→Deployment (Service is parent of Deployment)
310
747
  if (edge.type === 'exposes') {
311
- if (!laneParent.has(targetLaneId) && targetExists) {
312
- if (!sourceExists) {
313
- const parts = sourceLaneId.split('/')
314
- laneMap.set(sourceLaneId, {
315
- id: sourceLaneId,
316
- kind: parts[0],
317
- group: resolveGroup(sourceLaneId),
318
- namespace: parts[1],
319
- name: parts.slice(2).join('/'),
320
- events: [],
321
- isWorkload: isWorkloadKind(parts[0]),
322
- children: [],
323
- childEventCount: 0,
324
- })
325
- }
326
- laneParent.set(targetLaneId, sourceLaneId)
327
- }
748
+ link(targetKey, sourceKey, targetExists)
328
749
  }
329
750
 
330
751
  // routes-to has two cases:
331
752
  // 1. Ingress→Service: Service should be parent (representative)
332
753
  // 2. Service→Pod/PodGroup: Service should be parent (normal hierarchy)
333
754
  if (edge.type === 'routes-to') {
334
- const sourceKind = sourceLaneId.split('/')[0]
335
- const targetKind = targetLaneId.split('/')[0]
755
+ const sourceKind = sourceKey.split('/')[0]
756
+ const targetKind = targetKey.split('/')[0]
336
757
 
337
758
  // Gateway→Route: Gateway is parent of Route
338
759
  if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute')) {
339
- if (!laneParent.has(targetLaneId) && targetExists) {
340
- if (!sourceExists) {
341
- const parts = sourceLaneId.split('/')
342
- laneMap.set(sourceLaneId, {
343
- id: sourceLaneId,
344
- kind: parts[0],
345
- group: resolveGroup(sourceLaneId),
346
- namespace: parts[1],
347
- name: parts.slice(2).join('/'),
348
- events: [],
349
- isWorkload: isWorkloadKind(parts[0]),
350
- children: [],
351
- childEventCount: 0,
352
- })
353
- }
354
- laneParent.set(targetLaneId, sourceLaneId)
355
- }
760
+ link(targetKey, sourceKey, targetExists)
356
761
  }
357
762
  // Route→Service: reverse (Service is representative, like Ingress)
358
763
  else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute') && targetKind === 'Service') {
359
- if (!laneParent.has(sourceLaneId) && sourceExists) {
360
- if (!targetExists) {
361
- const parts = targetLaneId.split('/')
362
- laneMap.set(targetLaneId, {
363
- id: targetLaneId,
364
- kind: parts[0],
365
- group: resolveGroup(targetLaneId),
366
- namespace: parts[1],
367
- name: parts.slice(2).join('/'),
368
- events: [],
369
- isWorkload: isWorkloadKind(parts[0]),
370
- children: [],
371
- childEventCount: 0,
372
- })
373
- }
374
- laneParent.set(sourceLaneId, targetLaneId)
375
- }
764
+ link(sourceKey, targetKey, sourceExists)
376
765
  }
377
766
  // Ingress→Service: reverse relationship (Service is representative)
378
767
  else if (sourceKind === 'Ingress' && targetKind === 'Service') {
379
- if (!laneParent.has(sourceLaneId) && sourceExists) {
380
- if (!targetExists) {
381
- const parts = targetLaneId.split('/')
382
- laneMap.set(targetLaneId, {
383
- id: targetLaneId,
384
- kind: parts[0],
385
- group: resolveGroup(targetLaneId),
386
- namespace: parts[1],
387
- name: parts.slice(2).join('/'),
388
- events: [],
389
- isWorkload: isWorkloadKind(parts[0]),
390
- children: [],
391
- childEventCount: 0,
392
- })
393
- }
394
- laneParent.set(sourceLaneId, targetLaneId)
395
- }
768
+ link(sourceKey, targetKey, sourceExists)
396
769
  }
397
770
  // Service→Pod/PodGroup: normal direction (Service is parent)
398
771
  else if (sourceKind === 'Service') {
399
- if (!laneParent.has(targetLaneId) && targetExists) {
400
- if (!sourceExists) {
401
- const parts = sourceLaneId.split('/')
402
- laneMap.set(sourceLaneId, {
403
- id: sourceLaneId,
404
- kind: parts[0],
405
- group: resolveGroup(sourceLaneId),
406
- namespace: parts[1],
407
- name: parts.slice(2).join('/'),
408
- events: [],
409
- isWorkload: isWorkloadKind(parts[0]),
410
- children: [],
411
- childEventCount: 0,
412
- })
413
- }
414
- laneParent.set(targetLaneId, sourceLaneId)
415
- }
772
+ link(targetKey, sourceKey, targetExists)
416
773
  }
417
774
  }
418
775
 
419
776
  // configures/uses/protects: ConfigMap→Deployment, HPA→Deployment, PDB→Deployment (target is parent)
420
777
  if (edge.type === 'configures' || edge.type === 'uses' || edge.type === 'protects') {
421
- if (!laneParent.has(sourceLaneId) && sourceExists) {
422
- if (!targetExists) {
423
- const parts = targetLaneId.split('/')
424
- laneMap.set(targetLaneId, {
425
- id: targetLaneId,
426
- kind: parts[0],
427
- group: resolveGroup(targetLaneId),
428
- namespace: parts[1],
429
- name: parts.slice(2).join('/'),
430
- events: [],
431
- isWorkload: isWorkloadKind(parts[0]),
432
- children: [],
433
- childEventCount: 0,
434
- })
435
- }
436
- laneParent.set(sourceLaneId, targetLaneId)
437
- }
778
+ link(sourceKey, targetKey, sourceExists)
438
779
  }
439
780
  }
440
781
  }
441
782
 
442
- // Source 3: App label grouping (optional)
443
- if (groupByApp && topology?.nodes) {
783
+ } // end grouping !== 'flat' (sources 1 & 2)
784
+
785
+ // Source 3 (legacy fallback): raw app-label grouping. Runs only in 'app' mode
786
+ // WITHOUT a server app-membership index — when the index is present the
787
+ // membership cascade below replaces this. Kept so WorkloadView and index-less
788
+ // callers keep today's behavior.
789
+ if (grouping === 'app' && !appIndex && topology?.nodes) {
790
+ // Keyed by group-less resource key (the topology node id form), so a CRD
791
+ // lane's group-qualified id still matches via its key.
444
792
  const laneAppLabels = new Map<string, string>()
445
793
  for (const node of topology.nodes) {
446
- const laneId = nodeIdToLaneId(node.id)
447
- if (!laneId) continue
794
+ const key = nodeIdToLaneId(node.id)
795
+ if (!key) continue
448
796
  const labels = node.data?.labels as Record<string, string> | undefined
449
797
  const appLabel = labels?.['app.kubernetes.io/name'] || labels?.['app']
450
798
  if (appLabel) {
451
- laneAppLabels.set(laneId, appLabel)
799
+ laneAppLabels.set(key, appLabel)
452
800
  }
453
801
  }
454
802
 
@@ -469,16 +817,16 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
469
817
  // namespaces (e.g. the same workload deployed to dev and staging) is two
470
818
  // distinct apps and must not collapse into one lane.
471
819
  const appGroups = new Map<string, string[]>()
472
- for (const [laneId, lane] of laneMap) {
473
- if (laneParent.has(laneId)) continue
820
+ for (const [id, lane] of laneMap) {
821
+ if (laneParent.has(id)) continue
474
822
  if (!appLabelEligibleKinds.has(lane.kind)) continue
475
- const appLabel = laneAppLabels.get(laneId)
823
+ const appLabel = laneAppLabels.get(laneResourceKey(lane.kind, lane.namespace, lane.name))
476
824
  if (!appLabel) continue
477
825
  const groupKey = `${lane.namespace}/${appLabel}`
478
826
  if (!appGroups.has(groupKey)) {
479
827
  appGroups.set(groupKey, [])
480
828
  }
481
- appGroups.get(groupKey)!.push(laneId)
829
+ appGroups.get(groupKey)!.push(id)
482
830
  }
483
831
 
484
832
  // For each app group with multiple members, pick the best parent
@@ -489,7 +837,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
489
837
  Service: 1, Ingress: 2, Gateway: 2,
490
838
  HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
491
839
  Deployment: 3, Rollout: 3, StatefulSet: 3, DaemonSet: 3,
492
- Job: 4, CronJob: 4,
840
+ Job: 4, CronJob: 4, Workflow: 4, CronWorkflow: 4,
493
841
  ConfigMap: 5, Secret: 5,
494
842
  ReplicaSet: 6, Pod: 7,
495
843
  KnativeService: 1, KnativeRoute: 2, Broker: 2, Channel: 2,
@@ -518,83 +866,81 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
518
866
  }
519
867
  }
520
868
 
869
+ // Source 1.5: parent-driven naming contracts. A lane still at root whose only
870
+ // in-window events were ownerless can still nest under a PRESENT parent lane
871
+ // when its generated name encodes the parent (see KIND_CONTRACTS) — e.g. an old
872
+ // CronJob run whose deletion event lost its owner ref. Runs after every real
873
+ // edge source (owner/topology/label) so a genuine parent always wins; skipped
874
+ // in flat mode (no parenting at all). A nested-by-contract lane leaves the root
875
+ // set, so it rides its chain root into app membership instead of joining flat
876
+ // via the tier-2.5 name-prefix fallback (which only catches surviving roots).
877
+ if (grouping !== 'flat') {
878
+ for (const [id, lane] of laneMap) {
879
+ if (laneParent.has(id)) continue
880
+ const parentId = contractParentId(lane, laneMap, laneParent, laneIdByKey)
881
+ if (!parentId) continue
882
+ laneParent.set(id, parentId)
883
+ lane.nestedByContract = true
884
+ }
885
+ }
886
+
521
887
  // Walk up parent chain to find root
522
- const findRoot = (laneId: string, visited = new Set<string>()): string => {
523
- if (visited.has(laneId)) return laneId
524
- visited.add(laneId)
525
- const parentId = laneParent.get(laneId)
888
+ const findRoot = (id: string, visited = new Set<string>()): string => {
889
+ if (visited.has(id)) return id
890
+ visited.add(id)
891
+ const parentId = laneParent.get(id)
526
892
  if (parentId && laneMap.has(parentId)) {
527
893
  return findRoot(parentId, visited)
528
894
  }
529
- return laneId
895
+ return id
530
896
  }
531
897
 
532
- // Second pass: group children under their root
898
+ // Second pass: attach each lane to its DIRECT owner so multi-level chains
899
+ // (CronJob → Job → Pod) preserve every ownership level. Attaching to the
900
+ // chain root instead — the prior bug — flattened grandchildren (Pods) into
901
+ // siblings of their parent (Jobs), losing one level.
533
902
  const topLevelLanes: ResourceLane[] = []
534
903
  const childLaneIds = new Set<string>()
535
904
 
536
- for (const [laneId] of laneMap) {
537
- if (!laneParent.has(laneId)) continue
538
- const rootId = findRoot(laneId)
539
- if (rootId !== laneId && laneMap.has(rootId)) {
540
- const root = laneMap.get(rootId)!
541
- const child = laneMap.get(laneId)!
542
- root.children!.push(child)
543
- root.childEventCount = (root.childEventCount || 0) + child.events.length
544
- childLaneIds.add(laneId)
545
- }
905
+ for (const [id] of laneMap) {
906
+ if (!laneParent.has(id)) continue
907
+ // findRoot walks the chain AND breaks cycles: a self-returning root means
908
+ // id sits on a parent cycle — leave it top-level rather than nest it.
909
+ const rootId = findRoot(id)
910
+ if (rootId === id || !laneMap.has(rootId)) continue
911
+ const parentId = laneParent.get(id)!
912
+ const parent = laneMap.get(parentId)
913
+ if (!parent) continue
914
+ parent.children!.push(laneMap.get(id)!)
915
+ parent.childEventCount = (parent.childEventCount || 0) + laneMap.get(id)!.events.length
916
+ childLaneIds.add(id)
546
917
  }
547
918
 
548
919
  // Collect top-level lanes (not children of anyone)
549
- for (const [laneId, lane] of laneMap) {
550
- if (!childLaneIds.has(laneId)) {
551
- // Sort children by kind priority then by latest event
552
- if (lane.children && lane.children.length > 0) {
553
- const kindPriority: Record<string, number> = {
554
- Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
555
- Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
556
- Job: 3, CronJob: 3,
557
- ReplicaSet: 3, Pod: 4, ConfigMap: 5, Secret: 5,
558
- KnativeService: 1, KnativeRoute: 2, Broker: 1, Channel: 1,
559
- KnativeConfiguration: 2, KnativeRevision: 3, Trigger: 2,
560
- PingSource: 3, ApiServerSource: 3, ContainerSource: 3, SinkBinding: 3,
561
- IngressRoute: 1, IngressRouteTCP: 1, IngressRouteUDP: 1,
562
- TraefikService: 2, Middleware: 3, MiddlewareTCP: 3,
563
- HTTPProxy: 1, // Contour
564
- }
565
- // Precompute each child's latest event time once — otherwise the
566
- // comparator below reparses every child's event timestamps on every
567
- // comparison (O(children log children × events) Date parses).
568
- const latestByChildId = new Map<string, number>()
569
- for (const c of lane.children) {
570
- let latest = 0
571
- for (const e of c.events) {
572
- const t = new Date(e.timestamp).getTime()
573
- if (t > latest) latest = t
574
- }
575
- latestByChildId.set(c.id, latest)
576
- }
577
- lane.children.sort((a, b) => {
578
- const aPriority = kindPriority[a.kind] || 10
579
- const bPriority = kindPriority[b.kind] || 10
580
- if (aPriority !== bPriority) return aPriority - bPriority
581
- return (latestByChildId.get(b.id) ?? 0) - (latestByChildId.get(a.id) ?? 0)
582
- })
583
- }
584
-
585
- // Pre-compute sorted events (own + children) for efficient rendering
586
- // Deduplicate by event ID
587
- const allEvents = [...lane.events, ...(lane.children?.flatMap(c => c.events) || [])]
588
- const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values())
589
- lane.allEventsSorted = sortEventsForRendering(uniqueEvents)
920
+ for (const [id, lane] of laneMap) {
921
+ if (childLaneIds.has(id)) continue
922
+ // Sort children by kind priority then latest event, recursively (every depth).
923
+ sortLaneChildrenDeep(lane)
924
+ // Pre-compute the collapsed roll-up: own events + EVERY descendant's, deduped
925
+ // and render-sorted. subtreeEvents walks the full tree, so a two-level chain's
926
+ // grandchildren (Pods under Jobs) stay in the parent's aggregate.
927
+ lane.allEventsSorted = subtreeEvents(lane)
928
+ topLevelLanes.push(lane)
929
+ }
590
930
 
591
- topLevelLanes.push(lane)
592
- }
931
+ // App-membership cascade — wrap root lanes into app-group header lanes.
932
+ // Only for the main timeline (rootResource views want the resource's own
933
+ // subtree, not an app roll-up).
934
+ if (grouping === 'app' && appIndex && !rootResource) {
935
+ return applyAppGrouping(topLevelLanes, appIndex)
593
936
  }
594
937
 
595
938
  // If rootResource is specified, filter to only include lanes related to that resource
596
939
  if (rootResource) {
597
- const rootLaneId = `${rootResource.kind}/${rootResource.namespace}/${rootResource.name}`
940
+ // Resolve to the canonical (possibly group-qualified) id so a CRD root matches
941
+ // the lanes built from its events. rootResource carries no group; resolveId
942
+ // uses the registry (its own events) or the topology/event group fallback.
943
+ const rootLaneId = resolveId(laneResourceKey(rootResource.kind, rootResource.namespace, rootResource.name))
598
944
  const rootLane = topLevelLanes.find(l => l.id === rootLaneId)
599
945
 
600
946
  if (rootLane) {
@@ -626,10 +972,9 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
626
972
  return [rootAncestor]
627
973
  }
628
974
  }
629
- // No parent found, return as a standalone lane
630
- const allEvents = [...childLane.events, ...(childLane.children?.flatMap(c => c.events) || [])]
631
- const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values())
632
- childLane.allEventsSorted = sortEventsForRendering(uniqueEvents)
975
+ // No parent found, return as a standalone lane. Full-subtree roll-up so a
976
+ // nested chain's grandchildren stay in the aggregate.
977
+ childLane.allEventsSorted = subtreeEvents(childLane)
633
978
  return [childLane]
634
979
  }
635
980
 
@@ -638,7 +983,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
638
983
  const placeholderLane: ResourceLane = {
639
984
  id: rootLaneId,
640
985
  kind: rootResource.kind,
641
- group: resolveGroup(rootLaneId),
986
+ group: parseLaneId(rootLaneId)?.group || '',
642
987
  namespace: rootResource.namespace,
643
988
  name: rootResource.name,
644
989
  events: [],
@@ -653,20 +998,144 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
653
998
  return topLevelLanes
654
999
  }
655
1000
 
1001
+ // -----------------------------------------------------------------------------
1002
+ // Collision chip — the ONLY place a lane's API group surfaces visually. Two
1003
+ // visible lanes that share kind+namespace+name necessarily differ in API group
1004
+ // (else they'd share an id and be one lane), i.e. a genuine same-kind cross-group
1005
+ // collision (CAPI vs CNPG `Cluster`). Both such lanes render a small group chip
1006
+ // so the user can tell them apart; every other lane shows nothing.
1007
+ // -----------------------------------------------------------------------------
1008
+
1009
+ /** The group-less collision key for a lane — kind + namespace + name. The
1010
+ * pipe delimiter can't appear in any component (DNS-1123 names, alphanumeric
1011
+ * kinds) and keeps this file greppable — a NUL delimiter makes text tooling
1012
+ * classify the whole file as binary. */
1013
+ export function laneCollisionKey(lane: Pick<ResourceLane, 'kind' | 'namespace' | 'name'>): string {
1014
+ return `${lane.kind}|${lane.namespace}|${lane.name}`
1015
+ }
1016
+
1017
+ /** Collision keys (see laneCollisionKey) that appear on 2+ lanes in the given
1018
+ * lane set (roots + every descendant). A lane whose key is in this set shares
1019
+ * kind+ns+name with another visible lane of a different API group, so both
1020
+ * should render the disambiguating group chip. Pure + SSR-safe: no DOM, no
1021
+ * globals. Synthetic app-group headers (no real resource) are skipped. */
1022
+ export function collidingLaneKeys(lanes: ResourceLane[]): Set<string> {
1023
+ const count = new Map<string, number>()
1024
+ const walk = (l: ResourceLane): void => {
1025
+ if (!l.isAppGroup) {
1026
+ const k = laneCollisionKey(l)
1027
+ count.set(k, (count.get(k) ?? 0) + 1)
1028
+ }
1029
+ for (const c of l.children ?? []) walk(c)
1030
+ }
1031
+ for (const l of lanes) walk(l)
1032
+ const out = new Set<string>()
1033
+ for (const [k, n] of count) if (n > 1) out.add(k)
1034
+ return out
1035
+ }
1036
+
1037
+ /** Own events + every descendant's events, deduped and render-sorted. The
1038
+ * roll-up a COLLAPSED parent row paints. Roots and app-group members already
1039
+ * carry `allEventsSorted`; a nested parent (a Job owning Pods surfaced as an
1040
+ * app-group member's child) does not, so this recomputes it on demand. */
1041
+ export function subtreeEvents(lane: ResourceLane): TimelineEvent[] {
1042
+ const acc: TimelineEvent[] = []
1043
+ const walk = (l: ResourceLane): void => {
1044
+ acc.push(...l.events)
1045
+ for (const c of l.children ?? []) walk(c)
1046
+ }
1047
+ walk(lane)
1048
+ const unique = Array.from(new Map(acc.map((e) => [e.id, e])).values())
1049
+ return sortEventsForRendering(unique)
1050
+ }
1051
+
1052
+ /** Return every event attributable to one selected application. The caller
1053
+ * supplies an index containing only that app, so singleton apps are retained
1054
+ * even though the global Timeline intentionally avoids one-member app groups. */
1055
+ export function eventsForApplication(
1056
+ events: TimelineEvent[],
1057
+ topology: Topology | undefined,
1058
+ appIndex: AppMembershipIndex,
1059
+ ): TimelineEvent[] {
1060
+ const roots = buildResourceHierarchy({ events, topology, grouping: 'owner' })
1061
+ const matched = roots.filter((lane) => cascadeRootMembership(lane, appIndex) !== null)
1062
+ const unique = new Map<string, TimelineEvent>()
1063
+ for (const lane of matched) {
1064
+ for (const event of subtreeEvents(lane)) unique.set(event.id, event)
1065
+ }
1066
+ return [...unique.values()].sort((a, b) => {
1067
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1068
+ return byTime || (b.seq ?? 0) - (a.seq ?? 0) || a.id.localeCompare(b.id)
1069
+ })
1070
+ }
1071
+
1072
+ /** The events a lane paints on its OWN track row, given whether it's expanded.
1073
+ * - collapsed parent → whole-subtree aggregate (the roll-up)
1074
+ * - expanded parent, or a leaf → only the lane's own events
1075
+ * This is the locked attribution rule: an expanded parent yields just its own
1076
+ * slice while its children render their own slices below it, so no event ever
1077
+ * appears on two visible rows, and no resource that owns events in the window
1078
+ * renders an empty track. Holds at every depth and in every grouping mode. */
1079
+ export function laneTrackEvents(lane: ResourceLane, expanded: boolean): TimelineEvent[] {
1080
+ const hasChildren = !!lane.children?.length
1081
+ if (hasChildren && !expanded) return lane.allEventsSorted ?? subtreeEvents(lane)
1082
+ return lane.events
1083
+ }
1084
+
1085
+ /** True when the lane's SUBTREE (own events + every descendant's) has at least one
1086
+ * event within [startMs, endMs] inclusive. The window-visibility predicate an
1087
+ * EXPANDED parent applies to each child: render only children that actually move
1088
+ * in the visible lens — the same rule top-level lanes follow. Uses the
1089
+ * precomputed allEventsSorted roll-up when present (roots + app-group members
1090
+ * carry it), else short-circuits a subtree walk. */
1091
+ export function laneHasEventInWindow(lane: ResourceLane, startMs: number, endMs: number): boolean {
1092
+ const inRange = (e: TimelineEvent): boolean => {
1093
+ const t = new Date(e.timestamp).getTime()
1094
+ return t >= startMs && t <= endMs
1095
+ }
1096
+ if (lane.allEventsSorted) return lane.allEventsSorted.some(inRange)
1097
+ const walk = (l: ResourceLane): boolean => {
1098
+ if (l.events.some(inRange)) return true
1099
+ for (const c of l.children ?? []) if (walk(c)) return true
1100
+ return false
1101
+ }
1102
+ return walk(lane)
1103
+ }
1104
+
1105
+ /** Whether an EXPANDED parent should render `child`, given the visible window and
1106
+ * the per-child exemptions. Renders when the child's subtree moves in-window, OR
1107
+ * it's pinned (its row is its home), OR the user deliberately expanded it (never
1108
+ * yank a row they opened; an auto-expanded child is NOT exempt). Pure so the
1109
+ * swimlane and its tests share one rule. */
1110
+ export function isChildVisibleInWindow(
1111
+ child: ResourceLane,
1112
+ startMs: number,
1113
+ endMs: number,
1114
+ exempt: { pinned: boolean; userExpanded: boolean },
1115
+ ): boolean {
1116
+ if (exempt.pinned || exempt.userExpanded) return true
1117
+ // A structural app member (server-declared workload/Service/Ingress) stays
1118
+ // visible with an empty window — hiding the app's own Service makes a
1119
+ // matched app read as incomplete. Generated/evidence-matched lanes keep the
1120
+ // noise filter.
1121
+ if (child.structuralMember) return true
1122
+ return laneHasEventInWindow(child, startMs, endMs)
1123
+ }
1124
+
656
1125
  /**
657
1126
  * Get all events from a hierarchy, flattened and sorted by timestamp (newest first).
658
1127
  */
659
1128
  export function getAllEventsFromHierarchy(lanes: ResourceLane[]): TimelineEvent[] {
660
1129
  const allEvents: TimelineEvent[] = []
661
1130
 
662
- for (const lane of lanes) {
1131
+ // Recurse the whole tree — a Deployment's Pod events hang two levels down
1132
+ // (Deployment → ReplicaSet → Pod); a one-level walk drops them, so the list
1133
+ // undercounts the events the swimlane (built from the full tree) shows.
1134
+ const collect = (lane: ResourceLane) => {
663
1135
  allEvents.push(...lane.events)
664
- if (lane.children) {
665
- for (const child of lane.children) {
666
- allEvents.push(...child.events)
667
- }
668
- }
1136
+ lane.children?.forEach(collect)
669
1137
  }
1138
+ lanes.forEach(collect)
670
1139
 
671
1140
  // Deduplicate by event ID
672
1141
  const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values())
@@ -676,17 +1145,206 @@ export function getAllEventsFromHierarchy(lanes: ResourceLane[]): TimelineEvent[
676
1145
  }
677
1146
 
678
1147
  /**
679
- * Count total events in a hierarchy.
1148
+ * Persisted pin record for a single resource. Carries enough to render the lane
1149
+ * label (kind badge + name + namespace) with zero event data, so a pinned
1150
+ * resource that's been deleted or gone quiet still renders as an empty track.
1151
+ * `type` is optional for back-compat: records persisted before app-group pins
1152
+ * existed carry no discriminant and default to 'resource'.
1153
+ */
1154
+ export interface PinnedResourceRef {
1155
+ type?: 'resource'
1156
+ /** "Kind/namespace/name" — the same id form buildResourceHierarchy emits. */
1157
+ id: string
1158
+ kind: string
1159
+ namespace: string
1160
+ name: string
1161
+ }
1162
+
1163
+ /**
1164
+ * Persisted pin record for an APP-GROUP header. The group's members re-resolve
1165
+ * live each render by matching `appKey` against the current lanes, so a group
1166
+ * pin survives grouping-mode switches and app churn; when no matching group lane
1167
+ * exists a quiet header is synthesized from `appName`.
1168
+ */
1169
+ export interface PinnedAppGroupRef {
1170
+ type: 'appGroup'
1171
+ /** "app:<appKey>" — the same id form makeAppGroupLane emits. */
1172
+ id: string
1173
+ appKey: string
1174
+ appName: string
1175
+ }
1176
+
1177
+ export type PinnedLaneRef = PinnedResourceRef | PinnedAppGroupRef
1178
+
1179
+ /** True when `value` is a well-formed pin record (either shape). Records without
1180
+ * a `type` are treated as legacy resource refs (back-compat with localStorage
1181
+ * entries written before app-group pins existed). */
1182
+ export function isPinnedLaneRef(value: unknown): value is PinnedLaneRef {
1183
+ if (!value || typeof value !== 'object') return false
1184
+ const v = value as Record<string, unknown>
1185
+ if (typeof v.id !== 'string') return false
1186
+ if (v.type === 'appGroup') {
1187
+ return typeof v.appKey === 'string' && typeof v.appName === 'string'
1188
+ }
1189
+ return typeof v.kind === 'string' && typeof v.namespace === 'string' && typeof v.name === 'string'
1190
+ }
1191
+
1192
+ /** Ensure a lane carries a merged allEventsSorted (own + descendants). Roots and
1193
+ * app-group members already have it; a plain child (leaf) may only have events. */
1194
+ function withAllEventsSorted(lane: ResourceLane): ResourceLane {
1195
+ if (lane.allEventsSorted) return { ...lane }
1196
+ const allEvents = [...lane.events, ...(lane.children?.flatMap((c) => c.events) ?? [])]
1197
+ const uniqueEvents = Array.from(new Map(allEvents.map((e) => [e.id, e])).values())
1198
+ return { ...lane, allEventsSorted: sortEventsForRendering(uniqueEvents) }
1199
+ }
1200
+
1201
+ function synthesizeEmptyPinnedLane(ref: PinnedResourceRef): ResourceLane {
1202
+ return {
1203
+ id: ref.id,
1204
+ kind: ref.kind,
1205
+ // Recover the group from the id so an absent CRD pin still carries it (the
1206
+ // pin record itself stores none). Bare/built-in ids yield ''.
1207
+ group: parseLaneId(ref.id)?.group || '',
1208
+ namespace: ref.namespace,
1209
+ name: ref.name,
1210
+ events: [],
1211
+ isWorkload: isWorkloadKind(ref.kind),
1212
+ children: [],
1213
+ childEventCount: 0,
1214
+ allEventsSorted: [],
1215
+ }
1216
+ }
1217
+
1218
+ /** A pinned app-group whose live group lane isn't in the current view (owner/flat
1219
+ * grouping, or the app vanished): a header-only quiet row from the pin record.
1220
+ * Honest by construction — owner/flat modes never build the group lane, so the
1221
+ * dimmed "not present" header is the correct fallback (the pin still survives
1222
+ * the mode switch). */
1223
+ function synthesizeQuietAppGroupLane(ref: PinnedAppGroupRef): ResourceLane {
1224
+ return {
1225
+ id: ref.id,
1226
+ kind: 'AppGroup',
1227
+ namespace: '',
1228
+ name: ref.appName,
1229
+ events: [],
1230
+ isWorkload: false,
1231
+ isAppGroup: true,
1232
+ appKey: ref.appKey,
1233
+ title: ref.appName,
1234
+ absentPinnedApp: true,
1235
+ children: [],
1236
+ childEventCount: 0,
1237
+ allEventsSorted: [],
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Remove pinned lanes from the regular list — pin MOVES a row to the pinned
1243
+ * section rather than copying it, so a resource never appears twice. Pinned
1244
+ * children are pruned from their parents; a group/parent left with no children
1245
+ * and no events of its own is dropped entirely.
1246
+ */
1247
+ export function removePinnedLanes(
1248
+ allLanes: ResourceLane[],
1249
+ pinnedIds: Set<string>,
1250
+ pinnedAppKeys?: Set<string>,
1251
+ ): ResourceLane[] {
1252
+ if (pinnedIds.size === 0 && !pinnedAppKeys?.size) return allLanes
1253
+ const prune = (lane: ResourceLane): ResourceLane | null => {
1254
+ if (pinnedIds.has(lane.id)) return null
1255
+ // App-group roots also match by appKey — a group's id is deterministic, but
1256
+ // matching the key is robust to any id-shape drift.
1257
+ if (lane.isAppGroup && lane.appKey && pinnedAppKeys?.has(lane.appKey)) return null
1258
+ if (!lane.children?.length) return lane
1259
+ const children = lane.children.map(prune).filter((c): c is ResourceLane => c !== null)
1260
+ if (children.length === lane.children.length) return lane
1261
+ if (children.length === 0 && lane.events.length === 0) return null
1262
+ // A pinned child was pruned, so the parent's roll-up fields (allEventsSorted +
1263
+ // childEventCount) were built with the moved child included. Rebuild them from
1264
+ // the pruned subtree; otherwise a COLLAPSED parent's laneTrackEvents prefers the
1265
+ // stale allEventsSorted and double-paints the pinned child's events (which now
1266
+ // also render on the pinned row).
1267
+ const pruned = { ...lane, children }
1268
+ return {
1269
+ ...pruned,
1270
+ allEventsSorted: subtreeEvents(pruned),
1271
+ childEventCount: children.reduce((n, c) => n + (c.allEventsSorted?.length ?? c.events.length), 0),
1272
+ }
1273
+ }
1274
+ return allLanes.map(prune).filter((l): l is ResourceLane => l !== null)
1275
+ }
1276
+
1277
+ /**
1278
+ * Resolve pinned refs into stationary top-level lanes, in pin order.
1279
+ *
1280
+ * Each ref is looked up among roots, their children, and (for app-group headers)
1281
+ * the members' own children — first match wins. A match is cloned into its own
1282
+ * top-level lane (carrying its events + descendants' events via the existing
1283
+ * merge); `removePinnedLanes` then drops the original from the regular list, so
1284
+ * the net effect is a move — the resource shows once, in the pinned section. A ref
1285
+ * with no match anywhere (deleted or filtered out of the loaded selection) is
1286
+ * synthesized from the record as an empty lane so the row still renders. Pure: no
1287
+ * mutation of the input lanes.
1288
+ */
1289
+ export function extractPinnedLanes(allLanes: ResourceLane[], pinnedRefs: PinnedLaneRef[]): ResourceLane[] {
1290
+ if (pinnedRefs.length === 0) return []
1291
+ const byId = new Map<string, ResourceLane>()
1292
+ // App-group headers re-resolve by appKey (live members), independent of the
1293
+ // deterministic "app:<appKey>" id, so a group survives grouping-mode churn.
1294
+ const groupByAppKey = new Map<string, ResourceLane>()
1295
+ const indexLane = (lane: ResourceLane): void => {
1296
+ if (!byId.has(lane.id)) byId.set(lane.id, lane)
1297
+ if (lane.isAppGroup && lane.appKey && !groupByAppKey.has(lane.appKey)) {
1298
+ groupByAppKey.set(lane.appKey, lane)
1299
+ }
1300
+ for (const child of lane.children ?? []) indexLane(child)
1301
+ }
1302
+ for (const lane of allLanes) indexLane(lane)
1303
+
1304
+ // A resource can be pinned on its own AND be a live member of a separately
1305
+ // pinned app group. Emitting both renders the resource twice — once inside the
1306
+ // group's roll-up, once as its own row — and double-counts its events. Dedupe
1307
+ // in the group's favor: collect every lane id under any pinned group's live
1308
+ // membership and skip a resource ref that falls inside one. Resolved by group
1309
+ // membership, not pin order, so pinning the member first or the app first
1310
+ // yields the same single occurrence (the app-group wins either way). Among
1311
+ // refs of the same kind, the first pin still wins (byId/appKey index above).
1312
+ const coveredByPinnedGroup = new Set<string>()
1313
+ const collectMemberIds = (lane: ResourceLane): void => {
1314
+ coveredByPinnedGroup.add(lane.id)
1315
+ for (const child of lane.children ?? []) collectMemberIds(child)
1316
+ }
1317
+ for (const ref of pinnedRefs) {
1318
+ if (ref.type !== 'appGroup') continue
1319
+ const group = groupByAppKey.get(ref.appKey)
1320
+ if (!group) continue
1321
+ for (const child of group.children ?? []) collectMemberIds(child)
1322
+ }
1323
+
1324
+ const out: ResourceLane[] = []
1325
+ for (const ref of pinnedRefs) {
1326
+ if (ref.type === 'appGroup') {
1327
+ const found = groupByAppKey.get(ref.appKey)
1328
+ out.push(found ? withAllEventsSorted(found) : synthesizeQuietAppGroupLane(ref))
1329
+ continue
1330
+ }
1331
+ if (coveredByPinnedGroup.has(ref.id)) continue // folded into a pinned app group
1332
+ const found = byId.get(ref.id)
1333
+ out.push(found ? withAllEventsSorted(found) : synthesizeEmptyPinnedLane(ref))
1334
+ }
1335
+ return out
1336
+ }
1337
+
1338
+ /**
1339
+ * Count total events in a hierarchy (full depth — grandchild Pod events under a
1340
+ * Deployment→ReplicaSet→Pod chain count too, matching getAllEventsFromHierarchy).
680
1341
  */
681
1342
  export function countEventsInHierarchy(lanes: ResourceLane[]): number {
682
1343
  let count = 0
683
- for (const lane of lanes) {
1344
+ const walk = (lane: ResourceLane) => {
684
1345
  count += lane.events.length
685
- if (lane.children) {
686
- for (const child of lane.children) {
687
- count += child.events.length
688
- }
689
- }
1346
+ lane.children?.forEach(walk)
690
1347
  }
1348
+ lanes.forEach(walk)
691
1349
  return count
692
1350
  }