@skyhook-io/k8s-ui 1.5.13 → 1.6.0

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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/index.ts +4 -0
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  7. package/src/components/gitops/insights/index.ts +6 -0
  8. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  9. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  11. package/src/components/gitops/tree/index.ts +4 -0
  12. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  13. package/src/components/resources/ResourcesView.tsx +71 -18
  14. package/src/components/resources/index.ts +1 -1
  15. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  19. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  20. package/src/components/shared/EditableYamlView.tsx +28 -17
  21. package/src/components/shared/ManagedByChip.tsx +45 -0
  22. package/src/components/shared/index.ts +1 -0
  23. package/src/components/timeline/TimelineList.tsx +3 -3
  24. package/src/components/topology/TopologyGraph.tsx +3 -2
  25. package/src/components/ui/Tooltip.tsx +10 -1
  26. package/src/components/ui/drawer-components.tsx +1 -1
  27. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  28. package/src/components/workload/WorkloadView.tsx +66 -0
  29. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  30. package/src/index.ts +3 -0
  31. package/src/types/core.ts +19 -2
  32. package/src/types/gitops-insights.ts +193 -0
  33. package/src/types/gitops-tree.ts +57 -0
  34. package/src/types/index.ts +2 -0
  35. package/src/utils/badge-colors.ts +6 -1
  36. package/src/utils/format.ts +28 -0
  37. package/src/utils/gitops-owner.test.ts +136 -0
  38. package/src/utils/gitops-owner.ts +92 -0
  39. package/src/utils/gitops-route.test.ts +78 -0
  40. package/src/utils/gitops-route.ts +104 -0
  41. package/src/utils/index.ts +2 -0
  42. package/src/utils/navigation.ts +14 -0
  43. package/src/utils/resource-hierarchy.ts +47 -3
  44. package/src/utils/yaml.test.ts +101 -0
  45. package/src/utils/yaml.ts +26 -0
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectGitOpsOwner } from './gitops-owner'
3
+
4
+ const FLUX_HELM_NAME = 'helm.toolkit.fluxcd.io/name'
5
+ const FLUX_HELM_NS = 'helm.toolkit.fluxcd.io/namespace'
6
+ const FLUX_KUSTOMIZE_NAME = 'kustomize.toolkit.fluxcd.io/name'
7
+ const FLUX_KUSTOMIZE_NS = 'kustomize.toolkit.fluxcd.io/namespace'
8
+ const ARGO_TRACKING_ID = 'argocd.argoproj.io/tracking-id'
9
+ const ARGO_INSTANCE = 'argocd.argoproj.io/instance'
10
+ const HELM_INSTANCE = 'app.kubernetes.io/instance'
11
+
12
+ function resource(labels: Record<string, string> = {}, annotations: Record<string, string> = {}) {
13
+ return { metadata: { labels, annotations } }
14
+ }
15
+
16
+ describe('detectGitOpsOwner', () => {
17
+ it('returns null for non-objects', () => {
18
+ expect(detectGitOpsOwner(null)).toBeNull()
19
+ expect(detectGitOpsOwner(undefined)).toBeNull()
20
+ expect(detectGitOpsOwner('string')).toBeNull()
21
+ })
22
+
23
+ it('returns null when no GitOps labels/annotations present', () => {
24
+ expect(detectGitOpsOwner(resource({ app: 'foo' }))).toBeNull()
25
+ })
26
+
27
+ describe('Flux HelmRelease labels', () => {
28
+ it('extracts name + namespace', () => {
29
+ const got = detectGitOpsOwner(resource({
30
+ [FLUX_HELM_NAME]: 'podinfo',
31
+ [FLUX_HELM_NS]: 'flux-system',
32
+ }))
33
+ expect(got).toEqual({ tool: 'fluxcd', kind: 'helmreleases', namespace: 'flux-system', name: 'podinfo' })
34
+ })
35
+
36
+ it('wins over Flux Kustomize labels when both are present (most-direct owner)', () => {
37
+ const got = detectGitOpsOwner(resource({
38
+ [FLUX_HELM_NAME]: 'podinfo',
39
+ [FLUX_HELM_NS]: 'flux-system',
40
+ [FLUX_KUSTOMIZE_NAME]: 'parent',
41
+ [FLUX_KUSTOMIZE_NS]: 'flux-system',
42
+ }))
43
+ expect(got?.kind).toBe('helmreleases')
44
+ expect(got?.name).toBe('podinfo')
45
+ })
46
+
47
+ it('requires both name and namespace', () => {
48
+ expect(detectGitOpsOwner(resource({ [FLUX_HELM_NAME]: 'podinfo' }))).toBeNull()
49
+ expect(detectGitOpsOwner(resource({ [FLUX_HELM_NS]: 'flux-system' }))).toBeNull()
50
+ })
51
+ })
52
+
53
+ describe('Flux Kustomize labels', () => {
54
+ it('extracts name + namespace', () => {
55
+ const got = detectGitOpsOwner(resource({
56
+ [FLUX_KUSTOMIZE_NAME]: 'infra',
57
+ [FLUX_KUSTOMIZE_NS]: 'flux-system',
58
+ }))
59
+ expect(got).toEqual({ tool: 'fluxcd', kind: 'kustomizations', namespace: 'flux-system', name: 'infra' })
60
+ })
61
+ })
62
+
63
+ describe('Argo tracking-id annotation', () => {
64
+ it('parses the namespaced form (<ns>_<name>:...)', () => {
65
+ const got = detectGitOpsOwner(resource({}, {
66
+ [ARGO_TRACKING_ID]: 'argocd_my-app:apps/Deployment:default/web',
67
+ }))
68
+ expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'my-app' })
69
+ })
70
+
71
+ it('parses the legacy single-name form (no underscore)', () => {
72
+ const got = detectGitOpsOwner(resource({}, {
73
+ [ARGO_TRACKING_ID]: 'my-app:apps/Deployment:default/web',
74
+ }))
75
+ // Empty namespace is the contract — caller defaults / routes accordingly.
76
+ expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: '', name: 'my-app' })
77
+ })
78
+
79
+ it('returns null on a malformed tracking-id (no colon)', () => {
80
+ const got = detectGitOpsOwner(resource({}, { [ARGO_TRACKING_ID]: 'just-garbage-no-colon' }))
81
+ // Falls through to label scan; with no labels present, returns null.
82
+ expect(got).toBeNull()
83
+ })
84
+
85
+ it('returns null on tracking-id with empty name after underscore', () => {
86
+ const got = detectGitOpsOwner(resource({}, { [ARGO_TRACKING_ID]: 'my-ns_:apps/Deployment:default/web' }))
87
+ expect(got).toBeNull()
88
+ })
89
+
90
+ it('wins over the bare instance label (most authoritative)', () => {
91
+ const got = detectGitOpsOwner(resource(
92
+ { [ARGO_INSTANCE]: 'wrong-app' },
93
+ { [ARGO_TRACKING_ID]: 'argocd_right-app:apps/Deployment:default/web' },
94
+ ))
95
+ expect(got?.name).toBe('right-app')
96
+ expect(got?.namespace).toBe('argocd')
97
+ })
98
+ })
99
+
100
+ describe('Argo instance label fallback', () => {
101
+ it('extracts name from argocd-specific instance label', () => {
102
+ const got = detectGitOpsOwner(resource({ [ARGO_INSTANCE]: 'guestbook' }))
103
+ expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: '', name: 'guestbook' })
104
+ })
105
+
106
+ it('does NOT fall back to standard k8s instance label', () => {
107
+ // app.kubernetes.io/instance is stamped by virtually every Helm chart.
108
+ // Treating it as an Argo signal produced a false "Managed by <release>"
109
+ // chip on plain Helm-installed resources, so detection now requires an
110
+ // Argo-specific signal (tracking-id annotation or argocd.argoproj.io/instance).
111
+ expect(detectGitOpsOwner(resource({ [HELM_INSTANCE]: 'guestbook-healthy' }))).toBeNull()
112
+ })
113
+
114
+ it('argocd-specific label still wins when both labels present', () => {
115
+ const got = detectGitOpsOwner(resource({
116
+ [ARGO_INSTANCE]: 'argo-pick',
117
+ [HELM_INSTANCE]: 'helm-pick',
118
+ }))
119
+ expect(got?.name).toBe('argo-pick')
120
+ })
121
+ })
122
+
123
+ describe('Flux labels beat Argo annotations', () => {
124
+ it('Flux HelmRelease wins over Argo tracking-id', () => {
125
+ const got = detectGitOpsOwner(resource(
126
+ {
127
+ [FLUX_HELM_NAME]: 'podinfo',
128
+ [FLUX_HELM_NS]: 'flux-system',
129
+ },
130
+ { [ARGO_TRACKING_ID]: 'argocd_some-app:apps/Deployment:default/web' },
131
+ ))
132
+ expect(got?.tool).toBe('fluxcd')
133
+ expect(got?.kind).toBe('helmreleases')
134
+ })
135
+ })
136
+ })
@@ -0,0 +1,92 @@
1
+ // Detect whether a Kubernetes resource is managed by a GitOps controller
2
+ // (ArgoCD or FluxCD) and return a navigable ref to its owning GitOps CR so the
3
+ // drawer can render a "Managed by <app>" affordance.
4
+ //
5
+ // Precedence (most-specific wins):
6
+ // 1. Flux HelmRelease labels
7
+ // 2. Flux Kustomize labels
8
+ // 3. Argo tracking-id annotation
9
+ // 4. Argo-specific instance label
10
+ // 5. Standard k8s instance label (best-effort; false positives possible)
11
+
12
+ // GitOpsOwnerRef is a discriminated union — the tool determines which kind is
13
+ // valid. Modeling it this way prevents callers (and consumers of the returned
14
+ // type) from constructing `{ tool: 'argo', kind: 'helmreleases' }` which would
15
+ // route to a non-existent page.
16
+ export type GitOpsOwnerRef =
17
+ | { tool: 'argocd'; kind: 'applications'; namespace: string; name: string }
18
+ | { tool: 'fluxcd'; kind: 'kustomizations' | 'helmreleases'; namespace: string; name: string }
19
+
20
+ // Vocabulary mirrors `pkg/gitops/tree.Tool` so the wire labels match end-to-end.
21
+ export type GitOpsOwnerTool = GitOpsOwnerRef['tool']
22
+
23
+ const ARGO_TRACKING_ID_ANNOTATION = 'argocd.argoproj.io/tracking-id'
24
+ const ARGO_INSTANCE_LABEL = 'argocd.argoproj.io/instance'
25
+ // app.kubernetes.io/instance is intentionally NOT a fallback signal here.
26
+ // It's the standard k8s recommended label and stamped by virtually every
27
+ // Helm chart in existence, not just Argo. Treating it as an Argo ownership
28
+ // hint produced false positives on plain Helm-installed resources, which
29
+ // surfaced as a misleading "Managed by <release>" chip on ordinary workload
30
+ // drawers. Argo installs that rely on this default can still be detected
31
+ // via the tracking-id annotation; the `argocd.argoproj.io/instance` label
32
+ // (above) covers Argo deployments that explicitly set their own label key.
33
+
34
+ const FLUX_KUSTOMIZE_NAME = 'kustomize.toolkit.fluxcd.io/name'
35
+ const FLUX_KUSTOMIZE_NS = 'kustomize.toolkit.fluxcd.io/namespace'
36
+ const FLUX_HELM_NAME = 'helm.toolkit.fluxcd.io/name'
37
+ const FLUX_HELM_NS = 'helm.toolkit.fluxcd.io/namespace'
38
+
39
+ export function detectGitOpsOwner(resource: unknown): GitOpsOwnerRef | null {
40
+ if (!resource || typeof resource !== 'object') return null
41
+ const meta = (resource as { metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> } }).metadata
42
+ const labels = meta?.labels ?? {}
43
+ const annotations = meta?.annotations ?? {}
44
+
45
+ const helmName = labels[FLUX_HELM_NAME]
46
+ const helmNs = labels[FLUX_HELM_NS]
47
+ if (helmName && helmNs) {
48
+ return { tool: 'fluxcd', kind: 'helmreleases', namespace: helmNs, name: helmName }
49
+ }
50
+ const kustName = labels[FLUX_KUSTOMIZE_NAME]
51
+ const kustNs = labels[FLUX_KUSTOMIZE_NS]
52
+ if (kustName && kustNs) {
53
+ return { tool: 'fluxcd', kind: 'kustomizations', namespace: kustNs, name: kustName }
54
+ }
55
+
56
+ const trackingID = annotations[ARGO_TRACKING_ID_ANNOTATION]
57
+ if (trackingID) {
58
+ const parsed = parseArgoTrackingID(trackingID)
59
+ if (parsed) {
60
+ return { tool: 'argocd', kind: 'applications', namespace: parsed.namespace, name: parsed.name }
61
+ }
62
+ }
63
+
64
+ const instance = labels[ARGO_INSTANCE_LABEL]
65
+ if (instance) {
66
+ // App namespace unknown without tracking-id; emit empty so the consumer can
67
+ // either skip the link or default to a well-known namespace.
68
+ return { tool: 'argocd', kind: 'applications', namespace: '', name: instance }
69
+ }
70
+
71
+ return null
72
+ }
73
+
74
+ // Argo CD writes its tracking-id in one of two forms depending on whether
75
+ // installationID / namespaced-install is configured:
76
+ // "<appName>:<group>/<kind>:<resourceNs>/<resourceName>" default
77
+ // "<appNamespace>_<appName>:<group>/<kind>:<resourceNs>/<resourceName>" namespaced install
78
+ // We accept both. The legacy single-name form yields an empty namespace so the
79
+ // caller can route to a "find this app" search instead of guessing.
80
+ function parseArgoTrackingID(value: string): { namespace: string; name: string } | null {
81
+ const firstColon = value.indexOf(':')
82
+ if (firstColon < 0) return null
83
+ const head = value.slice(0, firstColon)
84
+ const sep = head.indexOf('_')
85
+ if (sep < 0) {
86
+ return head ? { namespace: '', name: head } : null
87
+ }
88
+ const namespace = head.slice(0, sep)
89
+ const name = head.slice(sep + 1)
90
+ if (!name) return null
91
+ return { namespace, name }
92
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { gitOpsRouteForOwner, gitOpsRouteForResource } from './gitops-route'
3
+
4
+ describe('gitOpsRouteForOwner', () => {
5
+ it('routes to detail URL for Argo with known namespace', () => {
6
+ expect(gitOpsRouteForOwner({ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'guestbook' }))
7
+ .toBe('/gitops/detail/applications/argocd/guestbook')
8
+ })
9
+ it('routes to fleet when Argo namespace is unknown (bare instance label)', () => {
10
+ expect(gitOpsRouteForOwner({ tool: 'argocd', kind: 'applications', namespace: '', name: 'guestbook' }))
11
+ .toBe('/gitops')
12
+ })
13
+ it('routes Flux Kustomization owners with their namespace', () => {
14
+ expect(gitOpsRouteForOwner({ tool: 'fluxcd', kind: 'kustomizations', namespace: 'flux-system', name: 'podinfo' }))
15
+ .toBe('/gitops/detail/kustomizations/flux-system/podinfo')
16
+ })
17
+ it('routes Flux HelmRelease owners', () => {
18
+ expect(gitOpsRouteForOwner({ tool: 'fluxcd', kind: 'helmreleases', namespace: 'flux-system', name: 'podinfo' }))
19
+ .toBe('/gitops/detail/helmreleases/flux-system/podinfo')
20
+ })
21
+ it('encodes path components', () => {
22
+ expect(gitOpsRouteForOwner({ tool: 'fluxcd', kind: 'kustomizations', namespace: 'has space', name: 'a/b' }))
23
+ .toBe('/gitops/detail/kustomizations/has%20space/a%2Fb')
24
+ })
25
+ })
26
+
27
+ describe('gitOpsRouteForResource', () => {
28
+ const mk = (api: string, kind: string, ns = 'x', name = 'y') => ({
29
+ apiVersion: api, kind, metadata: { namespace: ns, name },
30
+ })
31
+
32
+ it('routes Argo CRs (Application/ApplicationSet/AppProject)', () => {
33
+ expect(gitOpsRouteForResource(mk('argoproj.io/v1alpha1', 'Application', 'argocd', 'app')))
34
+ .toBe('/gitops/detail/applications/argocd/app')
35
+ expect(gitOpsRouteForResource(mk('argoproj.io/v1alpha1', 'ApplicationSet', 'argocd', 'set')))
36
+ .toBe('/gitops/detail/applicationsets/argocd/set')
37
+ expect(gitOpsRouteForResource(mk('argoproj.io/v1alpha1', 'AppProject', 'argocd', 'proj')))
38
+ .toBe('/gitops/detail/appprojects/argocd/proj')
39
+ })
40
+
41
+ it('routes Flux reconcilers (Kustomization/HelmRelease)', () => {
42
+ expect(gitOpsRouteForResource(mk('kustomize.toolkit.fluxcd.io/v1', 'Kustomization', 'flux-system', 'k')))
43
+ .toBe('/gitops/detail/kustomizations/flux-system/k')
44
+ expect(gitOpsRouteForResource(mk('helm.toolkit.fluxcd.io/v2', 'HelmRelease', 'flux-system', 'hr')))
45
+ .toBe('/gitops/detail/helmreleases/flux-system/hr')
46
+ })
47
+
48
+ // Source CRs are *not* portals — the resource drawer renders them better.
49
+ // If this ever flips, also update pkg/gitops/tree/graph.go classifyGitOpsKind
50
+ // and web/src/components/gitops/GitOpsView.tsx isGitOpsDetailRef.
51
+ it('returns null for Flux source CRs (drawer-only)', () => {
52
+ expect(gitOpsRouteForResource(mk('source.toolkit.fluxcd.io/v1', 'GitRepository'))).toBeNull()
53
+ expect(gitOpsRouteForResource(mk('source.toolkit.fluxcd.io/v1', 'HelmRepository'))).toBeNull()
54
+ expect(gitOpsRouteForResource(mk('source.toolkit.fluxcd.io/v1beta2', 'OCIRepository'))).toBeNull()
55
+ expect(gitOpsRouteForResource(mk('source.toolkit.fluxcd.io/v1beta2', 'Bucket'))).toBeNull()
56
+ expect(gitOpsRouteForResource(mk('source.toolkit.fluxcd.io/v1', 'HelmChart'))).toBeNull()
57
+ })
58
+
59
+ it('returns null for ordinary K8s resources', () => {
60
+ expect(gitOpsRouteForResource(mk('apps/v1', 'Deployment'))).toBeNull()
61
+ expect(gitOpsRouteForResource(mk('v1', 'Service'))).toBeNull()
62
+ expect(gitOpsRouteForResource(mk('v1', 'Pod'))).toBeNull()
63
+ })
64
+
65
+ it('returns null on malformed input', () => {
66
+ expect(gitOpsRouteForResource(null)).toBeNull()
67
+ expect(gitOpsRouteForResource(undefined)).toBeNull()
68
+ expect(gitOpsRouteForResource({})).toBeNull()
69
+ expect(gitOpsRouteForResource({ kind: 'Application' })).toBeNull() // no apiVersion → no group
70
+ })
71
+
72
+ it('distinguishes name collisions by group', () => {
73
+ // Knative Service vs core Service — not a portal kind, but pins behavior.
74
+ expect(gitOpsRouteForResource(mk('serving.knative.dev/v1', 'Service'))).toBeNull()
75
+ // A hypothetical "Application" CR in another group must not portal.
76
+ expect(gitOpsRouteForResource(mk('example.com/v1', 'Application'))).toBeNull()
77
+ })
78
+ })
@@ -0,0 +1,104 @@
1
+ // Routes to the GitOps detail page (in fleet view if the lookup is ambiguous).
2
+ //
3
+ // Two distinct entry points:
4
+ //
5
+ // gitOpsRouteForOwner(owner)
6
+ // For "Managed by <app>" affordances: an ordinary K8s resource carries
7
+ // GitOps ownership labels/annotations and we want to navigate to its
8
+ // owning GitOps CR. Wraps the detect→navigate split: detectGitOpsOwner
9
+ // gives us a typed ref, this helper turns the ref into a URL.
10
+ //
11
+ // gitOpsRouteForResource(resource)
12
+ // For "Open in GitOps" affordances on the GitOps CR itself
13
+ // (Application, ApplicationSet, AppProject, Kustomization, HelmRelease).
14
+ // Source CRs (GitRepository/HelmRepository/OCIRepository/Bucket/HelmChart)
15
+ // return null — they're not portal kinds; the standard resource drawer
16
+ // renders them better.
17
+ //
18
+ // Keep the portal catalog in sync with:
19
+ // - pkg/gitops/tree/graph.go (Go classifier)
20
+ // - web/src/components/gitops/GitOpsView.tsx isGitOpsDetailRef
21
+ // If a kind is added or removed here, update those too.
22
+
23
+ import type { GitOpsOwnerRef } from './gitops-owner'
24
+
25
+ export interface GitOpsResource {
26
+ apiVersion?: string
27
+ kind?: string
28
+ metadata?: { namespace?: string; name?: string }
29
+ }
30
+
31
+ // gitOpsRouteForOwner turns a detected GitOps owner ref into a navigable URL.
32
+ // When the owner namespace is unknown (Argo apps detected only by the bare
33
+ // app.kubernetes.io/instance label), routes to the GitOps fleet view so the
34
+ // user can locate the app rather than landing on a 404'd detail URL.
35
+ export function gitOpsRouteForOwner(owner: GitOpsOwnerRef): string {
36
+ if (owner.tool === 'argocd' && !owner.namespace) {
37
+ return '/gitops'
38
+ }
39
+ return gitOpsDetailUrl(owner.kind, owner.namespace, owner.name)
40
+ }
41
+
42
+ // gitOpsRouteForResource returns the GitOps detail URL for a portal-kind
43
+ // resource, or null when the resource isn't itself a GitOps CR that has its
44
+ // own detail page. Callers should fall back to the standard resource drawer
45
+ // on null.
46
+ export function gitOpsRouteForResource(resource: GitOpsResource | null | undefined): string | null {
47
+ if (!resource) return null
48
+ const kind = resource.kind?.toLowerCase()
49
+ const group = apiGroup(resource.apiVersion)
50
+ const ns = resource.metadata?.namespace ?? ''
51
+ const name = resource.metadata?.name ?? ''
52
+ if (!kind || !name) return null
53
+
54
+ const plural = portalPluralFor(kind, group)
55
+ if (!plural) return null
56
+ return gitOpsDetailUrl(plural, ns, name)
57
+ }
58
+
59
+ function portalPluralFor(kindLower: string, group: string): string | null {
60
+ if (group === 'argoproj.io') {
61
+ if (kindLower === 'application') return 'applications'
62
+ if (kindLower === 'applicationset') return 'applicationsets'
63
+ if (kindLower === 'appproject') return 'appprojects'
64
+ return null
65
+ }
66
+ if (group === 'kustomize.toolkit.fluxcd.io' && kindLower === 'kustomization') return 'kustomizations'
67
+ if (group === 'helm.toolkit.fluxcd.io' && kindLower === 'helmrelease') return 'helmreleases'
68
+ return null
69
+ }
70
+
71
+ function apiGroup(apiVersion: string | undefined): string {
72
+ if (!apiVersion) return ''
73
+ const slash = apiVersion.indexOf('/')
74
+ return slash < 0 ? '' : apiVersion.slice(0, slash)
75
+ }
76
+
77
+ function gitOpsDetailUrl(kindPlural: string, namespace: string, name: string): string {
78
+ const ns = encodeURIComponent(namespace || '_')
79
+ return `/gitops/detail/${encodeURIComponent(kindPlural)}/${ns}/${encodeURIComponent(name)}`
80
+ }
81
+
82
+ // gitOpsRouteForKind is a kind-only variant for callers that only have
83
+ // (kind, namespace, name) — e.g. Audit findings, which today don't carry the
84
+ // apiGroup of the resource they subject. Matches by kind alone, accepting the
85
+ // (very small) risk of routing a CRD named "Application" or "Kustomization"
86
+ // from a non-GitOps group to a "not found" GitOps detail page. When/if those
87
+ // callers grow apiGroup access, prefer gitOpsRouteForResource.
88
+ export function gitOpsRouteForKind(kind: string, namespace: string, name: string): string | null {
89
+ if (!kind || !name) return null
90
+ switch (kind) {
91
+ case 'Application':
92
+ return gitOpsDetailUrl('applications', namespace, name)
93
+ case 'ApplicationSet':
94
+ return gitOpsDetailUrl('applicationsets', namespace, name)
95
+ case 'AppProject':
96
+ return gitOpsDetailUrl('appprojects', namespace, name)
97
+ case 'Kustomization':
98
+ return gitOpsDetailUrl('kustomizations', namespace, name)
99
+ case 'HelmRelease':
100
+ return gitOpsDetailUrl('helmreleases', namespace, name)
101
+ default:
102
+ return null
103
+ }
104
+ }
@@ -14,3 +14,5 @@ export * from './k8s-errors'
14
14
  export * from './parse-go-time'
15
15
  export * from './view-transition'
16
16
  export * from './validators'
17
+ export * from './gitops-owner'
18
+ export * from './gitops-route'
@@ -142,3 +142,17 @@ export function refToSelectedResource(ref: ResourceRef): SelectedResource {
142
142
  group: ref.group,
143
143
  }
144
144
  }
