@skyhook-io/k8s-ui 1.14.9 → 1.14.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- package/src/utils/topology-neighborhood.ts +40 -18
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import type { TimelineEvent, Topology } from '../types/core'
|
|
15
15
|
import { isWorkloadKind } from '../types/core'
|
|
16
|
+
import { canonicalResourceGroup } from './api-resources'
|
|
16
17
|
import { apiVersionToGroup, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
|
|
17
18
|
import type { AppMembership, AppMembershipIndex } from './applications'
|
|
19
|
+
import { topologyNodeResourceKind } from './topology-neighborhood'
|
|
18
20
|
|
|
19
21
|
/** Timeline grouping mode (replaces the legacy groupByApp boolean).
|
|
20
22
|
* - app = owner/topology parenting + the app-membership cascade (default)
|
|
@@ -36,6 +38,10 @@ export interface ResourceLane {
|
|
|
36
38
|
* (e.g. CAPI Cluster vs CNPG Cluster) when the lane is clicked.
|
|
37
39
|
*/
|
|
38
40
|
group?: string
|
|
41
|
+
/** True when group identity came from apiVersion/topology or an unambiguous
|
|
42
|
+
* live identity, including the explicit core group (`''`). */
|
|
43
|
+
identityResolved?: boolean
|
|
44
|
+
identityAmbiguous?: boolean
|
|
39
45
|
namespace: string
|
|
40
46
|
name: string
|
|
41
47
|
events: TimelineEvent[]
|
|
@@ -84,7 +90,7 @@ export interface HierarchyOptions {
|
|
|
84
90
|
events: TimelineEvent[]
|
|
85
91
|
topology?: Topology
|
|
86
92
|
/** If provided, returns only the hierarchy rooted at this resource */
|
|
87
|
-
rootResource?: { kind: string; namespace: string; name: string }
|
|
93
|
+
rootResource?: { kind: string; group?: string; namespace: string; name: string }
|
|
88
94
|
/** Legacy toggle. Kept for back-compat (WorkloadView): true→'app', false→'owner'
|
|
89
95
|
* when `grouping` is omitted. Prefer `grouping`. */
|
|
90
96
|
groupByApp?: boolean
|
|
@@ -184,7 +190,7 @@ function isGeneratedNameChildOf(laneName: string, memberName: string): boolean {
|
|
|
184
190
|
* owner:null + labels:null (connector cache miss) but whose name still encodes
|
|
185
191
|
* its Deployment. Scoped to the SAME namespace; only workload-ish member kinds
|
|
186
192
|
* count. Longest member-name wins (so "web-admin" beats "web" for a
|
|
187
|
-
* web-admin-xyz pod);
|
|
193
|
+
* web-admin-xyz pod); an equal-length cross-app tie fails closed. */
|
|
188
194
|
export function matchLaneByMemberNamePrefix(
|
|
189
195
|
lane: { kind: string; namespace: string; name: string },
|
|
190
196
|
index: AppMembershipIndex,
|
|
@@ -192,25 +198,21 @@ export function matchLaneByMemberNamePrefix(
|
|
|
192
198
|
if (!GENERATED_NAME_LANE_KINDS.has(lane.kind)) return null
|
|
193
199
|
let best: AppMembership | null = null
|
|
194
200
|
let bestLen = -1
|
|
201
|
+
let ambiguous = false
|
|
195
202
|
for (const [key, membership] of index.byResource) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
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) {
|
|
203
|
+
const member = parseLaneId(key)
|
|
204
|
+
if (!member || !NAME_PREFIX_MEMBER_KINDS.has(member.kind)) continue
|
|
205
|
+
if (member.namespace !== lane.namespace) continue
|
|
206
|
+
if (!isGeneratedNameChildOf(lane.name, member.name)) continue
|
|
207
|
+
if (member.name.length > bestLen) {
|
|
209
208
|
best = membership
|
|
210
|
-
bestLen =
|
|
209
|
+
bestLen = member.name.length
|
|
210
|
+
ambiguous = false
|
|
211
|
+
} else if (member.name.length === bestLen && best && membership.appKey !== best.appKey) {
|
|
212
|
+
ambiguous = true
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
|
-
return best
|
|
215
|
+
return ambiguous ? null : best
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
// Parent-driven naming contracts. Confidence ladder: ownerRef (fact) >
|
|
@@ -261,7 +263,6 @@ function contractParentId(
|
|
|
261
263
|
lane: ResourceLane,
|
|
262
264
|
laneMap: Map<string, ResourceLane>,
|
|
263
265
|
laneParent: Map<string, string>,
|
|
264
|
-
laneIdByResourceKey: Map<string, string>,
|
|
265
266
|
): string | null {
|
|
266
267
|
const contract = KIND_CONTRACTS.find((c) => c.childKind === lane.kind)
|
|
267
268
|
if (!contract) return null
|
|
@@ -271,11 +272,11 @@ function contractParentId(
|
|
|
271
272
|
const suffix = lane.name.slice(cut + 1)
|
|
272
273
|
if (!contract.suffix.test(suffix)) return null
|
|
273
274
|
for (const parentKind of contract.parentKinds) {
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
const parentId =
|
|
278
|
-
if (
|
|
275
|
+
// The contracts are built-in controllers' naming, so only the built-in
|
|
276
|
+
// parent's bare lane id qualifies; a same-named CRD lane (Volcano's Job) must
|
|
277
|
+
// never win through a group-less lookup.
|
|
278
|
+
const parentId = laneResourceKey(parentKind, lane.namespace, parentName)
|
|
279
|
+
if (parentId === lane.id) continue
|
|
279
280
|
if (!laneMap.has(parentId)) continue
|
|
280
281
|
if (chainReaches(parentId, lane.id, laneParent)) continue
|
|
281
282
|
return parentId
|
|
@@ -300,9 +301,12 @@ function contractParentId(
|
|
|
300
301
|
* falls back to owner grouping until hub-side membership snapshots (the
|
|
301
302
|
* retention-grade fix) exist. */
|
|
302
303
|
function cascadeRootMembership(lane: ResourceLane, appIndex: AppMembershipIndex): RootGroupAssignment | null {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const direct =
|
|
304
|
+
if (lane.identityAmbiguous) return null
|
|
305
|
+
const unqualified = laneResourceKey(lane.kind, lane.namespace, lane.name)
|
|
306
|
+
const direct = lane.identityResolved
|
|
307
|
+
? appIndex.byResource.get(lane.id)
|
|
308
|
+
: appIndex.byUnqualifiedResource?.get(unqualified)
|
|
309
|
+
?? (!appIndex.byUnqualifiedResource ? appIndex.byResource.get(lane.id) : undefined)
|
|
306
310
|
if (direct) return { membership: direct, structural: true }
|
|
307
311
|
for (const key of evidenceKeysForLane(lane)) {
|
|
308
312
|
const m = appIndex.byEvidence.get(key)
|
|
@@ -421,63 +425,10 @@ export function isProblematicEvent(event: TimelineEvent): boolean {
|
|
|
421
425
|
return false
|
|
422
426
|
}
|
|
423
427
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
*/
|
|
429
|
-
function nodeIdToLaneId(nodeId: string): string | null {
|
|
430
|
-
const parts = nodeId.split('/')
|
|
431
|
-
if (parts.length < 3) return null
|
|
432
|
-
const kind = parts[0]
|
|
433
|
-
const namespace = parts[1]
|
|
434
|
-
const name = parts[2]
|
|
435
|
-
// Maps lowercase topology node IDs to PascalCase kind names used in timeline lane IDs.
|
|
436
|
-
const kindMap: Record<string, string> = {
|
|
437
|
-
pod: 'Pod', service: 'Service', deployment: 'Deployment',
|
|
438
|
-
replicaset: 'ReplicaSet', statefulset: 'StatefulSet', daemonset: 'DaemonSet',
|
|
439
|
-
ingress: 'Ingress', gateway: 'Gateway', httproute: 'HTTPRoute',
|
|
440
|
-
grpcroute: 'GRPCRoute', tcproute: 'TCPRoute', tlsroute: 'TLSRoute',
|
|
441
|
-
configmap: 'ConfigMap', secret: 'Secret',
|
|
442
|
-
persistentvolumeclaim: 'PersistentVolumeClaim',
|
|
443
|
-
persistentvolume: 'PersistentVolume', storageclass: 'StorageClass',
|
|
444
|
-
job: 'Job', cronjob: 'CronJob',
|
|
445
|
-
horizontalpodautoscaler: 'HorizontalPodAutoscaler',
|
|
446
|
-
verticalpodautoscaler: 'VerticalPodAutoscaler',
|
|
447
|
-
poddisruptionbudget: 'PodDisruptionBudget',
|
|
448
|
-
podgroup: 'PodGroup', rollout: 'Rollout', namespace: 'Namespace',
|
|
449
|
-
node: 'Node',
|
|
450
|
-
application: 'Application', applicationset: 'ApplicationSet', appproject: 'AppProject',
|
|
451
|
-
kustomization: 'Kustomization',
|
|
452
|
-
helmrelease: 'HelmRelease', helmrepository: 'HelmRepository',
|
|
453
|
-
helmchart: 'HelmChart', gitrepository: 'GitRepository',
|
|
454
|
-
ocirepository: 'OCIRepository', certificate: 'Certificate',
|
|
455
|
-
// Istio
|
|
456
|
-
virtualservice: 'VirtualService', destinationrule: 'DestinationRule',
|
|
457
|
-
istiogateway: 'IstioGateway', serviceentry: 'ServiceEntry',
|
|
458
|
-
peerauthentication: 'PeerAuthentication', authorizationpolicy: 'AuthorizationPolicy',
|
|
459
|
-
// KEDA
|
|
460
|
-
scaledobject: 'ScaledObject', scaledjob: 'ScaledJob',
|
|
461
|
-
// Karpenter
|
|
462
|
-
nodepool: 'NodePool', nodeclaim: 'NodeClaim',
|
|
463
|
-
// cert-manager
|
|
464
|
-
issuer: 'Issuer', clusterissuer: 'ClusterIssuer',
|
|
465
|
-
// Knative
|
|
466
|
-
knativeservice: 'KnativeService', knativeconfiguration: 'KnativeConfiguration',
|
|
467
|
-
knativerevision: 'KnativeRevision', knativeroute: 'KnativeRoute',
|
|
468
|
-
broker: 'Broker', trigger: 'Trigger', channel: 'Channel',
|
|
469
|
-
pingsource: 'PingSource', apiserversource: 'ApiServerSource',
|
|
470
|
-
containersource: 'ContainerSource', sinkbinding: 'SinkBinding',
|
|
471
|
-
// Traefik
|
|
472
|
-
ingressroute: 'IngressRoute', ingressroutetcp: 'IngressRouteTCP',
|
|
473
|
-
ingressrouteudp: 'IngressRouteUDP', middleware: 'Middleware',
|
|
474
|
-
middlewaretcp: 'MiddlewareTCP', traefikservice: 'TraefikService',
|
|
475
|
-
serverstransport: 'ServersTransport', serverstransporttcp: 'ServersTransportTCP',
|
|
476
|
-
tlsoption: 'TLSOption', tlsstore: 'TLSStore',
|
|
477
|
-
// Contour
|
|
478
|
-
httpproxy: 'HTTPProxy',
|
|
479
|
-
}
|
|
480
|
-
return `${kindMap[kind] || kind}/${namespace}/${name}`
|
|
428
|
+
function topologyNodeLaneId(node: Topology['nodes'][number]): string {
|
|
429
|
+
const namespace = typeof node.data?.namespace === 'string' ? node.data.namespace : ''
|
|
430
|
+
const apiVersion = typeof node.data?.apiVersion === 'string' ? node.data.apiVersion : ''
|
|
431
|
+
return laneId(topologyNodeResourceKind(node), apiVersionToGroup(apiVersion), namespace, node.name)
|
|
481
432
|
}
|
|
482
433
|
|
|
483
434
|
/**
|
|
@@ -554,49 +505,68 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
554
505
|
const grouping: TimelineGrouping = options.grouping ?? (options.groupByApp === false ? 'owner' : 'app')
|
|
555
506
|
const laneMap = new Map<string, ResourceLane>()
|
|
556
507
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
508
|
+
const topologyLaneIdByNodeId = new Map<string, string>()
|
|
509
|
+
const identityGroupsByResourceKey = new Map<string, Set<string>>()
|
|
510
|
+
for (const node of topology?.nodes ?? []) {
|
|
511
|
+
const id = topologyNodeLaneId(node)
|
|
512
|
+
topologyLaneIdByNodeId.set(node.id, id)
|
|
513
|
+
const parsed = parseLaneId(id)!
|
|
514
|
+
const rk = laneResourceKey(parsed.kind, parsed.namespace, parsed.name)
|
|
515
|
+
const groups = identityGroupsByResourceKey.get(rk) ?? new Set<string>()
|
|
516
|
+
groups.add(parsed.group)
|
|
517
|
+
identityGroupsByResourceKey.set(rk, groups)
|
|
568
518
|
}
|
|
569
|
-
|
|
570
|
-
//
|
|
571
|
-
//
|
|
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>()
|
|
519
|
+
|
|
520
|
+
// Merge exact identities from topology and timeline. Built-in groups share
|
|
521
|
+
// the same bare lane identity; CRD groups remain distinct.
|
|
576
522
|
for (const event of events) {
|
|
577
|
-
if (event.kind === 'Event' && event.owner) continue
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
523
|
+
if ((event.kind === 'Event' && event.owner) || !event.apiVersion) continue
|
|
524
|
+
const rk = laneResourceKey(event.kind, event.namespace, event.name)
|
|
525
|
+
const groups = identityGroupsByResourceKey.get(rk) ?? new Set<string>()
|
|
526
|
+
const group = apiVersionToGroup(event.apiVersion)
|
|
527
|
+
groups.add(groupQualifiesLaneId(group) ? group : '')
|
|
528
|
+
identityGroupsByResourceKey.set(rk, groups)
|
|
583
529
|
}
|
|
530
|
+
|
|
531
|
+
// API group fallback for a stored event that shipped without apiVersion.
|
|
532
|
+
// A same-kind/name collision is ambiguous and therefore supplies no fallback.
|
|
533
|
+
const groupByKey = new Map<string, string>()
|
|
534
|
+
const ambiguousResourceKeys = new Set<string>()
|
|
535
|
+
for (const [rk, groups] of identityGroupsByResourceKey) {
|
|
536
|
+
if (groups.size === 1) groupByKey.set(rk, groups.values().next().value ?? '')
|
|
537
|
+
else ambiguousResourceKeys.add(rk)
|
|
538
|
+
}
|
|
539
|
+
// Reserved pseudo-group for a genuinely ambiguous key (2+ groups collide on
|
|
540
|
+
// this kind/ns/name and the reference itself carries no apiVersion to pick
|
|
541
|
+
// one). No real Kubernetes API group can equal this string, so laneId()
|
|
542
|
+
// always group-qualifies it — the synthesized lane can never coincide with
|
|
543
|
+
// a real group's canonical id, including a built-in's bare `Kind/ns/name`
|
|
544
|
+
// form (an empty-string fallback would silently collapse into that).
|
|
545
|
+
const AMBIGUOUS_GROUP = ' ambiguous'
|
|
584
546
|
// The group for a resource key when the reference itself carries none.
|
|
585
|
-
const fallbackGroup = (rk: string): string =>
|
|
547
|
+
const fallbackGroup = (rk: string): string =>
|
|
548
|
+
ambiguousResourceKeys.has(rk) ? AMBIGUOUS_GROUP : (groupByKey.get(rk) ?? '')
|
|
586
549
|
|
|
587
550
|
// Group-less resource key → canonical (possibly group-qualified) lane id. Lets
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
// bare
|
|
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).
|
|
551
|
+
// owner refs and K8s-event involvedObjects reconcile onto the real lane instead
|
|
552
|
+
// of forking a bare duplicate when identity is unambiguous. Ambiguous refs stay
|
|
553
|
+
// bare; topology endpoints use the exact node-id map above.
|
|
593
554
|
const laneIdByKey = new Map<string, string>()
|
|
594
555
|
const registerLaneKey = (rk: string, id: string): void => {
|
|
595
556
|
if (!laneIdByKey.has(rk)) laneIdByKey.set(rk, id)
|
|
596
557
|
}
|
|
558
|
+
// The lane id an ambiguous key resolves to — same computation ensureRefLane
|
|
559
|
+
// and resolveId use to create one, so a lookup here always agrees with a
|
|
560
|
+
// subsequent get-or-create.
|
|
561
|
+
const ambiguousLaneId = (rk: string): string => {
|
|
562
|
+
const p = parseLaneId(rk)!
|
|
563
|
+
return laneId(p.kind, AMBIGUOUS_GROUP, p.namespace, p.name)
|
|
564
|
+
}
|
|
597
565
|
// Get-or-create the lane for a group-less reference, returning its canonical id.
|
|
598
566
|
const ensureRefLane = (rk: string, seedEvent?: TimelineEvent): string => {
|
|
599
|
-
const existing =
|
|
567
|
+
const existing = ambiguousResourceKeys.has(rk)
|
|
568
|
+
? (laneMap.has(ambiguousLaneId(rk)) ? ambiguousLaneId(rk) : undefined)
|
|
569
|
+
: laneIdByKey.get(rk)
|
|
600
570
|
if (existing) {
|
|
601
571
|
if (seedEvent) laneMap.get(existing)!.events.push(seedEvent)
|
|
602
572
|
return existing
|
|
@@ -607,7 +577,9 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
607
577
|
laneMap.set(id, {
|
|
608
578
|
id,
|
|
609
579
|
kind: p.kind,
|
|
610
|
-
group,
|
|
580
|
+
group: group === AMBIGUOUS_GROUP ? undefined : group,
|
|
581
|
+
identityAmbiguous: ambiguousResourceKeys.has(rk),
|
|
582
|
+
identityResolved: groupByKey.has(rk) && !ambiguousResourceKeys.has(rk),
|
|
611
583
|
namespace: p.namespace,
|
|
612
584
|
name: p.name,
|
|
613
585
|
events: seedEvent ? [seedEvent] : [],
|
|
@@ -618,10 +590,30 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
618
590
|
registerLaneKey(rk, id)
|
|
619
591
|
return id
|
|
620
592
|
}
|
|
593
|
+
const ensureTopologyLane = (id: string): string => {
|
|
594
|
+
if (laneMap.has(id)) return id
|
|
595
|
+
const parsed = parseLaneId(id)!
|
|
596
|
+
laneMap.set(id, {
|
|
597
|
+
id,
|
|
598
|
+
kind: parsed.kind,
|
|
599
|
+
group: parsed.group,
|
|
600
|
+
identityResolved: true,
|
|
601
|
+
namespace: parsed.namespace,
|
|
602
|
+
name: parsed.name,
|
|
603
|
+
events: [],
|
|
604
|
+
isWorkload: isWorkloadKind(parsed.kind),
|
|
605
|
+
children: [],
|
|
606
|
+
childEventCount: 0,
|
|
607
|
+
})
|
|
608
|
+
registerLaneKey(laneResourceKey(parsed.kind, parsed.namespace, parsed.name), id)
|
|
609
|
+
return id
|
|
610
|
+
}
|
|
621
611
|
// The canonical id a group-less key resolves to, WITHOUT creating a lane —
|
|
622
612
|
// agrees with ensureRefLane so guards keyed by canonical id line up.
|
|
623
613
|
const resolveId = (rk: string): string => {
|
|
624
|
-
const existing =
|
|
614
|
+
const existing = ambiguousResourceKeys.has(rk)
|
|
615
|
+
? (laneMap.has(ambiguousLaneId(rk)) ? ambiguousLaneId(rk) : undefined)
|
|
616
|
+
: laneIdByKey.get(rk)
|
|
625
617
|
if (existing) return existing
|
|
626
618
|
const p = parseLaneId(rk)!
|
|
627
619
|
return laneId(p.kind, fallbackGroup(rk), p.namespace, p.name)
|
|
@@ -639,19 +631,23 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
639
631
|
}
|
|
640
632
|
|
|
641
633
|
const rk = laneResourceKey(event.kind, event.namespace, event.name)
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
const
|
|
646
|
-
const
|
|
634
|
+
// An explicit apiVersion always chooses its canonical lane, including a
|
|
635
|
+
// built-in group whose canonical id is bare. Only an event that omitted
|
|
636
|
+
// apiVersion reconciles through the group-less registry.
|
|
637
|
+
const hasAPIVersion = !!event.apiVersion
|
|
638
|
+
const group = hasAPIVersion ? apiVersionToGroup(event.apiVersion) : fallbackGroup(rk)
|
|
639
|
+
const id = hasAPIVersion || groupQualifiesLaneId(group)
|
|
647
640
|
? laneId(event.kind, group, event.namespace, event.name)
|
|
648
|
-
: (
|
|
641
|
+
: (!ambiguousResourceKeys.has(rk) ? laneIdByKey.get(rk) : undefined)
|
|
642
|
+
?? laneResourceKey(event.kind, event.namespace, event.name)
|
|
649
643
|
const existing = laneMap.get(id)
|
|
650
644
|
if (!existing) {
|
|
651
645
|
laneMap.set(id, {
|
|
652
646
|
id,
|
|
653
647
|
kind: event.kind,
|
|
654
|
-
group,
|
|
648
|
+
group: group === AMBIGUOUS_GROUP ? undefined : group,
|
|
649
|
+
identityAmbiguous: !hasAPIVersion && ambiguousResourceKeys.has(rk),
|
|
650
|
+
identityResolved: hasAPIVersion || (groupByKey.has(rk) && !ambiguousResourceKeys.has(rk)),
|
|
655
651
|
namespace: event.namespace,
|
|
656
652
|
name: event.name,
|
|
657
653
|
events: [event],
|
|
@@ -661,7 +657,8 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
661
657
|
})
|
|
662
658
|
registerLaneKey(rk, id)
|
|
663
659
|
} else {
|
|
664
|
-
if (!existing.group && group) existing.group = group
|
|
660
|
+
if (!existing.group && group && group !== AMBIGUOUS_GROUP) existing.group = group
|
|
661
|
+
if (hasAPIVersion || (groupByKey.has(rk) && !ambiguousResourceKeys.has(rk))) existing.identityResolved = true
|
|
665
662
|
existing.events.push(event)
|
|
666
663
|
}
|
|
667
664
|
}
|
|
@@ -688,27 +685,23 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
688
685
|
for (const [id, lane] of laneMap) {
|
|
689
686
|
const eventWithOwner = lane.events.find(e => e.owner)
|
|
690
687
|
if (eventWithOwner?.owner) {
|
|
691
|
-
const
|
|
688
|
+
const ownerKey = laneResourceKey(eventWithOwner.owner.kind, lane.namespace, eventWithOwner.owner.name)
|
|
689
|
+
if (ambiguousResourceKeys.has(ownerKey)) continue
|
|
690
|
+
const ownerId = ensureRefLane(ownerKey)
|
|
692
691
|
laneParent.set(id, ownerId)
|
|
693
692
|
}
|
|
694
693
|
}
|
|
695
694
|
|
|
696
695
|
// 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.
|
|
700
696
|
if (topology?.edges) {
|
|
701
697
|
for (const edge of topology.edges) {
|
|
702
|
-
const
|
|
703
|
-
const
|
|
704
|
-
if (!
|
|
705
|
-
|
|
706
|
-
// manages: Deployment→RS→Pod (already covered by owner refs, skip)
|
|
707
|
-
if (edge.type === 'manages') continue
|
|
698
|
+
const sourceId = topologyLaneIdByNodeId.get(edge.source)
|
|
699
|
+
const targetId = topologyLaneIdByNodeId.get(edge.target)
|
|
700
|
+
if (!sourceId || !targetId) continue
|
|
708
701
|
|
|
709
702
|
// At least one side must have events
|
|
710
|
-
const sourceExists =
|
|
711
|
-
const targetExists =
|
|
703
|
+
const sourceExists = laneMap.has(sourceId)
|
|
704
|
+
const targetExists = laneMap.has(targetId)
|
|
712
705
|
if (!sourceExists && !targetExists) continue
|
|
713
706
|
|
|
714
707
|
// App membership outranks topology attachment: two members of the SAME
|
|
@@ -718,64 +711,70 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
718
711
|
// the whole stack with the attachment (a health-less Service) instead of
|
|
719
712
|
// the app. Only the byResource tier applies — both endpoints here are
|
|
720
713
|
// concrete resources the server either claims or doesn't.
|
|
721
|
-
const sameAppMembers = (
|
|
714
|
+
const sameAppMembers = (aId: string, bId: string): boolean => {
|
|
722
715
|
if (grouping !== 'app' || !appIndex) return false
|
|
723
|
-
const
|
|
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
|
|
716
|
+
const a = appIndex.byResource.get(aId)
|
|
729
717
|
if (!a) return false
|
|
730
|
-
const
|
|
731
|
-
const b = bRes ? appIndex.byResource.get(bRes) : undefined
|
|
718
|
+
const b = appIndex.byResource.get(bId)
|
|
732
719
|
return b != null && a.appKey === b.appKey
|
|
733
720
|
}
|
|
734
721
|
|
|
735
722
|
// Parent `child` under `parent`, materializing `parent`'s lane if absent.
|
|
736
723
|
// `childHasEvents` mirrors the old guard (only parent an endpoint that
|
|
737
724
|
// actually exists). Guards read canonical ids so laneParent lines up.
|
|
738
|
-
const link = (
|
|
725
|
+
const link = (childId: string, parentId: string, childHasEvents: boolean): void => {
|
|
739
726
|
if (!childHasEvents) return
|
|
740
|
-
if (sameAppMembers(
|
|
741
|
-
const childId = resolveId(childKey)
|
|
727
|
+
if (sameAppMembers(childId, parentId)) return
|
|
742
728
|
if (laneParent.has(childId)) return
|
|
743
|
-
laneParent.set(childId,
|
|
729
|
+
laneParent.set(childId, ensureTopologyLane(parentId))
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// A manages edge can represent inventory membership or a shortcut over
|
|
733
|
+
// ReplicaSets/Jobs. Only refine the owner actually recorded by the event.
|
|
734
|
+
if (edge.type === 'manages') {
|
|
735
|
+
const source = parseLaneId(sourceId)!
|
|
736
|
+
const owner = laneMap.get(targetId)?.events.find(event => event.owner)?.owner
|
|
737
|
+
if (targetExists && owner?.kind === source.kind && owner.name === source.name
|
|
738
|
+
&& laneMap.get(targetId)?.namespace === source.namespace
|
|
739
|
+
&& !sameAppMembers(targetId, sourceId)) {
|
|
740
|
+
laneParent.set(targetId, ensureTopologyLane(sourceId))
|
|
741
|
+
}
|
|
742
|
+
continue
|
|
744
743
|
}
|
|
745
744
|
|
|
746
745
|
// exposes: Service→Deployment (Service is parent of Deployment)
|
|
747
746
|
if (edge.type === 'exposes') {
|
|
748
|
-
link(
|
|
747
|
+
link(targetId, sourceId, targetExists)
|
|
749
748
|
}
|
|
750
749
|
|
|
751
750
|
// routes-to has two cases:
|
|
752
751
|
// 1. Ingress→Service: Service should be parent (representative)
|
|
753
752
|
// 2. Service→Pod/PodGroup: Service should be parent (normal hierarchy)
|
|
754
753
|
if (edge.type === 'routes-to') {
|
|
755
|
-
const sourceKind =
|
|
756
|
-
const targetKind =
|
|
754
|
+
const sourceKind = parseLaneId(sourceId)!.kind
|
|
755
|
+
const targetKind = parseLaneId(targetId)!.kind
|
|
757
756
|
|
|
758
757
|
// Gateway→Route: Gateway is parent of Route
|
|
759
758
|
if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute')) {
|
|
760
|
-
link(
|
|
759
|
+
link(targetId, sourceId, targetExists)
|
|
761
760
|
}
|
|
762
761
|
// Route→Service: reverse (Service is representative, like Ingress)
|
|
763
762
|
else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute') && targetKind === 'Service') {
|
|
764
|
-
link(
|
|
763
|
+
link(sourceId, targetId, sourceExists)
|
|
765
764
|
}
|
|
766
765
|
// Ingress→Service: reverse relationship (Service is representative)
|
|
767
766
|
else if (sourceKind === 'Ingress' && targetKind === 'Service') {
|
|
768
|
-
link(
|
|
767
|
+
link(sourceId, targetId, sourceExists)
|
|
769
768
|
}
|
|
770
769
|
// Service→Pod/PodGroup: normal direction (Service is parent)
|
|
771
770
|
else if (sourceKind === 'Service') {
|
|
772
|
-
link(
|
|
771
|
+
link(targetId, sourceId, targetExists)
|
|
773
772
|
}
|
|
774
773
|
}
|
|
775
774
|
|
|
776
775
|
// configures/uses/protects: ConfigMap→Deployment, HPA→Deployment, PDB→Deployment (target is parent)
|
|
777
776
|
if (edge.type === 'configures' || edge.type === 'uses' || edge.type === 'protects') {
|
|
778
|
-
link(
|
|
777
|
+
link(sourceId, targetId, sourceExists)
|
|
779
778
|
}
|
|
780
779
|
}
|
|
781
780
|
}
|
|
@@ -787,16 +786,13 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
787
786
|
// membership cascade below replaces this. Kept so WorkloadView and index-less
|
|
788
787
|
// callers keep today's behavior.
|
|
789
788
|
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.
|
|
792
789
|
const laneAppLabels = new Map<string, string>()
|
|
793
790
|
for (const node of topology.nodes) {
|
|
794
|
-
const
|
|
795
|
-
if (!key) continue
|
|
791
|
+
const id = topologyLaneIdByNodeId.get(node.id)!
|
|
796
792
|
const labels = node.data?.labels as Record<string, string> | undefined
|
|
797
793
|
const appLabel = labels?.['app.kubernetes.io/name'] || labels?.['app']
|
|
798
794
|
if (appLabel) {
|
|
799
|
-
laneAppLabels.set(
|
|
795
|
+
laneAppLabels.set(id, appLabel)
|
|
800
796
|
}
|
|
801
797
|
}
|
|
802
798
|
|
|
@@ -820,7 +816,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
820
816
|
for (const [id, lane] of laneMap) {
|
|
821
817
|
if (laneParent.has(id)) continue
|
|
822
818
|
if (!appLabelEligibleKinds.has(lane.kind)) continue
|
|
823
|
-
const appLabel = laneAppLabels.get(
|
|
819
|
+
const appLabel = laneAppLabels.get(id)
|
|
824
820
|
if (!appLabel) continue
|
|
825
821
|
const groupKey = `${lane.namespace}/${appLabel}`
|
|
826
822
|
if (!appGroups.has(groupKey)) {
|
|
@@ -877,7 +873,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
877
873
|
if (grouping !== 'flat') {
|
|
878
874
|
for (const [id, lane] of laneMap) {
|
|
879
875
|
if (laneParent.has(id)) continue
|
|
880
|
-
const parentId = contractParentId(lane, laneMap, laneParent
|
|
876
|
+
const parentId = contractParentId(lane, laneMap, laneParent)
|
|
881
877
|
if (!parentId) continue
|
|
882
878
|
laneParent.set(id, parentId)
|
|
883
879
|
lane.nestedByContract = true
|
|
@@ -937,10 +933,10 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
937
933
|
|
|
938
934
|
// If rootResource is specified, filter to only include lanes related to that resource
|
|
939
935
|
if (rootResource) {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
936
|
+
const rootGroup = canonicalResourceGroup(rootResource.kind, rootResource.group)
|
|
937
|
+
const rootLaneId = rootGroup !== undefined
|
|
938
|
+
? laneId(rootResource.kind, rootGroup, rootResource.namespace, rootResource.name)
|
|
939
|
+
: resolveId(laneResourceKey(rootResource.kind, rootResource.namespace, rootResource.name))
|
|
944
940
|
const rootLane = topLevelLanes.find(l => l.id === rootLaneId)
|
|
945
941
|
|
|
946
942
|
if (rootLane) {
|
|
@@ -984,6 +980,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
984
980
|
id: rootLaneId,
|
|
985
981
|
kind: rootResource.kind,
|
|
986
982
|
group: parseLaneId(rootLaneId)?.group || '',
|
|
983
|
+
identityResolved: rootGroup !== undefined,
|
|
987
984
|
namespace: rootResource.namespace,
|
|
988
985
|
name: rootResource.name,
|
|
989
986
|
events: [],
|
|
@@ -1153,9 +1150,12 @@ export function getAllEventsFromHierarchy(lanes: ResourceLane[]): TimelineEvent[
|
|
|
1153
1150
|
*/
|
|
1154
1151
|
export interface PinnedResourceRef {
|
|
1155
1152
|
type?: 'resource'
|
|
1156
|
-
/**
|
|
1153
|
+
/** Canonical lane id emitted by buildResourceHierarchy. */
|
|
1157
1154
|
id: string
|
|
1158
1155
|
kind: string
|
|
1156
|
+
/** Present on newly written pins so a built-in lane remains distinguishable
|
|
1157
|
+
* from a same-kind CRD. Older localStorage records legitimately omit it. */
|
|
1158
|
+
group?: string
|
|
1159
1159
|
namespace: string
|
|
1160
1160
|
name: string
|
|
1161
1161
|
}
|
|
@@ -1186,7 +1186,88 @@ export function isPinnedLaneRef(value: unknown): value is PinnedLaneRef {
|
|
|
1186
1186
|
if (v.type === 'appGroup') {
|
|
1187
1187
|
return typeof v.appKey === 'string' && typeof v.appName === 'string'
|
|
1188
1188
|
}
|
|
1189
|
-
return typeof v.kind === 'string'
|
|
1189
|
+
return typeof v.kind === 'string'
|
|
1190
|
+
&& (v.group === undefined || typeof v.group === 'string')
|
|
1191
|
+
&& typeof v.namespace === 'string'
|
|
1192
|
+
&& typeof v.name === 'string'
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
interface LaneIdentityIndex {
|
|
1196
|
+
byId: Map<string, ResourceLane>
|
|
1197
|
+
byUnqualified: Map<string, ResourceLane[]>
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function indexLanesByIdentity(allLanes: ResourceLane[]): LaneIdentityIndex {
|
|
1201
|
+
const byId = new Map<string, ResourceLane>()
|
|
1202
|
+
const byUnqualified = new Map<string, ResourceLane[]>()
|
|
1203
|
+
const indexLane = (lane: ResourceLane): void => {
|
|
1204
|
+
if (!byId.has(lane.id)) byId.set(lane.id, lane)
|
|
1205
|
+
const parsed = parseLaneId(lane.id)
|
|
1206
|
+
if (parsed && !lane.isAppGroup) {
|
|
1207
|
+
const key = laneResourceKey(parsed.kind, parsed.namespace, parsed.name)
|
|
1208
|
+
const candidates = byUnqualified.get(key) ?? []
|
|
1209
|
+
if (!candidates.some((candidate) => candidate.id === lane.id)) candidates.push(lane)
|
|
1210
|
+
byUnqualified.set(key, candidates)
|
|
1211
|
+
}
|
|
1212
|
+
for (const child of lane.children ?? []) indexLane(child)
|
|
1213
|
+
}
|
|
1214
|
+
for (const lane of allLanes) indexLane(lane)
|
|
1215
|
+
return { byId, byUnqualified }
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function resolvePinnedResourceLane(ref: PinnedResourceRef, index: LaneIdentityIndex): ResourceLane | undefined {
|
|
1219
|
+
const key = laneResourceKey(ref.kind, ref.namespace, ref.name)
|
|
1220
|
+
const candidates = index.byUnqualified.get(key) ?? []
|
|
1221
|
+
// A pin's `group` field is a later addition (PinnedResourceRef's own
|
|
1222
|
+
// comment: "Older localStorage records legitimately omit it"), but its
|
|
1223
|
+
// persisted `id` — the canonical lane id at save time — already carries a
|
|
1224
|
+
// CRD group in its `.group` suffix, if it had one. Recovering that instead
|
|
1225
|
+
// of treating an absent field as "group unknown" is what stops an old
|
|
1226
|
+
// qualified pin from resolving onto a same-named lane in the WRONG group
|
|
1227
|
+
// once its own group's lane goes away.
|
|
1228
|
+
const group = ref.group ?? (parseLaneId(ref.id)?.group || undefined)
|
|
1229
|
+
if (group !== undefined) {
|
|
1230
|
+
const canonical = laneId(ref.kind, group, ref.namespace, ref.name)
|
|
1231
|
+
const exact = index.byId.get(canonical)
|
|
1232
|
+
const refGroup = canonicalResourceGroup(ref.kind, group)
|
|
1233
|
+
if (exact && canonicalResourceGroup(exact.kind, exact.group) === refGroup) return exact
|
|
1234
|
+
const matching = candidates.filter((candidate) => canonicalResourceGroup(candidate.kind, candidate.group) === refGroup)
|
|
1235
|
+
return matching.length === 1 ? matching[0] : undefined
|
|
1236
|
+
}
|
|
1237
|
+
return candidates.length === 1 ? candidates[0] : undefined
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Resolve persisted resource pins onto current canonical lane IDs. Older pins
|
|
1241
|
+
* omitted API group, so they migrate only when kind/namespace/name identifies
|
|
1242
|
+
* exactly one live lane; collisions intentionally stay unresolved. */
|
|
1243
|
+
export function resolvePinnedLaneIds(allLanes: ResourceLane[], pinnedRefs: PinnedLaneRef[]): Set<string> {
|
|
1244
|
+
const index = indexLanesByIdentity(allLanes)
|
|
1245
|
+
const resolved = new Set<string>()
|
|
1246
|
+
for (const ref of pinnedRefs) {
|
|
1247
|
+
if (ref.type === 'appGroup') {
|
|
1248
|
+
resolved.add(ref.id)
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1251
|
+
const candidates = index.byUnqualified.get(laneResourceKey(ref.kind, ref.namespace, ref.name)) ?? []
|
|
1252
|
+
const lane = resolvePinnedResourceLane(ref, index)
|
|
1253
|
+
if (lane) resolved.add(lane.id)
|
|
1254
|
+
else if (ref.group !== undefined || candidates.length === 0) resolved.add(ref.id)
|
|
1255
|
+
}
|
|
1256
|
+
return resolved
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export function pinnedLaneRefMatches(stored: PinnedLaneRef, incoming: PinnedLaneRef): boolean {
|
|
1260
|
+
if (stored.type === 'appGroup' || incoming.type === 'appGroup') {
|
|
1261
|
+
return stored.type === 'appGroup' && incoming.type === 'appGroup' && stored.appKey === incoming.appKey
|
|
1262
|
+
}
|
|
1263
|
+
if (stored.kind !== incoming.kind || stored.namespace !== incoming.namespace || stored.name !== incoming.name) return false
|
|
1264
|
+
// A group-less `stored.group` is a wildcard ONLY when the stored id itself
|
|
1265
|
+
// never encoded a group either — an old id that already carries a CRD
|
|
1266
|
+
// group (`Cluster.postgresql.cnpg.io/...`) must not be treated as matching
|
|
1267
|
+
// every group, or a CNPG pin would report a match against a CAPI ref.
|
|
1268
|
+
const storedGroup = stored.group ?? (parseLaneId(stored.id)?.group || undefined)
|
|
1269
|
+
if (storedGroup === undefined) return true
|
|
1270
|
+
return canonicalResourceGroup(stored.kind, storedGroup) === canonicalResourceGroup(incoming.kind, incoming.group)
|
|
1190
1271
|
}
|
|
1191
1272
|
|
|
1192
1273
|
/** Ensure a lane carries a merged allEventsSorted (own + descendants). Roots and
|
|
@@ -1202,9 +1283,8 @@ function synthesizeEmptyPinnedLane(ref: PinnedResourceRef): ResourceLane {
|
|
|
1202
1283
|
return {
|
|
1203
1284
|
id: ref.id,
|
|
1204
1285
|
kind: ref.kind,
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
group: parseLaneId(ref.id)?.group || '',
|
|
1286
|
+
group: ref.group ?? parseLaneId(ref.id)?.group ?? '',
|
|
1287
|
+
identityResolved: (ref.group ?? (parseLaneId(ref.id)?.group || undefined)) !== undefined,
|
|
1208
1288
|
namespace: ref.namespace,
|
|
1209
1289
|
name: ref.name,
|
|
1210
1290
|
events: [],
|
|
@@ -1288,12 +1368,11 @@ export function removePinnedLanes(
|
|
|
1288
1368
|
*/
|
|
1289
1369
|
export function extractPinnedLanes(allLanes: ResourceLane[], pinnedRefs: PinnedLaneRef[]): ResourceLane[] {
|
|
1290
1370
|
if (pinnedRefs.length === 0) return []
|
|
1291
|
-
const
|
|
1371
|
+
const identityIndex = indexLanesByIdentity(allLanes)
|
|
1292
1372
|
// App-group headers re-resolve by appKey (live members), independent of the
|
|
1293
1373
|
// deterministic "app:<appKey>" id, so a group survives grouping-mode churn.
|
|
1294
1374
|
const groupByAppKey = new Map<string, ResourceLane>()
|
|
1295
1375
|
const indexLane = (lane: ResourceLane): void => {
|
|
1296
|
-
if (!byId.has(lane.id)) byId.set(lane.id, lane)
|
|
1297
1376
|
if (lane.isAppGroup && lane.appKey && !groupByAppKey.has(lane.appKey)) {
|
|
1298
1377
|
groupByAppKey.set(lane.appKey, lane)
|
|
1299
1378
|
}
|
|
@@ -1328,9 +1407,15 @@ export function extractPinnedLanes(allLanes: ResourceLane[], pinnedRefs: PinnedL
|
|
|
1328
1407
|
out.push(found ? withAllEventsSorted(found) : synthesizeQuietAppGroupLane(ref))
|
|
1329
1408
|
continue
|
|
1330
1409
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1410
|
+
const found = resolvePinnedResourceLane(ref, identityIndex)
|
|
1411
|
+
if (found && coveredByPinnedGroup.has(found.id)) continue // folded into a pinned app group
|
|
1412
|
+
if (found) {
|
|
1413
|
+
out.push(withAllEventsSorted(found))
|
|
1414
|
+
continue
|
|
1415
|
+
}
|
|
1416
|
+
const candidates = identityIndex.byUnqualified.get(laneResourceKey(ref.kind, ref.namespace, ref.name)) ?? []
|
|
1417
|
+
if (ref.group === undefined && candidates.length > 1) continue
|
|
1418
|
+
out.push(synthesizeEmptyPinnedLane(ref))
|
|
1334
1419
|
}
|
|
1335
1420
|
return out
|
|
1336
1421
|
}
|