@skyhook-io/k8s-ui 1.5.13 → 1.6.1

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 (57) hide show
  1. package/package.json +4 -4
  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/GitOpsDetailLayout.tsx +621 -0
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  8. package/src/components/gitops/RollbackDialog.tsx +117 -0
  9. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  10. package/src/components/gitops/detail-helpers.test.ts +97 -0
  11. package/src/components/gitops/detail-helpers.ts +112 -0
  12. package/src/components/gitops/index.ts +55 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  14. package/src/components/gitops/insights/index.ts +6 -0
  15. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  16. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  17. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  18. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  19. package/src/components/gitops/tree/index.ts +6 -0
  20. package/src/components/gitops/tree/merge.test.ts +240 -0
  21. package/src/components/gitops/tree/merge.ts +160 -0
  22. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  23. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  24. package/src/components/resources/ResourcesView.tsx +136 -30
  25. package/src/components/resources/index.ts +1 -1
  26. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  28. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  30. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  31. package/src/components/shared/EditableYamlView.tsx +28 -17
  32. package/src/components/shared/ManagedByChip.tsx +45 -0
  33. package/src/components/shared/index.ts +1 -0
  34. package/src/components/timeline/TimelineList.tsx +3 -3
  35. package/src/components/topology/TopologyGraph.tsx +3 -2
  36. package/src/components/ui/Tooltip.tsx +10 -1
  37. package/src/components/ui/drawer-components.tsx +9 -21
  38. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  39. package/src/components/workload/WorkloadView.tsx +66 -0
  40. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  41. package/src/index.ts +3 -0
  42. package/src/types/core.ts +48 -6
  43. package/src/types/gitops-insights.ts +193 -0
  44. package/src/types/gitops-tree.ts +57 -0
  45. package/src/types/index.ts +2 -0
  46. package/src/utils/badge-colors.ts +31 -1
  47. package/src/utils/format.ts +28 -0
  48. package/src/utils/gitops-owner.test.ts +95 -0
  49. package/src/utils/gitops-owner.ts +55 -0
  50. package/src/utils/gitops-route.test.ts +78 -0
  51. package/src/utils/gitops-route.ts +104 -0
  52. package/src/utils/helm-status.test.ts +50 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.ts +14 -0
  55. package/src/utils/resource-hierarchy.ts +47 -3
  56. package/src/utils/yaml.test.ts +101 -0
  57. package/src/utils/yaml.ts +26 -0
@@ -175,6 +175,34 @@ export function formatCPUMillicores(millicores: number): string {
175
175
  return formatCoresValue(cores)
176
176
  }
177
177
 
