@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.
- package/package.json +7 -6
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +697 -0
- package/src/components/applications/ApplicationDetail.tsx +3269 -474
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +13 -6
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
- package/src/components/gitops/insights/insights-helpers.ts +74 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
- package/src/components/issues/IssuesView.tsx +91 -52
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
- package/src/components/issues/ResourceIssuesSection.tsx +46 -144
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +3 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +142 -111
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +375 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +149 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +25 -17
- package/src/components/topology/index.ts +2 -0
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/drawer-components.tsx +48 -32
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2706 -475
- package/src/components/workload/index.ts +1 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +2 -1
- package/src/types/core.ts +57 -4
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +201 -1
- package/src/utils/applications.ts +1182 -393
- package/src/utils/index.ts +3 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +1024 -1
- package/src/utils/resource-hierarchy.ts +925 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
|
@@ -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
|
-
/**
|
|
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,
|
|
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
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
const
|
|
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
|
|
196
|
-
if (!
|
|
197
|
-
const
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
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(
|
|
224
|
-
id
|
|
651
|
+
laneMap.set(id, {
|
|
652
|
+
id,
|
|
225
653
|
kind: event.kind,
|
|
226
|
-
group
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
271
|
-
|
|
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
|
|
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
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
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 =
|
|
306
|
-
const targetExists =
|
|
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
|
-
|
|
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 =
|
|
335
|
-
const targetKind =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
443
|
-
|
|
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
|
|
447
|
-
if (!
|
|
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(
|
|
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 [
|
|
473
|
-
if (laneParent.has(
|
|
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(
|
|
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(
|
|
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 = (
|
|
523
|
-
if (visited.has(
|
|
524
|
-
visited.add(
|
|
525
|
-
const parentId = laneParent.get(
|
|
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
|
|
895
|
+
return id
|
|
530
896
|
}
|
|
531
897
|
|
|
532
|
-
// Second pass:
|
|
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 [
|
|
537
|
-
if (!laneParent.has(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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 [
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1344
|
+
const walk = (lane: ResourceLane) => {
|
|
684
1345
|
count += lane.events.length
|
|
685
|
-
|
|
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
|
}
|