145
+
146
+ /**
147
+ * Extract the API group from an apiVersion string.
148
+ * Returns '' for core resources (e.g. "v1") and for missing/empty input.
149
+ * Examples:
150
+ * "v1" → ""
151
+ * "apps/v1" → "apps"
152
+ * "cluster.x-k8s.io/v1beta1" → "cluster.x-k8s.io"
153
+ */
154
+ export function apiVersionToGroup(apiVersion?: string | null): string {
155
+ if (!apiVersion) return ''
156
+ const i = apiVersion.indexOf('/')
157
+ return i === -1 ? '' : apiVersion.slice(0, i)
158
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { TimelineEvent, Topology } from '../types/core'
15
15
  import { isWorkloadKind } from '../types/core'
16
+ import { apiVersionToGroup } from './navigation'
16
17
 
17
18
  /**
18
19
  * Resource lane representing a single resource and its timeline events.
@@ -21,6 +22,12 @@ import { isWorkloadKind } from '../types/core'
21
22
  export interface ResourceLane {
22
23
  id: string
23
24
  kind: string
25
+ /**
26
+ * API group for the resource (e.g. "cluster.x-k8s.io"). Empty for core
27
+ * resources. Needed to disambiguate CRDs whose kind collides with another
28
+ * (e.g. CAPI Cluster vs CNPG Cluster) when the lane is clicked.
29
+ */
30
+ group?: string
24
31
  namespace: string
25
32
  name: string
26
33
  events: TimelineEvent[]
@@ -179,6 +186,25 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
179
186
  const { events, topology, rootResource, groupByApp = true } = options
180
187
  const laneMap = new Map<string, ResourceLane>()
181
188
 
189
+ // API group lookup by lane ID (kind/namespace/name) sourced from topology nodes.
190
+ // Lanes built from events (which carry apiVersion) take precedence over this fallback,
191
+ // but parent lanes that exist only as edge endpoints still need a group.
192
+ const topoGroupByLaneId = new Map<string, string>()
193
+ if (topology?.nodes) {
194
+ for (const node of topology.nodes) {
195
+ const laneId = nodeIdToLaneId(node.id)
196
+ if (!laneId) continue
197
+ const apiVersion = node.data?.apiVersion as string | undefined
198
+ const group = apiVersionToGroup(apiVersion)
199
+ if (group) topoGroupByLaneId.set(laneId, group)
200
+ }
201
+ }
202
+ const resolveGroup = (laneId: string, event?: TimelineEvent): string => {
203
+ const fromEvent = apiVersionToGroup(event?.apiVersion)
204
+ if (fromEvent) return fromEvent
205
+ return topoGroupByLaneId.get(laneId) ?? ''
206
+ }
207
+
182
208
  // Track events that should be attached to their owner instead of their own lane
183
209
  const eventsToAttach: { event: TimelineEvent; ownerLaneId: string }[] = []
184
210
 
@@ -192,19 +218,28 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
192
218
  }
