@skyhook-io/k8s-ui 1.17.0 → 1.18.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/README.md +41 -0
- package/package.json +6 -6
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
- package/src/components/gitops/tree/tree-helpers.ts +12 -2
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +44 -6
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +8 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +75 -1
- package/src/components/issues/severity.ts +30 -2
- package/src/components/issues/types.ts +55 -4
- package/src/components/logs/LogCore.theme.test.tsx +206 -3
- package/src/components/logs/LogCore.tsx +180 -53
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +369 -0
- package/src/components/logs/LogsViewer.tsx +91 -13
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +23 -31
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +392 -181
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +96 -7
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +14 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +4 -2
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +193 -71
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +53 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +20 -2
- package/src/types/gitops-tree.ts +12 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { getSubtitle } from './GitOpsTreeGraph'
|
|
4
|
-
import type { GitOpsTreeNode } from '../../../types'
|
|
3
|
+
import { buildCauseByRef, getSubtitle, nodeCause } from './GitOpsTreeGraph'
|
|
4
|
+
import type { GitOpsIssue, GitOpsTreeNode } from '../../../types'
|
|
5
5
|
|
|
6
6
|
function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
|
|
7
7
|
return {
|
|
@@ -14,6 +14,9 @@ function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
describe('getSubtitle', () => {
|
|
17
|
+
it('shows confirmed absence even when controller health is unknown or stale', () => {
|
|
18
|
+
expect(getSubtitle(node({ tool: 'argocd', missing: true, sync: 'OutOfSync', health: 'Healthy' }))).toBe('OutOfSync • Not found in cluster')
|
|
19
|
+
})
|
|
17
20
|
it('shows what a healthy resource exposes, not that it is healthy', () => {
|
|
18
21
|
// The status dot and the left border stripe already paint a healthy node
|
|
19
22
|
// green, so the word adds nothing and the ports are the only thing here the
|
|
@@ -90,3 +93,89 @@ describe('getSubtitle', () => {
|
|
|
90
93
|
expect(getSubtitle(node({ ref: { kind: 'ClusterRole', name: 'podinfo', namespace: '' } }))).toBe('')
|
|
91
94
|
})
|
|
92
95
|
})
|
|
96
|
+
|
|
97
|
+
describe('nodeCause', () => {
|
|
98
|
+
// Shapes as the tree API sends them: the Go side maps Argo's Degraded and
|
|
99
|
+
// Missing to topologyStatus 'unhealthy', Progressing and Suspended to
|
|
100
|
+
// 'degraded' (pkg/gitops/tree healthToTopology).
|
|
101
|
+
const FAILURE = { text: 'Back-off restarting failed container main', lead: false }
|
|
102
|
+
const LEAD = { text: 'Elasticsearch cluster health degraded', lead: true }
|
|
103
|
+
|
|
104
|
+
it('shows the cause on a node the controller calls Degraded', () => {
|
|
105
|
+
expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }), FAILURE)).toBe(FAILURE.text)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('hides a failure verdict on a Progressing node, even when an issue matched it', () => {
|
|
109
|
+
// A rollout in flight is not broken; a cause tooltip on it says it is.
|
|
110
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
111
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('hides it on a Suspended node, even when an issue matched it', () => {
|
|
115
|
+
expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
116
|
+
expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), LEAD)).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('never shows it on a healthy node', () => {
|
|
120
|
+
expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }), FAILURE)).toBeUndefined()
|
|
121
|
+
expect(nodeCause(node({ health: 'Healthy', healthSource: 'radar', topologyStatus: 'healthy' }), LEAD)).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('shows a lead on a node that is not failing, framed as possible', () => {
|
|
125
|
+
// Argo CD 3 does not persist per-resource health, so an Argo-Degraded app
|
|
126
|
+
// can have every node read Progressing or unknown by Radar. The events
|
|
127
|
+
// lead is then the only pointer, and it lands on one of those nodes.
|
|
128
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), LEAD))
|
|
129
|
+
.toBe('Possible cause: Elasticsearch cluster health degraded')
|
|
130
|
+
expect(nodeCause(node({ ref: { group: 'elasticsearch.k8s.elastic.co', kind: 'Elasticsearch', name: 'logs', namespace: 'search' }, topologyStatus: 'unknown' }), LEAD))
|
|
131
|
+
.toBe('Possible cause: Elasticsearch cluster health degraded')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('shows a Flux source fetch error on the source while Flux retries it', () => {
|
|
135
|
+
const source = node({
|
|
136
|
+
ref: { group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', name: 'podinfo', namespace: 'flux-system' },
|
|
137
|
+
health: 'Progressing',
|
|
138
|
+
topologyStatus: 'degraded',
|
|
139
|
+
})
|
|
140
|
+
// Keyed like the tree's own nodes, so the issue's ref finds this node.
|
|
141
|
+
const cause = buildCauseByRef([{
|
|
142
|
+
severity: 'warning',
|
|
143
|
+
scope: 'tree',
|
|
144
|
+
reason: 'SourceNotReady',
|
|
145
|
+
message: 'GitRepository flux-system/podinfo can\'t fetch updates.',
|
|
146
|
+
cause: 'authentication required',
|
|
147
|
+
refs: [{ group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', namespace: 'flux-system', name: 'podinfo' }],
|
|
148
|
+
}]).get('source.toolkit.fluxcd.io/GitRepository/flux-system/podinfo')
|
|
149
|
+
expect(nodeCause(source, cause)).toBe('Possible cause: authentication required')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('shows a lead as is on a failing node', () => {
|
|
153
|
+
expect(nodeCause(node({ health: 'Degraded', topologyStatus: 'unhealthy' }), LEAD)).toBe(LEAD.text)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('buildCauseByRef', () => {
|
|
158
|
+
const ref = { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }
|
|
159
|
+
function issue(extras: Partial<GitOpsIssue>): GitOpsIssue {
|
|
160
|
+
return { severity: 'critical', scope: 'resource', reason: 'Degraded', message: 'Deployment web is Degraded', cause: 'crashloop', refs: [ref], ...extras }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('marks the Warning-event lead and the Flux blockers as leads, and nothing else', () => {
|
|
164
|
+
const lead = (reason: string) => [...buildCauseByRef([issue({ reason })]).values()][0]?.lead
|
|
165
|
+
expect(lead('PossibleCause')).toBe(true)
|
|
166
|
+
expect(lead('SourceNotReady')).toBe(true)
|
|
167
|
+
expect(lead('ArtifactFailed')).toBe(true)
|
|
168
|
+
expect(lead('DependencyNotReady')).toBe(true)
|
|
169
|
+
expect(lead('Degraded')).toBe(false)
|
|
170
|
+
expect(lead('Failed')).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('keeps the first, most severe cause for a node', () => {
|
|
174
|
+
const map = buildCauseByRef([issue({ cause: 'crashloop' }), issue({ severity: 'warning', reason: 'PossibleCause', cause: 'probe failed' })])
|
|
175
|
+
expect([...map.values()]).toEqual([{ text: 'crashloop', lead: false }])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('skips issues without a cause or a ref', () => {
|
|
179
|
+
expect(buildCauseByRef([issue({ cause: undefined }), issue({ refs: [] })]).size).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -28,7 +28,8 @@ import { radarHealthNote } from '../health-provenance'
|
|
|
28
28
|
import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
|
|
29
29
|
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
30
30
|
import { Tooltip } from '../../ui/Tooltip'
|
|
31
|
-
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
31
|
+
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, treeNodeHealth, type GitOpsTreeFilters } from './tree-helpers'
|
|
32
|
+
import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
|
|
32
33
|
|
|
33
34
|
export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
|
|
34
35
|
|
|
@@ -123,19 +124,7 @@ function GitOpsTreeGraphInner({
|
|
|
123
124
|
useEffect(() => {
|
|
124
125
|
if (preset !== 'compact') setExpandedGroups(new Set())
|
|
125
126
|
}, [preset])
|
|
126
|
-
const causeByRef = useMemo(() =>
|
|
127
|
-
const map = new Map<string, string>()
|
|
128
|
-
for (const issue of issues ?? []) {
|
|
129
|
-
const cause = issue.cause
|
|
130
|
-
const ref = issue.refs?.[0]
|
|
131
|
-
if (!cause || !ref) continue
|
|
132
|
-
const key = refKey(ref)
|
|
133
|
-
// First match wins — issues arrive ranked by severity, so a node
|
|
134
|
-
// that's the subject of multiple issues keeps its most severe cause.
|
|
135
|
-
if (!map.has(key)) map.set(key, cause)
|
|
136
|
-
}
|
|
137
|
-
return map
|
|
138
|
-
}, [issues])
|
|
127
|
+
const causeByRef = useMemo(() => buildCauseByRef(issues), [issues])
|
|
139
128
|
const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
|
|
140
129
|
|
|
141
130
|
useEffect(() => {
|
|
@@ -231,7 +220,7 @@ function GitOpsTreeGraphInner({
|
|
|
231
220
|
function EdgeLegend({ edges }: { edges: Edge[] }) {
|
|
232
221
|
const types = new Set(edges.map((e) => e.data?.type as string).filter(Boolean))
|
|
233
222
|
const rows: Array<{ key: string; color: string; label: string }> = []
|
|
234
|
-
if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source
|
|
223
|
+
if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source artifact' })
|
|
235
224
|
if (types.has('dependsOn')) rows.push({ key: 'dependsOn', color: getEdgeColor('dependsOn'), label: 'Depends on' })
|
|
236
225
|
// Always show the default ownership color when there are 2+ edge kinds —
|
|
237
226
|
// operators need to compare against it. Hide when "owns" is the only kind.
|
|
@@ -329,20 +318,14 @@ function getEdgeColor(type: string): string {
|
|
|
329
318
|
}
|
|
330
319
|
}
|
|
331
320
|
|
|
332
|
-
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string,
|
|
321
|
+
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, NodeCause>): { nodes: Node[]; edges: Edge[] } {
|
|
333
322
|
if (!tree) return { nodes: [], edges: [] }
|
|
334
323
|
const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
|
|
335
324
|
const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
|
|
336
|
-
const
|
|
337
|
-
const incoming = new Map<string, number>()
|
|
338
|
-
for (const edge of visibleTree.edges) {
|
|
339
|
-
if (!byID.has(edge.source) || !byID.has(edge.target)) continue
|
|
340
|
-
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
341
|
-
incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const ranks = assignRanks(visibleTree.root.id, byID, children, incoming)
|
|
325
|
+
const ranks = groupRanks(rankTree(visibleTree), byID)
|
|
345
326
|
const positioned = positionRanks(ranks, byID)
|
|
327
|
+
const upstreamTargets = new Set(upstreamEdges(visibleTree).map(edge => edge.target))
|
|
328
|
+
const shortcuts = shortcutEdges(visibleTree)
|
|
346
329
|
const normalizedQuery = query.trim().toLowerCase()
|
|
347
330
|
const nodes = Array.from(positioned.entries()).map(([id, position]) => {
|
|
348
331
|
const node = byID.get(id)!
|
|
@@ -359,11 +342,10 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
|
|
|
359
342
|
})
|
|
360
343
|
|
|
361
344
|
const edges = visibleTree.edges
|
|
362
|
-
.filter(edge => positioned.has(edge.source) && positioned.has(edge.target))
|
|
345
|
+
.filter(edge => positioned.has(edge.source) && positioned.has(edge.target) && !shortcuts.has(edge))
|
|
363
346
|
.map(edge => ({
|
|
364
347
|
id: `${edge.source}->${edge.target}`,
|
|
365
|
-
|
|
366
|
-
target: edge.target,
|
|
348
|
+
...renderedEdge(edge, upstreamTargets),
|
|
367
349
|
type: 'smoothstep',
|
|
368
350
|
// The legend reads back edge.data?.type to decide which rows to render —
|
|
369
351
|
// ReactFlow doesn't preserve our custom `type` field at the top level
|
|
@@ -394,7 +376,9 @@ function applyPreset(tree: GitOpsResourceTree, preset: GitOpsTreePreset, expande
|
|
|
394
376
|
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
395
377
|
}
|
|
396
378
|
|
|
397
|
-
|
|
379
|
+
// Dependencies answer "where does this come from" and are few, so every
|
|
380
|
+
// preset keeps them.
|
|
381
|
+
const keep = new Set<string>([tree.root.id, ...upstreamClosure(tree)])
|
|
398
382
|
if (preset === 'app') {
|
|
399
383
|
for (const edge of tree.edges) {
|
|
400
384
|
if (edge.source === tree.root.id) keep.add(edge.target)
|
|
@@ -438,7 +422,7 @@ function applyGraphFilters(tree: GitOpsResourceTree, filters?: GitOpsTreeFilters
|
|
|
438
422
|
if (node.id === tree.root.id) continue
|
|
439
423
|
if (matchesGitOpsTreeFilters(node, filters)) {
|
|
440
424
|
let current: string | undefined = node.id
|
|
441
|
-
while (current) {
|
|
425
|
+
while (current && !keep.has(current)) {
|
|
442
426
|
keep.add(current)
|
|
443
427
|
current = parent.get(current)
|
|
444
428
|
}
|
|
@@ -506,43 +490,10 @@ function compactInfra(tree: GitOpsResourceTree, expandedGroups?: Set<string>): G
|
|
|
506
490
|
}
|
|
507
491
|
}
|
|
508
492
|
|
|
509
|
-
function
|
|
510
|
-
rootID: string,
|
|
511
|
-
nodes: Map<string, GitOpsTreeNode>,
|
|
512
|
-
children: Map<string, string[]>,
|
|
513
|
-
incoming: Map<string, number>
|
|
514
|
-
): Map<number, string[]> {
|
|
515
|
-
const rankByID = new Map<string, number>()
|
|
516
|
-
const queue = [{ id: rootID, rank: 0 }]
|
|
517
|
-
// Cycle/explosion guard: GitOps trees are owner-ref DAGs in practice, but
|
|
518
|
-
// the EdgeManages set we feed in could pathologically include a cycle
|
|
519
|
-
// (e.g. a CRD whose owner ref points back at one of its own children, or
|
|
520
|
-
// a malformed inventory). Without this cap, the "previous >= rank" skip
|
|
521
|
-
// never fires for cyclic edges and the loop runs forever, freezing the
|
|
522
|
-
// UI thread. Cap at a large multiple of node count so legitimate trees
|
|
523
|
-
// never hit it; cycles bail with the partial layout we have so far.
|
|
524
|
-
let iterations = 0
|
|
525
|
-
const maxIterations = nodes.size * 8 + 64
|
|
526
|
-
|
|
527
|
-
while (queue.length > 0) {
|
|
528
|
-
if (++iterations > maxIterations) break
|
|
529
|
-
const current = queue.shift()!
|
|
530
|
-
const previous = rankByID.get(current.id)
|
|
531
|
-
if (previous !== undefined && previous >= current.rank) continue
|
|
532
|
-
rankByID.set(current.id, current.rank)
|
|
533
|
-
for (const child of children.get(current.id) ?? []) {
|
|
534
|
-
queue.push({ id: child, rank: current.rank + 1 })
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
for (const id of nodes.keys()) {
|
|
539
|
-
if (!rankByID.has(id)) {
|
|
540
|
-
rankByID.set(id, incoming.get(id) ? 1 : 0)
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
493
|
+
function groupRanks(rankByID: Map<string, number>, nodes: Map<string, GitOpsTreeNode>): Map<number, string[]> {
|
|
544
494
|
const ranks = new Map<number, string[]>()
|
|
545
495
|
for (const [id, rank] of rankByID.entries()) {
|
|
496
|
+
if (!nodes.has(id)) continue
|
|
546
497
|
ranks.set(rank, [...(ranks.get(rank) ?? []), id])
|
|
547
498
|
}
|
|
548
499
|
for (const ids of ranks.values()) {
|
|
@@ -565,29 +516,38 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
|
|
|
565
516
|
const rootRank = ranks.get(0) ?? []
|
|
566
517
|
if (rootRank.length === 1) {
|
|
567
518
|
const rootID = rootRank[0]
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
519
|
+
const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
|
|
520
|
+
const tallest = Math.max((ranks.get(1)?.length ?? 0), (ranks.get(-1)?.length ?? 0))
|
|
521
|
+
if (tallest > 0) {
|
|
522
|
+
const maxY = (tallest - 1) * ROW_GAP
|
|
572
523
|
positioned.set(rootID, { x: 0, y: Math.max(0, (maxY - rootHeight) / 2 - ROOT_Y_OFFSET) })
|
|
573
524
|
}
|
|
525
|
+
// Dependency columns are short; center them on the root so the chain
|
|
526
|
+
// reads as one line into it.
|
|
527
|
+
const rootCenter = positioned.get(rootID)!.y + rootHeight / 2
|
|
528
|
+
for (const rank of sortedRanks) {
|
|
529
|
+
if (rank >= 0) continue
|
|
530
|
+
const ids = ranks.get(rank) ?? []
|
|
531
|
+
const columnCenter = ((ids.length - 1) * ROW_GAP + rootHeight) / 2
|
|
532
|
+
ids.forEach((id, row) => {
|
|
533
|
+
positioned.set(id, { x: rank * RANK_GAP, y: row * ROW_GAP + rootCenter - columnCenter })
|
|
534
|
+
})
|
|
535
|
+
}
|
|
574
536
|
}
|
|
575
537
|
|
|
576
538
|
return positioned
|
|
577
539
|
}
|
|
578
540
|
|
|
579
|
-
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?:
|
|
541
|
+
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: NodeCause }>>) {
|
|
580
542
|
const node = data.node
|
|
581
543
|
const kind = normalizeDisplayKind(node)
|
|
582
|
-
const status = normalizeHealth(node.topologyStatus)
|
|
544
|
+
const status = node.missing ? 'unhealthy' : normalizeHealth(node.topologyStatus)
|
|
583
545
|
const terminating = isNodeTerminating(node)
|
|
584
546
|
const gitopsTool = gitopsToolForNode(node)
|
|
585
547
|
const chips = buildChips(node)
|
|
586
548
|
const dim = getNodeDimensions(node)
|
|
587
549
|
const KindIcon = getTopologyIcon(kind)
|
|
588
|
-
|
|
589
|
-
// matched by a stale/unrelated issue ref shouldn't show a tooltip.
|
|
590
|
-
const cause = status === 'unhealthy' || status === 'degraded' ? data.cause : undefined
|
|
550
|
+
const cause = nodeCause(node, data.cause)
|
|
591
551
|
// Only declared resources can carry the controller's verdict, so only
|
|
592
552
|
// there does "this one is Radar's" mean anything. Generated children
|
|
593
553
|
// (ReplicaSets, Pods) are always Radar's read; marking each would be
|
|
@@ -767,10 +727,49 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
767
727
|
const relationship = stringData(data.relationship)
|
|
768
728
|
if (relationship) chips.push({ value: relationship })
|
|
769
729
|
if (node.sync === 'OutOfSync') chips.push({ value: 'OutOfSync', tone: 'warning' })
|
|
770
|
-
|
|
730
|
+
const health = treeNodeHealth(node)
|
|
731
|
+
if (health === 'Degraded' || health === 'Missing') chips.push({ value: health, tone: 'danger' })
|
|
771
732
|
return chips
|
|
772
733
|
}
|
|
773
734
|
|
|
735
|
+
export interface NodeCause {
|
|
736
|
+
text: string
|
|
737
|
+
// An issue that names a resource as a lead without saying it failed: Argo's
|
|
738
|
+
// Warning-event lead (Go: degradedResourceFromEvents) and the Flux
|
|
739
|
+
// source/dependency blockers (Go: attachFluxBlocker, fluxSourceWarnings).
|
|
740
|
+
lead: boolean
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const LEAD_REASONS = new Set(['PossibleCause', 'SourceNotReady', 'ArtifactFailed', 'DependencyNotReady'])
|
|
744
|
+
|
|
745
|
+
export function buildCauseByRef(issues?: GitOpsIssue[]): Map<string, NodeCause> {
|
|
746
|
+
const map = new Map<string, NodeCause>()
|
|
747
|
+
for (const issue of issues ?? []) {
|
|
748
|
+
const cause = issue.cause
|
|
749
|
+
const ref = issue.refs?.[0]
|
|
750
|
+
if (!cause || !ref) continue
|
|
751
|
+
const key = refKey(ref)
|
|
752
|
+
// First match wins — issues arrive ranked by severity, so a node
|
|
753
|
+
// that's the subject of multiple issues keeps its most severe cause.
|
|
754
|
+
if (!map.has(key)) map.set(key, { text: cause, lead: LEAD_REASONS.has(issue.reason) })
|
|
755
|
+
}
|
|
756
|
+
return map
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// A failing node (topology 'unhealthy') shows any cause as is. A failure
|
|
760
|
+
// verdict on a Progressing node would read as "this is broken" on a rollout
|
|
761
|
+
// in flight, so elsewhere only a lead shows, framed as possible. That is where
|
|
762
|
+
// leads land: the Warning-event lead is only emitted when no resource reads
|
|
763
|
+
// as failing, and a Flux source mid-retry reads Progressing while its fetch
|
|
764
|
+
// error is still the answer. Healthy and Suspended nodes show nothing.
|
|
765
|
+
export function nodeCause(node: GitOpsTreeNode, cause?: NodeCause): string | undefined {
|
|
766
|
+
if (!cause) return undefined
|
|
767
|
+
const status = normalizeHealth(node.topologyStatus)
|
|
768
|
+
if (status === 'unhealthy') return cause.text
|
|
769
|
+
if (!cause.lead || status === 'healthy' || node.health === 'Suspended') return undefined
|
|
770
|
+
return `Possible cause: ${cause.text}`
|
|
771
|
+
}
|
|
772
|
+
|
|
774
773
|
// The subtitle is the node's one line of prose, and sync, health and the
|
|
775
774
|
// backend's info line all want it. It can't be won on precedence: the backend
|
|
776
775
|
// derives a health for every node it can build an info line for, so a rule that
|
|
@@ -791,6 +790,7 @@ export function getSubtitle(node: GitOpsTreeNode): string {
|
|
|
791
790
|
// a state description, not an affordance.
|
|
792
791
|
return 'Click to expand'
|
|
793
792
|
}
|
|
793
|
+
if (node.missing) return `${node.sync ? `${node.sync} • ` : ''}Not found in cluster`
|
|
794
794
|
if (isNodeTerminating(node)) {
|
|
795
795
|
return 'Pending deletion'
|
|
796
796
|
}
|
|
@@ -827,7 +827,7 @@ function matchesQuery(node: GitOpsTreeNode, query: string): boolean {
|
|
|
827
827
|
node.ref.namespace,
|
|
828
828
|
node.ref.group,
|
|
829
829
|
node.sync,
|
|
830
|
-
node
|
|
830
|
+
treeNodeHealth(node),
|
|
831
831
|
].some(value => String(value ?? '').toLowerCase().includes(query))
|
|
832
832
|
}
|
|
833
833
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { GitOpsResourceTree, GitOpsTreeEdge, GitOpsTreeNode } from '../../../types'
|
|
4
|
+
import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
|
|
5
|
+
|
|
6
|
+
function node(id: string, kind = 'Deployment', role: GitOpsTreeNode['role'] = 'declared'): GitOpsTreeNode {
|
|
7
|
+
return { id, ref: { kind, namespace: 'flux-system', name: id }, role, tool: 'fluxcd' }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function tree(nodes: GitOpsTreeNode[], edges: GitOpsTreeEdge[]): GitOpsResourceTree {
|
|
11
|
+
return { root: nodes[0], nodes, edges }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('rankTree', () => {
|
|
15
|
+
it('draws a release\'s generated chart and repository upstream, repository first', () => {
|
|
16
|
+
// The release names the repository directly and through its chart; the
|
|
17
|
+
// longer path wins so the chain reads repository → chart → release.
|
|
18
|
+
const t = tree(
|
|
19
|
+
[node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
|
|
20
|
+
[
|
|
21
|
+
{ source: 'release', target: 'repo', type: 'source' },
|
|
22
|
+
{ source: 'release', target: 'chart', type: 'source' },
|
|
23
|
+
{ source: 'chart', target: 'repo', type: 'source' },
|
|
24
|
+
{ source: 'release', target: 'deploy', type: 'owns' },
|
|
25
|
+
],
|
|
26
|
+
)
|
|
27
|
+
const rank = rankTree(t)
|
|
28
|
+
expect(rank.get('release')).toBe(0)
|
|
29
|
+
expect(rank.get('chart')).toBe(-1)
|
|
30
|
+
expect(rank.get('repo')).toBe(-2)
|
|
31
|
+
expect(rank.get('deploy')).toBe(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps managed resources downstream even when they are also a source (bootstrap)', () => {
|
|
35
|
+
const t = tree(
|
|
36
|
+
[node('flux-system', 'Kustomization', 'root'), node('repo', 'GitRepository'), node('controller')],
|
|
37
|
+
[
|
|
38
|
+
{ source: 'flux-system', target: 'repo', type: 'owns' },
|
|
39
|
+
{ source: 'flux-system', target: 'repo', type: 'source' },
|
|
40
|
+
{ source: 'flux-system', target: 'controller', type: 'owns' },
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
expect(upstreamEdges(t)).toEqual([])
|
|
44
|
+
expect(rankTree(t).get('repo')).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('places dependsOn targets upstream and keeps the owned subtree depth', () => {
|
|
48
|
+
const t = tree(
|
|
49
|
+
[node('overlay', 'Kustomization', 'root'), node('base', 'Kustomization'), node('repo', 'GitRepository'), node('deploy'), node('rs', 'ReplicaSet', 'generated'), node('pod', 'Pod', 'generated')],
|
|
50
|
+
[
|
|
51
|
+
{ source: 'overlay', target: 'repo', type: 'source' },
|
|
52
|
+
{ source: 'overlay', target: 'base', type: 'dependsOn' },
|
|
53
|
+
{ source: 'overlay', target: 'deploy', type: 'owns' },
|
|
54
|
+
{ source: 'deploy', target: 'rs', type: 'owns' },
|
|
55
|
+
{ source: 'rs', target: 'pod', type: 'owns' },
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
const rank = rankTree(t)
|
|
59
|
+
expect([rank.get('repo'), rank.get('base')]).toEqual([-1, -1])
|
|
60
|
+
expect([rank.get('deploy'), rank.get('rs'), rank.get('pod')]).toEqual([1, 2, 3])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('terminates on a dependency cycle and never moves the root', () => {
|
|
64
|
+
const t = tree(
|
|
65
|
+
[node('a', 'Kustomization', 'root'), node('b', 'Kustomization'), node('c', 'Kustomization')],
|
|
66
|
+
[
|
|
67
|
+
{ source: 'a', target: 'b', type: 'dependsOn' },
|
|
68
|
+
{ source: 'b', target: 'c', type: 'dependsOn' },
|
|
69
|
+
{ source: 'c', target: 'b', type: 'dependsOn' },
|
|
70
|
+
{ source: 'c', target: 'a', type: 'dependsOn' },
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
const rank = rankTree(t)
|
|
74
|
+
expect(rank.get('a')).toBe(0)
|
|
75
|
+
expect(rank.get('b')).toBeLessThan(0)
|
|
76
|
+
expect(rank.get('c')).toBeLessThan(0)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('renderedEdge', () => {
|
|
81
|
+
it('points a dependency edge into its consumer and leaves ownership alone', () => {
|
|
82
|
+
const upstream = new Set(['repo'])
|
|
83
|
+
expect(renderedEdge({ source: 'ks', target: 'repo', type: 'source' }, upstream)).toEqual({ source: 'repo', target: 'ks' })
|
|
84
|
+
expect(renderedEdge({ source: 'ks', target: 'deploy', type: 'owns' }, upstream)).toEqual({ source: 'ks', target: 'deploy' })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('upstreamClosure', () => {
|
|
89
|
+
it('follows the chart hop so every preset keeps the whole source chain', () => {
|
|
90
|
+
const t = tree(
|
|
91
|
+
[node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
|
|
92
|
+
[
|
|
93
|
+
{ source: 'release', target: 'chart', type: 'source' },
|
|
94
|
+
{ source: 'chart', target: 'repo', type: 'source' },
|
|
95
|
+
{ source: 'release', target: 'deploy', type: 'owns' },
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
expect([...upstreamClosure(t)].sort()).toEqual(['chart', 'repo'])
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('shortcutEdges', () => {
|
|
103
|
+
it('drops the direct release → repository edge only when the chart path covers it', () => {
|
|
104
|
+
const direct: GitOpsTreeEdge = { source: 'release', target: 'repo', type: 'source' }
|
|
105
|
+
const viaChart: GitOpsTreeEdge[] = [{ source: 'release', target: 'chart', type: 'source' }, { source: 'chart', target: 'repo', type: 'source' }]
|
|
106
|
+
const nodes = [node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository')]
|
|
107
|
+
expect([...shortcutEdges(tree(nodes, [direct, ...viaChart]))]).toEqual([direct])
|
|
108
|
+
// Before the chart is generated the direct edge is the only path.
|
|
109
|
+
expect(shortcutEdges(tree(nodes, [direct])).size).toBe(0)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { GitOpsResourceTree, GitOpsTreeEdge } from '../../../types'
|
|
2
|
+
|
|
3
|
+
// Flux source, chart and dependsOn edges travel consumer → dependency on the
|
|
4
|
+
// wire. A dependency feeds its consumer, so the graph draws it upstream (left
|
|
5
|
+
// of the consumer) with the arrow pointing into the consumer, as Flux's own
|
|
6
|
+
// tooling does. An object the root also manages (a bootstrap Kustomization
|
|
7
|
+
// that owns its GitRepository) keeps its place among the managed resources:
|
|
8
|
+
// ownership wins.
|
|
9
|
+
export function isUpstreamEdgeType(type: GitOpsTreeEdge['type']): boolean {
|
|
10
|
+
return type === 'source' || type === 'dependsOn'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function upstreamEdges(tree: Pick<GitOpsResourceTree, 'edges'>): GitOpsTreeEdge[] {
|
|
14
|
+
const owned = new Set(tree.edges.filter(edge => edge.type === 'owns').map(edge => edge.target))
|
|
15
|
+
return tree.edges.filter(edge => isUpstreamEdgeType(edge.type) && !owned.has(edge.target))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// rankTree places the root at 0, managed resources at positive ranks by
|
|
19
|
+
// ownership depth, and dependencies at negative ranks by their longest
|
|
20
|
+
// dependency path from the root, so a repository that a release names
|
|
21
|
+
// directly and through its generated chart still sits left of the chart.
|
|
22
|
+
// Cycles are bounded: a rank never moves past the node count.
|
|
23
|
+
export function rankTree(tree: GitOpsResourceTree): Map<string, number> {
|
|
24
|
+
const ids = new Set(tree.nodes.map(node => node.id))
|
|
25
|
+
ids.add(tree.root.id)
|
|
26
|
+
const limit = ids.size
|
|
27
|
+
const upstream = upstreamEdges(tree).filter(edge => ids.has(edge.source) && ids.has(edge.target) && edge.target !== tree.root.id)
|
|
28
|
+
|
|
29
|
+
const depth = new Map<string, number>([[tree.root.id, 0]])
|
|
30
|
+
for (let pass = 0; pass < limit; pass++) {
|
|
31
|
+
let changed = false
|
|
32
|
+
for (const edge of upstream) {
|
|
33
|
+
const from = depth.get(edge.source)
|
|
34
|
+
if (from === undefined) continue
|
|
35
|
+
const next = Math.min(from + 1, limit)
|
|
36
|
+
if ((depth.get(edge.target) ?? 0) < next) {
|
|
37
|
+
depth.set(edge.target, next)
|
|
38
|
+
changed = true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!changed) break
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rank = new Map<string, number>([[tree.root.id, 0]])
|
|
45
|
+
for (const [id, d] of depth) {
|
|
46
|
+
if (id !== tree.root.id) rank.set(id, -d)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const children = new Map<string, string[]>()
|
|
50
|
+
for (const edge of tree.edges) {
|
|
51
|
+
if (!ids.has(edge.source) || !ids.has(edge.target)) continue
|
|
52
|
+
if (rank.has(edge.target) && rank.get(edge.target)! < 0) continue
|
|
53
|
+
if (rank.has(edge.source) && rank.get(edge.source)! < 0) continue
|
|
54
|
+
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
55
|
+
}
|
|
56
|
+
const downstream = new Map<string, number>()
|
|
57
|
+
const queue = [{ id: tree.root.id, rank: 0 }]
|
|
58
|
+
let iterations = 0
|
|
59
|
+
const maxIterations = limit * 8 + 64
|
|
60
|
+
while (queue.length > 0 && ++iterations <= maxIterations) {
|
|
61
|
+
const current = queue.shift()!
|
|
62
|
+
const previous = downstream.get(current.id)
|
|
63
|
+
if (previous !== undefined && previous >= current.rank) continue
|
|
64
|
+
downstream.set(current.id, current.rank)
|
|
65
|
+
for (const child of children.get(current.id) ?? []) {
|
|
66
|
+
queue.push({ id: child, rank: current.rank + 1 })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [id, r] of downstream) {
|
|
70
|
+
if (id !== tree.root.id) rank.set(id, r)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const incoming = new Set(tree.edges.map(edge => edge.target))
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
if (!rank.has(id)) rank.set(id, incoming.has(id) ? 1 : 0)
|
|
76
|
+
}
|
|
77
|
+
return rank
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// renderedEdge returns the edge as drawn: dependency edges point from the
|
|
81
|
+
// dependency into its consumer.
|
|
82
|
+
export function renderedEdge(edge: GitOpsTreeEdge, upstreamTargets: Set<string>): { source: string; target: string } {
|
|
83
|
+
if (isUpstreamEdgeType(edge.type) && upstreamTargets.has(edge.target)) {
|
|
84
|
+
return { source: edge.target, target: edge.source }
|
|
85
|
+
}
|
|
86
|
+
return { source: edge.source, target: edge.target }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// upstreamClosure returns every dependency reachable from the root, for
|
|
90
|
+
// presets that must keep "where does this come from" in view.
|
|
91
|
+
export function upstreamClosure(tree: GitOpsResourceTree): Set<string> {
|
|
92
|
+
const edges = upstreamEdges(tree)
|
|
93
|
+
const out = new Set<string>()
|
|
94
|
+
const queue = [tree.root.id]
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const id = queue.shift()!
|
|
97
|
+
for (const edge of edges) {
|
|
98
|
+
if (edge.source !== id || out.has(edge.target) || edge.target === tree.root.id) continue
|
|
99
|
+
out.add(edge.target)
|
|
100
|
+
queue.push(edge.target)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// shortcutEdges returns the dependency edges that a longer dependency path
|
|
107
|
+
// already covers: an inline HelmRelease names its repository directly and
|
|
108
|
+
// through its generated chart, and drawing both puts the shortcut behind the
|
|
109
|
+
// chart card. The data keeps the edge; the drawing doesn't need it.
|
|
110
|
+
export function shortcutEdges(tree: Pick<GitOpsResourceTree, 'edges'>): Set<GitOpsTreeEdge> {
|
|
111
|
+
const edges = upstreamEdges(tree)
|
|
112
|
+
const out = new Set<GitOpsTreeEdge>()
|
|
113
|
+
for (const edge of edges) {
|
|
114
|
+
const seen = new Set<string>([edge.source])
|
|
115
|
+
const queue = edges.filter(e => e !== edge && e.source === edge.source).map(e => e.target)
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
const id = queue.shift()!
|
|
118
|
+
if (id === edge.target) {
|
|
119
|
+
out.add(edge)
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
if (seen.has(id)) continue
|
|
123
|
+
seen.add(id)
|
|
124
|
+
for (const next of edges) if (next.source === id) queue.push(next.target)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildTreeFacets } from '../GitOpsGraphFilterRail'
|
|
3
|
+
import { matchesGitOpsTreeFilters } from './tree-helpers'
|
|
4
|
+
import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
|
|
5
|
+
|
|
6
|
+
const node = (group: string): GitOpsTreeNode => ({ id: group, ref: { kind: 'Service', group, name: 'same', namespace: 'team' }, role: 'generated', tool: 'fluxcd' })
|
|
7
|
+
|
|
8
|
+
describe('GitOps kind filter contract', () => {
|
|
9
|
+
it('uses confirmed absence in health facets rather than stale health', () => {
|
|
10
|
+
const missing = { ...node(''), missing: true, health: 'Healthy' as const }
|
|
11
|
+
expect(matchesGitOpsTreeFilters(missing, { health: ['Missing'] })).toBe(true)
|
|
12
|
+
expect(matchesGitOpsTreeFilters(missing, { health: ['Healthy'] })).toBe(false)
|
|
13
|
+
expect(buildTreeFacets({ nodes: [missing] } as GitOpsResourceTree).health).toEqual([{ name: 'Missing', count: 1 }])
|
|
14
|
+
})
|
|
15
|
+
it('uses bare Kubernetes kinds across API groups', () => {
|
|
16
|
+
const nodes = [node(''), node('serving.knative.dev')]
|
|
17
|
+
expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
|
|
18
|
+
for (const n of nodes) {
|
|
19
|
+
expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Service']) })).toBe(true)
|
|
20
|
+
expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Deployment']) })).toBe(false)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -14,6 +14,16 @@ export function gitOpsFilterSet(values?: Set<string> | string[]): Set<string> |
|
|
|
14
14
|
return set.size > 0 ? set : undefined
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// treeNodeFilterRole is the node's role as the filter rail presents it: a
|
|
18
|
+
// dependency the root consumes but doesn't manage is not "Declared".
|
|
19
|
+
export function treeNodeFilterRole(node: GitOpsTreeNode): string {
|
|
20
|
+
return node.dependency ? 'dependency' : node.role
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function treeNodeHealth(node: GitOpsTreeNode): string {
|
|
24
|
+
return node.missing ? 'Missing' : node.health || 'Unknown'
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
|
|
18
28
|
if (!filters) return true
|
|
19
29
|
const kinds = gitOpsFilterSet(filters.kinds)
|
|
@@ -25,8 +35,8 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
|
|
|
25
35
|
if (kinds && !kinds.has(node.ref.kind)) return false
|
|
26
36
|
if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
|
|
27
37
|
if (sync && !sync.has(node.sync || 'Unknown')) return false
|
|
28
|
-
if (health && !health.has(node
|
|
29
|
-
if (roles && !roles.has(node
|
|
38
|
+
if (health && !health.has(treeNodeHealth(node))) return false
|
|
39
|
+
if (roles && !roles.has(treeNodeFilterRole(node))) return false
|
|
30
40
|
return true
|
|
31
41
|
}
|
|
32
42
|
|