@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.
Files changed (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. 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); ties resolve to the first key in index order. */
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
- // 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) {
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 = memberName.length
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
- // 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
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
- // 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))
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
- * Convert topology node ID to lane ID format.
426
- * Node IDs are formatted as: kind/namespace/name (e.g., "pod/default/nginx-abc123")
427
- * Lane IDs are formatted as: Kind/namespace/name (e.g., "Pod/default/nginx-abc123")
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
- // 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>()
561
- if (topology?.nodes) {
562
- for (const node of topology.nodes) {
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)
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
- // 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>()
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 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
- }
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 => groupByKey.get(rk) ?? topoGroupByKey.get(rk) ?? ''
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
- // 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).
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 = laneIdByKey.get(rk)
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 = laneIdByKey.get(rk)
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
- // 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)
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
- : (laneIdByKey.get(rk) ?? laneResourceKey(event.kind, event.namespace, event.name))
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 ownerId = ensureRefLane(laneResourceKey(eventWithOwner.owner.kind, lane.namespace, eventWithOwner.owner.name))
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 sourceKey = nodeIdToLaneId(edge.source)
703
- const targetKey = nodeIdToLaneId(edge.target)
704
- if (!sourceKey || !targetKey) continue
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 = laneIdByKey.has(sourceKey)
711
- const targetExists = laneIdByKey.has(targetKey)
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 = (aKey: string, bKey: string): boolean => {
714
+ const sameAppMembers = (aId: string, bId: string): boolean => {
722
715
  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
716
+ const a = appIndex.byResource.get(aId)
729
717
  if (!a) return false
730
- const bRes = toResourceKey(bKey)
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 = (childKey: string, parentKey: string, childHasEvents: boolean): void => {
725
+ const link = (childId: string, parentId: string, childHasEvents: boolean): void => {
739
726
  if (!childHasEvents) return
740
- if (sameAppMembers(childKey, parentKey)) return
741
- const childId = resolveId(childKey)
727
+ if (sameAppMembers(childId, parentId)) return
742
728
  if (laneParent.has(childId)) return
743
- laneParent.set(childId, ensureRefLane(parentKey))
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(targetKey, sourceKey, targetExists)
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 = sourceKey.split('/')[0]
756
- const targetKind = targetKey.split('/')[0]
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(targetKey, sourceKey, targetExists)
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(sourceKey, targetKey, sourceExists)
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(sourceKey, targetKey, sourceExists)
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(targetKey, sourceKey, targetExists)
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(sourceKey, targetKey, sourceExists)
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 key = nodeIdToLaneId(node.id)
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(key, appLabel)
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(laneResourceKey(lane.kind, lane.namespace, lane.name))
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, laneIdByKey)
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
- // 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))
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
- /** "Kind/namespace/name" — the same id form buildResourceHierarchy emits. */
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' && typeof v.namespace === 'string' && typeof v.name === '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
- // 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 || '',
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 byId = new Map<string, ResourceLane>()
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
- 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))
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
  }