193
219
 
194
220
  const laneId = `${event.kind}/${event.namespace}/${event.name}`
195
- if (!laneMap.has(laneId)) {
221
+ const existing = laneMap.get(laneId)
222
+ if (!existing) {
196
223
  laneMap.set(laneId, {
197
224
  id: laneId,
198
225
  kind: event.kind,
226
+ group: resolveGroup(laneId, event),
199
227
  namespace: event.namespace,
200
228
  name: event.name,
201
- events: [],
229
+ events: [event],
202
230
  isWorkload: isWorkloadKind(event.kind),
203
231
  children: [],
204
232
  childEventCount: 0,
205
233
  })
234
+ } else {
235
+ // Stored events may lack apiVersion; upgrade the lane's group from any
236
+ // later event that carries it.
237
+ if (!existing.group) {
238
+ const fromEvent = apiVersionToGroup(event.apiVersion)
239
+ if (fromEvent) existing.group = fromEvent
240
+ }
241
+ existing.events.push(event)
206
242
  }
207
- laneMap.get(laneId)!.events.push(event)
208
243
  }
209
244
 
210
245
  // Attach K8s Events to their owner lanes
@@ -218,6 +253,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
218
253
  laneMap.set(ownerLaneId, {
219
254
  id: ownerLaneId,
220
255
  kind: parts[0],
256
+ group: resolveGroup(ownerLaneId),
221
257
  namespace: parts[1],
222
258
  name: parts.slice(2).join('/'),
223
259
  events: [event],
@@ -242,6 +278,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
242
278
  laneMap.set(ownerLaneId, {
243
279
  id: ownerLaneId,
244
280
  kind: eventWithOwner.owner.kind,
281
+ group: resolveGroup(ownerLaneId),
245
282
  namespace: lane.namespace,
246
283
  name: eventWithOwner.owner.name,
247
284
  events: [],
@@ -277,6 +314,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
277
314
  laneMap.set(sourceLaneId, {
278
315
  id: sourceLaneId,
279
316
  kind: parts[0],
317
+ group: resolveGroup(sourceLaneId),
280
318
  namespace: parts[1],
281
319
  name: parts.slice(2).join('/'),
282
320
  events: [],
@@ -304,6 +342,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
304
342
  laneMap.set(sourceLaneId, {
305
343
  id: sourceLaneId,
306
344
  kind: parts[0],
345
+ group: resolveGroup(sourceLaneId),
307
346
  namespace: parts[1],
308
347
  name: parts.slice(2).join('/'),
309
348
  events: [],
@@ -323,6 +362,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
323
362
  laneMap.set(targetLaneId, {
324
363
  id: targetLaneId,
325
364
  kind: parts[0],
365
+ group: resolveGroup(targetLaneId),
326
366
  namespace: parts[1],
327
367
  name: parts.slice(2).join('/'),
328
368
  events: [],
@@ -342,6 +382,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
342
382
  laneMap.set(targetLaneId, {
343
383
  id: targetLaneId,
344
384
  kind: parts[0],
385
+ group: resolveGroup(targetLaneId),
345
386
  namespace: parts[1],
346
387
  name: parts.slice(2).join('/'),
347
388
  events: [],
@@ -361,6 +402,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
361
402
  laneMap.set(sourceLaneId, {
362
403
  id: sourceLaneId,
363
404
  kind: parts[0],
405
+ group: resolveGroup(sourceLaneId),
364
406
  namespace: parts[1],
365
407
  name: parts.slice(2).join('/'),
366
408
  events: [],
@@ -382,6 +424,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
382
424
  laneMap.set(targetLaneId, {
383
425
  id: targetLaneId,
384
426
  kind: parts[0],
427
+ group: resolveGroup(targetLaneId),
385
428
  namespace: parts[1],
386
429
  name: parts.slice(2).join('/'),
387
430
  events: [],
@@ -582,6 +625,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
582
625
  const placeholderLane: ResourceLane = {
583
626
  id: rootLaneId,
584
627
  kind: rootResource.kind,
628
+ group: resolveGroup(rootLaneId),
585
629
  namespace: rootResource.namespace,
586
630
  name: rootResource.name,
587
631
  events: [],