@skyhook-io/k8s-ui 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  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 +6 -2
  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 +115 -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 +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -0,0 +1,115 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import type { GitOpsChange, GitOpsDrift, GitOpsInsight, GitOpsResourceDiff } from '../../../types'
5
+ import { ArgoResourceDiff } from './ArgoResourceDiff'
6
+ import { GitOpsChangesView } from './GitOpsInsightViews'
7
+
8
+ // Shape seen live on Argo CD 3.5.2: the Deployment was scaled by hand (1 -> 3)
9
+ // and a Git commit bumping the image hasn't synced yet. last-applied holds the
10
+ // manifest from the last sync, so it only sees the scale.
11
+ const lastApplied: GitOpsDrift = {
12
+ source: 'lastAppliedAnnotation',
13
+ entries: [{ path: 'spec.replicas', op: 'changed', desired: '1', live: '3' }],
14
+ }
15
+
16
+ const argoDiff: GitOpsResourceDiff = {
17
+ source: 'argocd-api',
18
+ desired: 'spec:\n replicas: 1\n image: nginx:1.28-alpine\n',
19
+ live: 'spec:\n replicas: 3\n image: nginx:1.27-alpine\n',
20
+ fieldEntries: [
21
+ { path: 'spec.replicas', op: 'changed', desired: '1', live: '3' },
22
+ { path: 'spec.template.spec.containers.[0].image', op: 'changed', desired: '"nginx:1.28-alpine"', live: '"nginx:1.27-alpine"' },
23
+ ],
24
+ redacted: false,
25
+ hook: false,
26
+ }
27
+
28
+ const IMAGE_PATH = 'spec.template.spec.containers.[0].image'
29
+
30
+ const count = (html: string, needle: string) => html.split(needle).length - 1
31
+
32
+ describe('ArgoResourceDiff field list', () => {
33
+ test("shows Argo's field list above the YAML diff, including the unsynced Git change", () => {
34
+ const html = renderToStaticMarkup(<ArgoResourceDiff diff={argoDiff} loading={false} fallbackDrift={lastApplied} />)
35
+ expect(html).toContain('Field diff')
36
+ expect(html).toContain(IMAGE_PATH)
37
+ expect(html).toContain('via Argo CD API')
38
+ expect(html.indexOf('Field diff')).toBeLessThan(html.indexOf('desired (Git-rendered)'))
39
+ // One list, not Argo's plus the last-applied fallback.
40
+ expect(count(html, 'Field diff')).toBe(1)
41
+ })
42
+
43
+ test('renders no field list box when Argo reports no field differences', () => {
44
+ const html = renderToStaticMarkup(<ArgoResourceDiff diff={{ ...argoDiff, fieldEntries: [] }} loading={false} fallbackDrift={lastApplied} />)
45
+ expect(html).not.toContain('Field diff')
46
+ })
47
+
48
+ test('treats a null field list (sent by older Radars) as empty instead of crashing', () => {
49
+ const html = renderToStaticMarkup(<ArgoResourceDiff diff={{ ...argoDiff, fieldEntries: null }} loading={false} fallbackDrift={lastApplied} />)
50
+ expect(html).not.toContain('Field diff')
51
+ expect(html).toContain('desired (Git-rendered)')
52
+ })
53
+
54
+ test('says the list was cut short instead of presenting the cap as the count', () => {
55
+ const html = renderToStaticMarkup(<ArgoResourceDiff diff={{ ...argoDiff, fieldEntriesTruncated: true }} loading={false} />)
56
+ expect(html).toContain('showing first 50 entries')
57
+ expect(html).not.toContain('2 fields')
58
+ })
59
+
60
+ test("falls back to the last-applied list when Argo's diff fails to load", () => {
61
+ const html = renderToStaticMarkup(<ArgoResourceDiff loading={false} error="Failed to fetch the diff from the Argo CD API server." fallbackDrift={lastApplied} />)
62
+ expect(html).toContain('Field diff')
63
+ expect(html).toContain('spec.replicas')
64
+ expect(html).not.toContain('via Argo CD API')
65
+ expect(html).toContain('Failed to fetch the diff from the Argo CD API server.')
66
+ })
67
+ })
68
+
69
+ describe('GitOpsChangesView expanded Argo row', () => {
70
+ const change: GitOpsChange = {
71
+ ref: { group: 'apps', kind: 'Deployment', namespace: 'web', name: 'web' },
72
+ category: 'OutOfSync',
73
+ sync: 'OutOfSync',
74
+ health: 'Healthy',
75
+ hasDesired: true,
76
+ hasLive: true,
77
+ drift: lastApplied,
78
+ partial: false,
79
+ }
80
+ const insight = (argoDiffAvailable: boolean): GitOpsInsight => ({
81
+ summary: { tool: 'argocd', kind: 'Application', namespace: 'argocd', name: 'web', sync: 'OutOfSync', health: 'Healthy' },
82
+ changes: [change],
83
+ capabilities: {
84
+ sync: true, refresh: true, terminate: false, suspend: false, resume: false,
85
+ syncWithSource: false, selectiveSync: true, rollback: false, argoDiffAvailable,
86
+ },
87
+ })
88
+
89
+ test("hands the last-applied list to the Argo diff instead of rendering it alongside", () => {
90
+ let passed: GitOpsDrift | undefined
91
+ const html = renderToStaticMarkup(
92
+ <GitOpsChangesView
93
+ insight={insight(true)}
94
+ focusKey="Deployment/web/web"
95
+ renderResourceDiff={(_ref, fallbackDrift) => {
96
+ passed = fallbackDrift
97
+ return <div data-testid="argo-diff" />
98
+ }}
99
+ />,
100
+ )
101
+ expect(html).toContain('argo-diff')
102
+ expect(html).not.toContain('Field diff')
103
+ expect(passed).toEqual(lastApplied)
104
+ // The row's "N diff" badge counts the last-applied list, which can
105
+ // undercount here, so it isn't shown.
106
+ expect(html).not.toContain('1 diff')
107
+ })
108
+
109
+ test('without the Argo diff, the row still shows the last-applied list', () => {
110
+ const html = renderToStaticMarkup(<GitOpsChangesView insight={insight(false)} focusKey="Deployment/web/web" />)
111
+ expect(html).toContain('Field diff')
112
+ expect(html).toContain('spec.replicas')
113
+ expect(html).toContain('1 diff')
114
+ })
115
+ })
@@ -2,18 +2,23 @@ import { useState } from 'react'
2
2
  import { createTwoFilesPatch } from 'diff'
