@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.
- package/package.json +4 -4
- 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/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +55 -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/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +6 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +136 -30
- 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 +9 -21
- 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 +48 -6
- 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 +31 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +95 -0
- package/src/utils/gitops-owner.ts +55 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/helm-status.test.ts +50 -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
package/src/utils/format.ts
CHANGED
|
@@ -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
|
+
})
|
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: [],
|