@skyhook-io/k8s-ui 1.6.0 → 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.
@@ -724,7 +724,8 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
724
724
  (relationships.configRefs && relationships.configRefs.length > 0) ||
725
725
  (relationships.consumers && relationships.consumers.length > 0) ||
726
726
  (relationships.scalers && relationships.scalers.length > 0) ||
727
- (relationships.policies && relationships.policies.length > 0) ||
727
+ (relationships.pdbs && relationships.pdbs.length > 0) ||
728
+ (relationships.networkPolicies && relationships.networkPolicies.length > 0) ||
728
729
  relationships.scaleTarget
729
730
 
730
731
  if (!hasRelationships) return null
@@ -765,25 +766,12 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
765
766
  {relationships.scalers && relationships.scalers.length > 0 && (
766
767
  <RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
767
768
  )}
768
- {relationships.policies && relationships.policies.length > 0 && (() => {
769
- const policyKinds = new Set(['NetworkPolicy', 'CiliumNetworkPolicy', 'CiliumClusterwideNetworkPolicy', 'ClusterNetworkPolicy'])
770
- const pdbs = relationships.policies.filter(r => r.kind === 'PodDisruptionBudget')
771
- const netpols = relationships.policies.filter(r => policyKinds.has(r.kind))
772
- const other = relationships.policies.filter(r => r.kind !== 'PodDisruptionBudget' && !policyKinds.has(r.kind))
773
- return (
774
- <>
775
- {pdbs.length > 0 && (
776
- <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(pdbs)} onNavigate={onNavigate} />
777
- )}
778
- {netpols.length > 0 && (
779
- <RelationshipGroup label="Network Policies" refs={dedupeRefs(netpols)} onNavigate={onNavigate} />
780
- )}
781
- {other.length > 0 && (
782
- <RelationshipGroup label="Policies" refs={dedupeRefs(other)} onNavigate={onNavigate} />
783
- )}
784
- </>
785
- )
786
- })()}
769
+ {relationships.pdbs && relationships.pdbs.length > 0 && (
770
+ <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.pdbs)} onNavigate={onNavigate} />
771
+ )}
772
+ {relationships.networkPolicies && relationships.networkPolicies.length > 0 && (
773
+ <RelationshipGroup label="Network Policies" refs={dedupeRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
774
+ )}
787
775
  {relationships.scaleTarget && (
788
776
  <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
789
777
  )}
@@ -23,7 +23,7 @@ import {
23
23
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom } from '../../types'
24
24
  import type { NavigateToResource } from '../../utils/navigation'
25
25
  import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
26
- import { detectGitOpsOwner, type GitOpsOwnerRef } from '../../utils/gitops-owner'
26
+ import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
27
27
  import { gitOpsRouteForResource } from '../../utils/gitops-route'
28
28
  import { isChangeEvent, isHistoricalEvent } from '../../types'
29
29
  import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
@@ -311,7 +311,7 @@ export function WorkloadView({
311
311
 
312
312
  // Metadata
313
313
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
314
- const gitopsOwner = useMemo(() => detectGitOpsOwner(resource), [resource])
314
+ const gitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
315
315
  // When the resource itself is a portal GitOps CR (Application, Kustomization,
316
316
  // HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
317
317
  // the drawer's renderer is thorough but the tab has the tree + insights +
package/src/types/core.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Topology types matching the Go backend
2
2
 
3
- // Per-resource-type RBAC permissions (matches backend k8s.ResourcePermissions)
3
+ // Per-resource-type RBAC permissions. Field names must match the JSON keys
4
+ // produced by ResourcePermissions in internal/k8s/capabilities.go — there
5
+ // is no automated check across the Go/TS boundary.
4
6
  export interface ResourcePermissions {
5
7
  pods: boolean
6
8
  services: boolean
@@ -12,20 +14,39 @@ export interface ResourcePermissions {
12
14
  configMaps: boolean
13
15
  secrets: boolean
14
16
  events: boolean
15
- pvcs: boolean
17
+ persistentVolumeClaims: boolean
16
18
  nodes: boolean
17
19
  namespaces: boolean
18
20
  jobs: boolean
19
21
  cronJobs: boolean
20
- hpas: boolean
21
- gateways: boolean
22
- httpRoutes: boolean
22
+ horizontalPodAutoscalers: boolean
23
+ persistentVolumes: boolean
24
+ storageClasses: boolean
25
+ podDisruptionBudgets: boolean
26
+ networkPolicies: boolean
27
+ serviceAccounts: boolean
23
28
  roles: boolean
24
29
  clusterRoles: boolean
25
30
  roleBindings: boolean
26
31
  clusterRoleBindings: boolean
32
+ limitRanges: boolean
33
+ gateways: boolean
34
+ httpRoutes: boolean
35
+ verticalPodAutoscalers: boolean
27
36
  }
28
37
 
38
+ // Keys in ResourcePermissions that represent optional CRDs Radar can monitor
39
+ // when they're installed in the cluster. A `false` here means "CRD not
40
+ // installed (or RBAC denied)" — NOT "Radar is missing data the user expects",
41
+ // so banners about RBAC restrictions should ignore these keys.
42
+ //
43
+ // Keep in sync with dynamicCapabilityKinds in internal/k8s/capabilities_alignment_test.go.
44
+ export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray<keyof ResourcePermissions> = [
45
+ 'gateways',
46
+ 'httpRoutes',
47
+ 'verticalPodAutoscalers',
48
+ ]
49
+
29
50
  // Feature capabilities based on RBAC permissions
30
51
  export interface Capabilities {
31
52
  exec: boolean // Terminal feature (pods/exec)
@@ -394,6 +415,7 @@ export interface ResourceRef {
394
415
  export interface Relationships {
395
416
  owner?: ResourceRef
396
417
  deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
418
+ managedBy?: ResourceRef[] // Topmost meaningful manager(s): GitOps controller (ArgoCD Application / Flux Kustomization / Flux HelmRelease), Helm release, or the topmost K8s owner. Synthesized server-side; replaces client-side detectGitOpsOwner.
397
419
  children?: ResourceRef[]
398
420
  services?: ResourceRef[]
399
421
  ingresses?: ResourceRef[]
@@ -403,8 +425,11 @@ export interface Relationships {
403
425
  consumers?: ResourceRef[]
404
426
  scalers?: ResourceRef[]
405
427
  scaleTarget?: ResourceRef
406
- policies?: ResourceRef[]
428
+ pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
429
+ networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
407
430
  pods?: ResourceRef[]
431
+ serviceAccount?: ResourceRef // For Pods: derived from pod.spec.serviceAccountName
432
+ node?: ResourceRef // For scheduled Pods: derived from pod.spec.nodeName
408
433
  }
409
434
 
410
435
  // Parsed X.509 certificate metadata (from backend cert parsing)
@@ -286,6 +286,31 @@ export function getHelmStatusColor(status: string): string {
286
286
  return HELM_STATUS_COLORS[statusLower] || 'bg-theme-hover/50 text-theme-text-secondary'
287
287
  }
288
288
 
289
+ /**
290
+ * Helm release statuses where the row UI should signpost the user
291
+ * toward the drawer (history / rollback / logs).
292
+ *
293
+ * Currently `failed` only. The `pending-*` statuses (install /
294
+ * upgrade / rollback) are excluded deliberately: they're Helm's
295
+ * normal in-flight states during every routine operation. Treating
296
+ * them as "actionable" would briefly attach an alarming chevron +
297
+ * tooltip to every install while it ran — indistinguishable from
298
+ * the genuinely-stuck case (controller crashed mid-flight). Until
299
+ * we have release age available client-side to disambiguate
300
+ * "in-flight" from "stuck > N min", we give up the stuck-detect
301
+ * signpost rather than wrongly alarm the common case.
302
+ *
303
+ * @see https://github.com/helm/helm/blob/dev-v3/pkg/release/status.go
304
+ */
305
+ const ACTIONABLE_HELM_STATUSES: ReadonlySet<string> = new Set([
306
+ 'failed',
307
+ ])
308
+
309
+ export function isHelmReleaseActionable(status: string | null | undefined): boolean {
310
+ if (!status) return false
311
+ return ACTIONABLE_HELM_STATUSES.has(status.toLowerCase())
312
+ }
313
+
289
314
  // =============================================================================
290
315
  // VULNERABILITY SEVERITY COLORS - for Trivy and other security scanners
291
316
  // =============================================================================
@@ -1,136 +1,95 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { detectGitOpsOwner } from './gitops-owner'
3
2
 
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'
3
+ import type { Relationships } from '../types/core'
11
4
 
12
- function resource(labels: Record<string, string> = {}, annotations: Record<string, string> = {}) {
13
- return { metadata: { labels, annotations } }
14
- }
5
+ import { gitOpsOwnerFromRelationships } from './gitops-owner'
15
6
 
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()
7
+ describe('gitOpsOwnerFromRelationships', () => {
8
+ it('returns null when relationships is undefined', () => {
9
+ expect(gitOpsOwnerFromRelationships(undefined)).toBeNull()
21
10
  })
22
11
 
23
- it('returns null when no GitOps labels/annotations present', () => {
24
- expect(detectGitOpsOwner(resource({ app: 'foo' }))).toBeNull()
12
+ it('returns null when managedBy is absent', () => {
13
+ expect(gitOpsOwnerFromRelationships({})).toBeNull()
25
14
  })
26
15
 
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
- })
16
+ it('returns null when managedBy is empty', () => {
17
+ expect(gitOpsOwnerFromRelationships({ managedBy: [] })).toBeNull()
51
18
  })
52
19
 
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' })
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',
60
29
  })
61
30
  })
62
31
 
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()
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',
88
41
  })
42
+ })
89
43
 
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')
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',
97
53
  })
98
54
  })
99
55
 
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
- })
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
+ })
105
64
 
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
- })
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
+ })
113
71
 
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
- })
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()
121
77
  })
122
78
 
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')
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',
134
93
  })
135
94
  })
136
95
  })
@@ -1,13 +1,12 @@
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.
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.
4
3
  //
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)
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'
11
10
 
12
11
  // GitOpsOwnerRef is a discriminated union — the tool determines which kind is
13
12
  // valid. Modeling it this way prevents callers (and consumers of the returned
@@ -20,73 +19,37 @@ export type GitOpsOwnerRef =
20
19
  // Vocabulary mirrors `pkg/gitops/tree.Tool` so the wire labels match end-to-end.
21
20
  export type GitOpsOwnerTool = GitOpsOwnerRef['tool']
22
21
 
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
- }
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'
55
27
 
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
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])
72
42
  }
73
43
 
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
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
87
54
  }
88
- const namespace = head.slice(0, sep)
89
- const name = head.slice(sep + 1)
90
- if (!name) return null
91
- return { namespace, name }
92
55
  }
@@ -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
+ })