@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.
Files changed (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. 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 repo' })
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, string>): { nodes: Node[]; edges: Edge[] } {
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 children = new Map<string, string[]>()
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
- source: edge.source,
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
- const keep = new Set<string>([tree.root.id])
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 assignRanks(
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 nextRank = ranks.get(1) ?? []
569
- if (nextRank.length > 0) {
570
- const maxY = (nextRank.length - 1) * ROW_GAP
571
- const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
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?: string }>>) {
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
- // Only unhealthy nodes carry a cause worth surfacing — a healthy node
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
- if (node.health === 'Degraded' || node.health === 'Missing') chips.push({ value: node.health, tone: 'danger' })
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.health,
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.health || 'Unknown')) return false
29
- if (roles && !roles.has(node.role)) return false
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