178
+ // =============================================================================
179
+ // Time Formatting
180
+ // =============================================================================
181
+
182
+ export function formatCompactAge(value?: string): string {
183
+ if (!value) return ''
184
+ const time = Date.parse(value)
185
+ if (!Number.isFinite(time)) return ''
186
+ const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000))
187
+ if (seconds < 60) return `${seconds}s`
188
+ const minutes = Math.floor(seconds / 60)
189
+ if (minutes < 60) return `${minutes}m`
190
+ const hours = Math.floor(minutes / 60)
191
+ if (hours < 24) return `${hours}h`
192
+ return `${Math.floor(hours / 24)}d`
193
+ }
194
+
195
+ export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
196
+ if (!value) return fallback
197
+ const time = Date.parse(value)
198
+ if (!Number.isFinite(time)) return value
199
+ const diff = Date.now() - time
200
+ if (diff < 0) return new Date(time).toLocaleString()
201
+ const compact = formatCompactAge(value)
202
+ if (!compact) return fallback
203
+ return compact === '0s' ? 'just now' : `${compact} ago`
204
+ }
205
+
178
206
  /**
179
207
  * Format memory MiB to human-readable string.
180
208
  * Used by dashboard API which returns memory in MiB.
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import type { Relationships } from '../types/core'
4
+
5
+ import { gitOpsOwnerFromRelationships } from './gitops-owner'
6
+
7
+ describe('gitOpsOwnerFromRelationships', () => {
8
+ it('returns null when relationships is undefined', () => {
9
+ expect(gitOpsOwnerFromRelationships(undefined)).toBeNull()
10
+ })
11
+
12
+ it('returns null when managedBy is absent', () => {
13
+ expect(gitOpsOwnerFromRelationships({})).toBeNull()
14
+ })
15
+
16
+ it('returns null when managedBy is empty', () => {
17
+ expect(gitOpsOwnerFromRelationships({ managedBy: [] })).toBeNull()
18
+ })
19
+
20
+ it('maps an ArgoCD Application ref', () => {
21
+ const rel: Relationships = {
22
+ managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'storefront' }],
23
+ }
24
+ expect(gitOpsOwnerFromRelationships(rel)).toEqual({
25
+ tool: 'argocd',
26
+ kind: 'applications',
27
+ namespace: 'argocd',
28
+ name: 'storefront',
29
+ })
30
+ })
31
+
32
+ it('maps a Flux Kustomization ref', () => {
33
+ const rel: Relationships = {
34
+ managedBy: [{ kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', namespace: 'flux-system', name: 'prod-apps' }],
35
+ }
36
+ expect(gitOpsOwnerFromRelationships(rel)).toEqual({
37
+ tool: 'fluxcd',
38
+ kind: 'kustomizations',
39
+ namespace: 'flux-system',
40
+ name: 'prod-apps',
41
+ })
42
+ })
43
+
44
+ it('maps a Flux HelmRelease ref', () => {
45
+ const rel: Relationships = {
46
+ managedBy: [{ kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', namespace: 'flux-system', name: 'cert-manager' }],
47
+ }
48
+ expect(gitOpsOwnerFromRelationships(rel)).toEqual({
49
+ tool: 'fluxcd',
50
+ kind: 'helmreleases',
51
+ namespace: 'flux-system',
52
+ name: 'cert-manager',
53
+ })
54
+ })
55
+
56
+ it('disambiguates Flux HelmRelease from native Helm via API group', () => {
57
+ // A native Helm release would not carry the helm.toolkit.fluxcd.io group;
58
+ // server-side SynthesizeManagedBy only assigns that group for Flux.
59
+ const rel: Relationships = {
60
+ managedBy: [{ kind: 'HelmRelease', group: 'helm.sh', namespace: 'default', name: 'native-helm' }],
61
+ }
62
+ expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
63
+ })
64
+
65
+ it('returns null for native K8s owners (no GitOps chip)', () => {
66
+ const rel: Relationships = {
67
+ managedBy: [{ kind: 'Deployment', namespace: 'prod', name: 'web' }],
68
+ }
69
+ expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
70
+ })
71
+
72
+ it('returns null for an unrecognized manager kind', () => {
73
+ const rel: Relationships = {
74
+ managedBy: [{ kind: 'CustomController', group: 'example.com', namespace: 'prod', name: 'thing' }],
75
+ }
76
+ expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
77
+ })
78
+
79
+ it('only considers the first managedBy entry', () => {
80
+ // The server emits the topmost meaningful manager first; if there are
81
+ // multiple, downstream chips intentionally only render the primary.
82
+ const rel: Relationships = {
83
+ managedBy: [
84
+ { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'primary' },
85
+ { kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', namespace: 'flux-system', name: 'secondary' },
86
+ ],
87
+ }
88
+ expect(gitOpsOwnerFromRelationships(rel)).toEqual({
89
+ tool: 'argocd',
90
+ kind: 'applications',
91
+ namespace: 'argocd',
92
+ name: 'primary',
93
+ })
94
+ })
95
+ })
@@ -0,0 +1,55 @@
1
+ // Map a resource's server-side `Relationships.managedBy` to a navigable
2
+ // GitOps owner ref, used by the drawer to render the "Managed by <app>" chip.
3
+ //
4
+ // The detection (label/annotation parsing, Argo tracking-id decoding, Flux
5
+ // label inspection, Helm release annotation, owner-chain walk) lives server-
6
+ // side in pkg/topology.SynthesizeManagedBy. This file is now a thin mapper
7
+ // from the structured ref into the discriminated union the chip + router need.
8
+
9
+ import type { Relationships, ResourceRef } from '../types/core'
10
+
11
+ // GitOpsOwnerRef is a discriminated union — the tool determines which kind is
12
+ // valid. Modeling it this way prevents callers (and consumers of the returned
13
+ // type) from constructing `{ tool: 'argo', kind: 'helmreleases' }` which would
14
+ // route to a non-existent page.
15
+ export type GitOpsOwnerRef =
16
+ | { tool: 'argocd'; kind: 'applications'; namespace: string; name: string }
17
+ | { tool: 'fluxcd'; kind: 'kustomizations' | 'helmreleases'; namespace: string; name: string }
18
+
19
+ // Vocabulary mirrors `pkg/gitops/tree.Tool` so the wire labels match end-to-end.
20
+ export type GitOpsOwnerTool = GitOpsOwnerRef['tool']
21
+
22
+ // API groups for GitOps manager kinds. Used to disambiguate Flux HelmRelease
23
+ // from a future native-Helm kind, etc.
24
+ const ARGO_APPLICATION_GROUP = 'argoproj.io'
25
+ const FLUX_KUSTOMIZE_GROUP = 'kustomize.toolkit.fluxcd.io'
26
+ const FLUX_HELM_GROUP = 'helm.toolkit.fluxcd.io'
27
+
28
+ // gitOpsOwnerFromRelationships reads relationships.managedBy[0] and maps it to
29
+ // a GitOpsOwnerRef when the manager is a GitOps controller. Returns null when
30
+ // the manager is a native K8s owner (Deployment, ReplicaSet, etc.), a plain
31
+ // Helm release, or when no manager is reported.
32
+ //
33
+ // Old radar binaries (pre-T2) emit no managedBy field; this returns null in
34
+ // that case and the drawer silently skips the chip — natural pressure to
35
+ // upgrade rather than carry a label-parsing fallback in the client.
36
+ export function gitOpsOwnerFromRelationships(
37
+ rel: Relationships | undefined | null,
38
+ ): GitOpsOwnerRef | null {
39
+ const refs = rel?.managedBy
40
+ if (!refs || refs.length === 0) return null
41
+ return refToGitOpsOwner(refs[0])
42
+ }
43
+
44
+ function refToGitOpsOwner(ref: ResourceRef): GitOpsOwnerRef | null {
45
+ switch (true) {
46
+ case ref.kind === 'Application' && ref.group === ARGO_APPLICATION_GROUP:
47
+ return { tool: 'argocd', kind: 'applications', namespace: ref.namespace, name: ref.name }
48
+ case ref.kind === 'Kustomization' && ref.group === FLUX_KUSTOMIZE_GROUP:
49
+ return { tool: 'fluxcd', kind: 'kustomizations', namespace: ref.namespace, name: ref.name }
50
+ case ref.kind === 'HelmRelease' && ref.group === FLUX_HELM_GROUP:
51
+ return { tool: 'fluxcd', kind: 'helmreleases', namespace: ref.namespace, name: ref.name }
52
+ default:
53
+ return null
54
+ }
55
+ }
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isHelmReleaseActionable } from './badge-colors'
3
+
4
+ // Pin the membership set so adding a new Helm status becomes a
5
+ // deliberate decision here.
6
+
7
+ describe('isHelmReleaseActionable', () => {
8
+ it('returns true for `failed`', () => {
9
+ expect(isHelmReleaseActionable('failed')).toBe(true)
10
+ })
11
+
12
+ it('is case-insensitive (matches Helm SDK serialisation variants)', () => {
13
+ expect(isHelmReleaseActionable('FAILED')).toBe(true)
14
+ expect(isHelmReleaseActionable('Failed')).toBe(true)
15
+ })
16
+
17
+ it('returns FALSE for the pending-* in-flight statuses', () => {
18
+ // These are Helm's NORMAL in-flight states during every
19
+ // routine install/upgrade/rollback. If we treated them as
20
+ // actionable, every routine install would briefly attach an
21
+ // alarming chevron + tooltip to its own row. Until we have
22
+ // release age available client-side to distinguish
23
+ // "transient" from "stuck > N min", we give up the
24
+ // stuck-controller signpost rather than alarm the common case.
25
+ expect(isHelmReleaseActionable('pending-install')).toBe(false)
26
+ expect(isHelmReleaseActionable('pending-upgrade')).toBe(false)
27
+ expect(isHelmReleaseActionable('pending-rollback')).toBe(false)
28
+ })
29
+
30
+ it('returns false for the success / normal statuses', () => {
31
+ expect(isHelmReleaseActionable('deployed')).toBe(false)
32
+ expect(isHelmReleaseActionable('superseded')).toBe(false)
33
+ expect(isHelmReleaseActionable('uninstalled')).toBe(false)
34
+ })
35
+
36
+ it('returns false for `uninstalling` (in-progress, not stuck)', () => {
37
+ expect(isHelmReleaseActionable('uninstalling')).toBe(false)
38
+ })
39
+
40
+ it('returns false for null / undefined / empty', () => {
41
+ expect(isHelmReleaseActionable(null)).toBe(false)
42
+ expect(isHelmReleaseActionable(undefined)).toBe(false)
43
+ expect(isHelmReleaseActionable('')).toBe(false)
44
+ })
45
+
46
+ it('returns false for unknown strings (defensive)', () => {
47
+ expect(isHelmReleaseActionable('mystery-status')).toBe(false)
48
+ expect(isHelmReleaseActionable('error')).toBe(false) // Helm uses 'failed', not 'error'
49
+ })
50
+ })
@@ -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: [],