3
3
  import { clsx } from 'clsx'
4
4
  import { FileText, GitCompare, Maximize2, Rows, ShieldOff, X } from 'lucide-react'
5
- import type { GitOpsResourceDiff } from '../../../types'
5
+ import type { GitOpsDrift, GitOpsResourceDiff } from '../../../types'
6
6
  import { DiffLine, hasDiffBodyChange } from '../../shared/UnifiedDiff'
7
7
  import { CodeViewer } from '../../ui/CodeViewer'
8
8
  import { DialogPortal } from '../../ui/DialogPortal'
9
9
  import { YamlDiffEditor } from '../../ui/YamlEditor'
10
10
  import { Tooltip } from '../../ui/Tooltip'
11
+ import { DriftPanel } from './DriftPanel'
11
12
 
12
13
  interface ArgoResourceDiffProps {
13
14
  diff?: GitOpsResourceDiff | null
14
15
  loading: boolean
15
16
  // Server error string ({"error"} body), surfaced inline — not a toast.
16
17
  error?: string | null
18
+ // The row's last-applied field list, shown only when Argo's diff can't be
19
+ // loaded. A loaded diff carries Argo's own field list, which also covers Git
20
+ // changes that haven't synced yet.
21
+ fallbackDrift?: GitOpsDrift | null
17
22
  }
18
23
 
19
24
  // ArgoResourceDiff renders the full Git-rendered desired-vs-live diff for a
