@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.
- package/package.json +3 -3
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
- package/src/components/dock/BottomDock.tsx +24 -17
- package/src/components/dock/DockContext.tsx +39 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
- package/src/components/gitops/insights/index.ts +6 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
- package/src/components/gitops/insights/insights-helpers.ts +99 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +4 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesView.tsx +71 -18
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -3
- package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
- package/src/components/shared/EditableYamlView.tsx +28 -17
- package/src/components/shared/ManagedByChip.tsx +45 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +3 -3
- package/src/components/topology/TopologyGraph.tsx +3 -2
- package/src/components/ui/Tooltip.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +66 -0
- package/src/hooks/useKeyboardShortcuts.tsx +3 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +19 -2
- package/src/types/gitops-insights.ts +193 -0
- package/src/types/gitops-tree.ts +57 -0
- package/src/types/index.ts +2 -0
- package/src/utils/badge-colors.ts +6 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +136 -0
- package/src/utils/gitops-owner.ts +92 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.ts +14 -0
- package/src/utils/resource-hierarchy.ts +47 -3
- package/src/utils/yaml.test.ts +101 -0
- 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
|
+
}
|
package/src/utils/index.ts
CHANGED
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
-
|
|
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: [],
|