@@ -21,7 +26,8 @@ interface ArgoResourceDiffProps {
21
26
  // fetch and passes data / loading / error. The inline view is a compact unified
22
27
  // preview; the maximize control opens the full view (Diff / Live manifest /
23
28
  // Desired manifest, with the Monaco-based YamlDiffEditor powering the Diff tab).
24
- export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps) {
29
+ // Argo's per-field list sits above the preview, and only when it has entries.
30
+ export function ArgoResourceDiff({ diff, loading, error, fallbackDrift }: ArgoResourceDiffProps) {
25
31
  const [maximized, setMaximized] = useState(false)
26
32
 
27
33
  if (loading) {
@@ -42,19 +48,30 @@ export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps
42
48
  </div>
43
49
  )
44
50
  }
51
+ const fallbackPanel = fallbackDrift && fallbackDrift.entries.length > 0 ? <DriftPanel drift={fallbackDrift} /> : null
45
52
  if (error) {
46
53
  return (
47
- <div className="rounded-md border border-red-500/40 bg-red-500/5 px-3 py-2 text-xs text-red-700 dark:text-red-400">
48
- {error}
54
+ <div className="space-y-3">
55
+ {fallbackPanel}
56
+ <div className="rounded-md border border-red-500/40 bg-red-500/5 px-3 py-2 text-xs text-red-700 dark:text-red-400">
57
+ {error}
58
+ </div>
49
59
  </div>
50
60
  )
51
61
  }
52
- if (!diff) return null
62
+ if (!diff) return fallbackPanel
53
63
 
54
64
  const unchanged = !docsDiffer(diff.desired, diff.live)
65
+ // Older Radars send null for an empty list.
66
+ const fieldEntries = diff.fieldEntries ?? []
55
67
 
56
68
  return (
57
69
  <div>
70
+ {fieldEntries.length > 0 && (
71
+ <div className="mb-3">
72
+ <DriftPanel drift={{ entries: fieldEntries, source: diff.source, truncated: diff.fieldEntriesTruncated }} />
73
+ </div>
74
+ )}
58
75
  <div className="mb-2 flex items-center justify-between gap-2">
59
76
  <div className="flex items-center gap-2">
60
77
  <GitCompare className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
@@ -0,0 +1,62 @@
1
+ import type { GitOpsDrift, GitOpsDriftEntry } from '../../../types'
2
+
3
+ // DriftPanel renders the structured per-field diff. Format mimics a
4
+ // `diff -u` summary: removed paths in red, added in green, changed shown
5
+ // inline as "old → new". Path is monospace; values are JSON-encoded and
6
+ // pre-wrapped so structured values (objects, arrays) render readably.
7
+ export function DriftPanel({ drift }: { drift: GitOpsDrift }) {
8
+ const sourceLabel = driftSourceLabel(drift.source)
9
+ return (
10
+ <div>
11
+ <div className="mb-2 flex items-baseline justify-between gap-2">
12
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Field diff</h4>
13
+ <span className="text-[10px] text-theme-text-tertiary">
14
+ desired (Git) → live ·
15
+ {drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
16
+ {sourceLabel && ` · ${sourceLabel}`}
17
+ </span>
18
+ </div>
19
+ <div className="space-y-1 font-mono text-[11px]">
20
+ {drift.entries.map((entry, i) => (
21
+ <DriftEntryRow key={`${entry.path}-${i}`} entry={entry} />
22
+ ))}
23
+ </div>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ // Provenance label for a Drift's field entries. Only Argo-sourced drift gets an
29
+ // explicit label; the built-in last-applied path renders without one (the
30
+ // "desired (Git) → live" copy already implies it). Unknown sources (a library
31
+ // consumer lagging the backend) fall through to no label rather than surfacing
32
+ // a raw enum string.
33
+ function driftSourceLabel(source: GitOpsDrift['source']): string {
34
+ return source === 'argocd-api' ? 'via Argo CD API' : ''
35
+ }
36
+
37
+ function DriftEntryRow({ entry }: { entry: GitOpsDriftEntry }) {
38
+ if (entry.op === 'removed') {
39
+ return (
40
+ <div>
41
+ <span className="text-red-600 dark:text-red-400">- {entry.path}</span>
42
+ {entry.desired && <span className="ml-2 text-theme-text-secondary">{entry.desired}</span>}
43
+ </div>
44
+ )
45
+ }
46
+ if (entry.op === 'added') {
47
+ return (
48
+ <div>
49
+ <span className="text-emerald-700 dark:text-emerald-400">+ {entry.path}</span>
50
+ {entry.live && <span className="ml-2 text-theme-text-secondary">{entry.live}</span>}
51
+ </div>
52
+ )
53
+ }
54
+ return (
55
+ <div>
56
+ <span className="text-amber-700 dark:text-amber-400">~ {entry.path}</span>
57
+ <span className="ml-2 text-theme-text-tertiary">{entry.desired}</span>
58
+ <span className="mx-1 text-theme-text-tertiary">→</span>
59
+ <span className="text-theme-text-primary">{entry.live}</span>
60
+ </div>
61
+ )
62
+ }
@@ -2,7 +2,7 @@ import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, Circl
2
2
  import { PaneLoader } from '../../ui/PaneLoader'
3
3
  import { clsx } from 'clsx'
4
4
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
5
- import type { GitOpsChange, GitOpsHistoryItem, GitOpsInsight, GitOpsInsightRef, GitOpsIssue, GitOpsPlanItem, GitOpsRemediation, GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
5
+ import type { GitOpsChange, GitOpsDrift, GitOpsHistoryItem, GitOpsInsight, GitOpsInsightRef, GitOpsIssue, GitOpsPlanItem, GitOpsRemediation, GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
6
6
  import { HealthStatusBadge, SyncStatusBadge } from '../GitOpsStatusBadge'
7
7
  import { radarHealthNote } from '../health-provenance'
8
8
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
@@ -30,6 +30,7 @@ import {
30
30
  type ResourceSortKey,
31
31
  type ResourceStatusFacet,
32
32
  } from './insights-helpers'
33
+ import { DriftPanel } from './DriftPanel'
33
34
 
34
35
  interface GitOpsStatusStripProps {
35
36
  insight?: GitOpsInsight | null
@@ -222,7 +223,10 @@ function commitURLForRepo(source: string | undefined, revision: string): string
222
223
  // "did everything come up?" summary. All-healthy reads green and explicit
223
224
  // ("4/4 resources healthy"); mixed states list the unhealthy buckets so
224
225
  // the user sees at a glance what didn't make it.
225
- function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: string } | null {
226
+ function buildHealthSummary(allChanges: GitOpsChange[]): { text: string; tone: string } | null {
227
+ // Dependencies are consumed, not deployed, so they don't count toward
228
+ // "did everything come up"; a failing one has its own Issue.
229
+ const changes = allChanges.filter((c) => !c.dependency)
226
230
  if (changes.length === 0) return null
227
231
  let healthy = 0
228
232
  let degraded = 0
@@ -735,7 +739,10 @@ interface GitOpsChangesViewProps {
735
739
  // returns the loaded <ArgoResourceDiff/>. When present AND
736
740
  // insight.capabilities.argoDiffAvailable, resolvable rows expose a "Full
737
741
  // diff" toggle. Library consumers that don't wire it get no such affordance.
738
- renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
742
+ // fallbackDrift is the row's last-applied field list. The loaded diff shows
743
+ // Argo's own field list instead, and falls back to this one when the diff
744
+ // can't be loaded.
745
+ renderResourceDiff?: (ref: GitOpsInsightRef, fallbackDrift?: GitOpsDrift) => ReactNode
739
746
  // Opens the global Settings dialog. Backs the "Connect Argo CD" hint shown
740
747
  // when the root is an Argo Application without the diff integration.
741
748
  onOpenSettings?: () => void
@@ -1005,7 +1012,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
1005
1012
  )}
1006
1013
  </div>
1007
1014
  )}
1008
- {totalCount > 0 && isArgoRoot && !insight.capabilities?.argoDiffAvailable && onOpenSettings && (
1015
+ {totalCount > 0 && isArgoRoot && !insight.capabilities?.argoDiffAvailable && !insight.capabilities?.argoConnecting && onOpenSettings && (
1009
1016
  <div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
1010
1017
  {insight.capabilities?.argoConfigured ? (
1011
1018
  <>
@@ -1330,7 +1337,7 @@ function ChangeRow({
1330
1337
  // When true, the expanded panel offers a "Full diff" toggle that mounts the
1331
1338
  // host-wired Argo CD diff loader for this resource.
1332
1339
  canFullDiff: boolean
1333
- renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
1340
+ renderResourceDiff?: (ref: GitOpsInsightRef, fallbackDrift?: GitOpsDrift) => ReactNode
1334
1341
  onOpenResource?: (ref: GitOpsChange['ref']) => void
1335
1342
  onSyncResource?: (ref: GitOpsChange['ref']) => void
1336
1343
  syncResourceDisabledReason?: string
@@ -1422,8 +1429,11 @@ function ChangeRow({
1422
1429
  )}
1423
1430
  {/* Detail badges: surface that there's something to see in
1424
1431
  the expanded panel. Without these the user has no signal
1425
- that clicking will reveal anything useful. */}
1426
- {driftEntries.length > 0 && (
1432
+ that clicking will reveal anything useful. The field count
1433
+ comes from the last-applied list, which can undercount on an
1434
+ Argo row (Argo's own list loads only on expand), so it is
1435
+ left off there. The OutOfSync badge already marks the row. */}
1436
+ {!canFullDiff && driftEntries.length > 0 && (
1427
1437
  <Tooltip content={`${driftEntries.length} field${driftEntries.length === 1 ? '' : 's'} differ from Git`} delay={200} wrapperClassName="shrink-0">
1428
1438
  <span className="rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-400">
1429
1439
  {driftEntries.length} diff
@@ -1523,15 +1533,19 @@ function ChangeRow({
1523
1533
  {expandable && (
1524
1534
  <Collapse open={expanded} mountLazily>
1525
1535
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1526
- {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1536
+ {/* The last-applied list only knows what was applied at the last
1537
+ sync, so it misses a Git change that hasn't synced yet. When
1538
+ Argo's diff can load, it owns the field list and shows this
1539
+ one only if that load fails. */}
1540
+ {!canFullDiff && driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1527
1541
  {events.length > 0 && <RecentEventsPanel events={events} />}
1528
1542
  {/* Expanding a row IS the request to see its diff — render it inline
1529
1543
  rather than behind a second "Full diff" click. The capability is
1530
1544
  already gated on a live Argo connection, so this only mounts (and
1531
1545
  fetches) when the diff can actually be served. */}
1532
1546
  {canFullDiff && (
1533
- <div className={clsx((driftEntries.length > 0 || events.length > 0) && 'mt-3')}>
1534
- {renderResourceDiff?.(change.ref)}
1547
+ <div className={clsx(events.length > 0 && 'mt-3')}>
1548
+ {renderResourceDiff?.(change.ref, change.drift)}
1535
1549
  </div>
1536
1550
  )}
1537
1551
  </div>
@@ -1541,67 +1555,6 @@ function ChangeRow({
1541
1555
  )
1542
1556
  }
1543
1557
 
1544
- // DriftPanel renders the structured per-field diff. Format mimics a
1545
- // `diff -u` summary: removed paths in red, added in green, changed shown
1546
- // inline as "old → new". Path is monospace; values are JSON-encoded and
1547
- // pre-wrapped so structured values (objects, arrays) render readably.
1548
- function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
1549
- const sourceLabel = driftSourceLabel(drift.source)
1550
- return (
1551
- <div>
1552
- <div className="mb-2 flex items-baseline justify-between gap-2">
1553
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Field diff</h4>
1554
- <span className="text-[10px] text-theme-text-tertiary">
1555
- desired (Git) → live ·
1556
- {drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
1557
- {sourceLabel && ` · ${sourceLabel}`}
1558
- </span>
1559
- </div>
1560
- <div className="space-y-1 font-mono text-[11px]">
1561
- {drift.entries.map((entry, i) => (
1562
- <DriftEntryRow key={`${entry.path}-${i}`} entry={entry} />
1563
- ))}
1564
- </div>
1565
- </div>
1566
- )
1567
- }
1568
-
1569
- // Provenance label for a Drift's field entries. Only Argo-sourced drift gets an
1570
- // explicit label; the built-in last-applied path renders without one (the
1571
- // "desired (Git) → live" copy already implies it). Unknown sources (a library
1572
- // consumer lagging the backend) fall through to no label rather than surfacing
1573
- // a raw enum string.
1574
- function driftSourceLabel(source: NonNullable<GitOpsChange['drift']>['source']): string {
1575
- return source === 'argocd-api' ? 'via Argo CD API' : ''
1576
- }
1577
-
1578
- function DriftEntryRow({ entry }: { entry: NonNullable<GitOpsChange['drift']>['entries'][number] }) {
1579
- if (entry.op === 'removed') {
1580
- return (
1581
- <div>
1582
- <span className="text-red-600 dark:text-red-400">- {entry.path}</span>
1583
- {entry.desired && <span className="ml-2 text-theme-text-secondary">{entry.desired}</span>}
1584
- </div>
1585
- )
1586
- }
1587
- if (entry.op === 'added') {
1588
- return (
1589
- <div>
1590
- <span className="text-emerald-700 dark:text-emerald-400">+ {entry.path}</span>
1591
- {entry.live && <span className="ml-2 text-theme-text-secondary">{entry.live}</span>}
1592
- </div>
1593
- )
1594
- }
1595
- return (
1596
- <div>
1597
- <span className="text-amber-700 dark:text-amber-400">~ {entry.path}</span>
1598
- <span className="ml-2 text-theme-text-tertiary">{entry.desired}</span>
1599
- <span className="mx-1 text-theme-text-tertiary">→</span>
1600
- <span className="text-theme-text-primary">{entry.live}</span>
1601
- </div>
1602
- )
1603
- }
1604
-
1605
1558
  // RecentEventsPanel surfaces the last few events involving this resource.
1606
1559
  // Warning events get a red bar so the eye lands on them; normals are
1607
1560
  // muted. Aggregation count (when present) is a critical signal — "this
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { getSubtitle } from './GitOpsTreeGraph'
3
+ import { getSubtitle, nodeCause } from './GitOpsTreeGraph'
4
4
  import type { GitOpsTreeNode } from '../../../types'
5
5
 
6
6
  function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
@@ -90,3 +90,28 @@ describe('getSubtitle', () => {
90
90
  expect(getSubtitle(node({ ref: { kind: 'ClusterRole', name: 'podinfo', namespace: '' } }))).toBe('')
91
91
  })
92
92
  })
93
+
94
+ describe('nodeCause', () => {
95
+ // Shapes as the tree API sends them: the Go side maps Argo's Degraded and
96
+ // Missing to topologyStatus 'unhealthy', Progressing and Suspended to
97
+ // 'degraded' (pkg/gitops/tree healthToTopology).
98
+ const CAUSE = 'Back-off restarting failed container main'
99
+
100
+ it('shows the cause on a node the controller calls Degraded', () => {
101
+ expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }), CAUSE)).toBe(CAUSE)
102
+ })
103
+
104
+ it('hides it on a Progressing node, even when an issue matched it', () => {
105
+ // A rollout in flight is not broken; a cause tooltip on it says it is.
106
+ expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
107
+ expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
108
+ })
109
+
110
+ it('hides it on a Suspended node, even when an issue matched it', () => {
111
+ expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
112
+ })
113
+
114
+ it('never shows it on a healthy node', () => {
115
+ expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }), CAUSE)).toBeUndefined()
116
+ })
117
+ })
@@ -29,6 +29,7 @@ import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sRe
29
29
  import { getTopologyIcon } from '../../../utils/resource-icons'
30
30
  import { Tooltip } from '../../ui/Tooltip'
31
31
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, 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
 
@@ -231,7 +232,7 @@ function GitOpsTreeGraphInner({
231
232
  function EdgeLegend({ edges }: { edges: Edge[] }) {
232
233
  const types = new Set(edges.map((e) => e.data?.type as string).filter(Boolean))
233
234
  const rows: Array<{ key: string; color: string; label: string }> = []
234
- if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source repo' })
235
+ if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source artifact' })
235
236
  if (types.has('dependsOn')) rows.push({ key: 'dependsOn', color: getEdgeColor('dependsOn'), label: 'Depends on' })
236
237
  // Always show the default ownership color when there are 2+ edge kinds —
237
238
  // operators need to compare against it. Hide when "owns" is the only kind.
@@ -333,16 +334,10 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
333
334
  if (!tree) return { nodes: [], edges: [] }
334
335
  const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
335
336
  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)
337
+ const ranks = groupRanks(rankTree(visibleTree), byID)
345
338
  const positioned = positionRanks(ranks, byID)
339
+ const upstreamTargets = new Set(upstreamEdges(visibleTree).map(edge => edge.target))
340
+ const shortcuts = shortcutEdges(visibleTree)
346
341
  const normalizedQuery = query.trim().toLowerCase()
347
342
  const nodes = Array.from(positioned.entries()).map(([id, position]) => {
348
343
  const node = byID.get(id)!
@@ -359,11 +354,10 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
359
354
  })
360
355
 
361
356
  const edges = visibleTree.edges
362
- .filter(edge => positioned.has(edge.source) && positioned.has(edge.target))
357
+ .filter(edge => positioned.has(edge.source) && positioned.has(edge.target) && !shortcuts.has(edge))
363
358
  .map(edge => ({
364
359
  id: `${edge.source}->${edge.target}`,
365
- source: edge.source,
366
- target: edge.target,
360
+ ...renderedEdge(edge, upstreamTargets),
367
361
  type: 'smoothstep',
368
362
  // The legend reads back edge.data?.type to decide which rows to render —
369
363
  // ReactFlow doesn't preserve our custom `type` field at the top level
@@ -394,7 +388,9 @@ function applyPreset(tree: GitOpsResourceTree, preset: GitOpsTreePreset, expande
394
388
  children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
395
389
  }
396
390
 
397
- const keep = new Set<string>([tree.root.id])
391
+ // Dependencies answer "where does this come from" and are few, so every
392
+ // preset keeps them.
393
+ const keep = new Set<string>([tree.root.id, ...upstreamClosure(tree)])
398
394
  if (preset === 'app') {
399
395
  for (const edge of tree.edges) {
400
396
  if (edge.source === tree.root.id) keep.add(edge.target)
@@ -438,7 +434,7 @@ function applyGraphFilters(tree: GitOpsResourceTree, filters?: GitOpsTreeFilters
438
434
  if (node.id === tree.root.id) continue
439
435
  if (matchesGitOpsTreeFilters(node, filters)) {
440
436
  let current: string | undefined = node.id
441
- while (current) {
437
+ while (current && !keep.has(current)) {
442
438
  keep.add(current)
443
439
  current = parent.get(current)
444
440
  }
@@ -506,43 +502,10 @@ function compactInfra(tree: GitOpsResourceTree, expandedGroups?: Set<string>): G
506
502
  }
507
503
  }
508
504
 
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
-
505
+ function groupRanks(rankByID: Map<string, number>, nodes: Map<string, GitOpsTreeNode>): Map<number, string[]> {
544
506
  const ranks = new Map<number, string[]>()
545
507
  for (const [id, rank] of rankByID.entries()) {
508
+ if (!nodes.has(id)) continue
546
509
  ranks.set(rank, [...(ranks.get(rank) ?? []), id])
547
510
  }
548
511
  for (const ids of ranks.values()) {
@@ -565,12 +528,23 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
565
528
  const rootRank = ranks.get(0) ?? []
566
529
  if (rootRank.length === 1) {
567
530
  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
531
+ const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
532
+ const tallest = Math.max((ranks.get(1)?.length ?? 0), (ranks.get(-1)?.length ?? 0))
533
+ if (tallest > 0) {
534
+ const maxY = (tallest - 1) * ROW_GAP
572
535
  positioned.set(rootID, { x: 0, y: Math.max(0, (maxY - rootHeight) / 2 - ROOT_Y_OFFSET) })
573
536
  }
537
+ // Dependency columns are short; center them on the root so the chain
538
+ // reads as one line into it.
539
+ const rootCenter = positioned.get(rootID)!.y + rootHeight / 2
540
+ for (const rank of sortedRanks) {
541
+ if (rank >= 0) continue
542
+ const ids = ranks.get(rank) ?? []
543
+ const columnCenter = ((ids.length - 1) * ROW_GAP + rootHeight) / 2
544
+ ids.forEach((id, row) => {
545
+ positioned.set(id, { x: rank * RANK_GAP, y: row * ROW_GAP + rootCenter - columnCenter })
546
+ })
547
+ }
574
548
  }
575
549
 
576
550
  return positioned
@@ -587,7 +561,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
587
561
  const KindIcon = getTopologyIcon(kind)
588
562
  // Only unhealthy nodes carry a cause worth surfacing — a healthy node
589
563
  // matched by a stale/unrelated issue ref shouldn't show a tooltip.
590
- const cause = status === 'unhealthy' || status === 'degraded' ? data.cause : undefined
564
+ const cause = nodeCause(node, data.cause)
591
565
  // Only declared resources can carry the controller's verdict, so only
592
566
  // there does "this one is Radar's" mean anything. Generated children
593
567
  // (ReplicaSets, Pods) are always Radar's read; marking each would be
@@ -771,6 +745,15 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
771
745
  return chips
772
746
  }
773
747
 
748
+ // nodeCause returns the matched issue's cause when the node's hover should
749
+ // show it: only on a node that is actually failing (Degraded or Missing,
750
+ // topology 'unhealthy'). 'degraded' is also where Progressing and Suspended
751
+ // land, and a cause there reads as "this is broken" on a rollout in flight
752
+ // or a deliberate pause.
753
+ export function nodeCause(node: GitOpsTreeNode, cause?: string): string | undefined {
754
+ return normalizeHealth(node.topologyStatus) === 'unhealthy' ? cause : undefined
755
+ }
756
+
774
757
  // The subtitle is the node's one line of prose, and sync, health and the
775
758
  // backend's info line all want it. It can't be won on precedence: the backend
776
759
  // derives a health for every node it can build an info line for, so a rule that