@skyhook-io/k8s-ui 1.16.3 → 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 (309) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -15
  5. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  6. package/src/components/charts/AreaChart.test.tsx +25 -0
  7. package/src/components/charts/AreaChart.tsx +141 -12
  8. package/src/components/charts/index.ts +1 -0
  9. package/src/components/checks/ChecksView.tsx +18 -1
  10. package/src/components/checks/types.ts +2 -13
  11. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  12. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  13. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  14. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  15. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  16. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  17. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  18. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  19. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  20. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  21. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  22. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  23. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  24. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  25. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  26. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  27. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  28. package/src/components/cnpg/backupRuns.test.ts +17 -0
  29. package/src/components/cnpg/backupRuns.ts +19 -0
  30. package/src/components/cnpg/connect.test.ts +124 -0
  31. package/src/components/cnpg/connect.ts +155 -0
  32. package/src/components/cnpg/databaseRole.test.ts +38 -0
  33. package/src/components/cnpg/databaseRole.ts +80 -0
  34. package/src/components/cnpg/ha.test.ts +446 -0
  35. package/src/components/cnpg/ha.ts +621 -0
  36. package/src/components/cnpg/index.ts +18 -0
  37. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  38. package/src/components/cnpg/logicalReplication.ts +252 -0
  39. package/src/components/cnpg/pooler.test.ts +84 -0
  40. package/src/components/cnpg/pooler.ts +225 -0
  41. package/src/components/cnpg/primitives.tsx +21 -117
  42. package/src/components/cnpg/problems.ts +12 -0
  43. package/src/components/cnpg/relations.test.ts +63 -13
  44. package/src/components/cnpg/relations.ts +61 -52
  45. package/src/components/cnpg/schedule.ts +35 -0
  46. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  47. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  48. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  49. package/src/components/cnpg/workspace.test.ts +380 -9
  50. package/src/components/cnpg/workspace.ts +1137 -136
  51. package/src/components/dock/DockContext.test.tsx +32 -0
  52. package/src/components/dock/DockContext.tsx +18 -4
  53. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  54. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  55. package/src/components/dock/TerminalTab.tsx +20 -8
  56. package/src/components/facts/certainty.tsx +36 -0
  57. package/src/components/facts/facts.test.tsx +24 -0
  58. package/src/components/facts/facts.tsx +62 -0
  59. package/src/components/facts/index.ts +6 -0
  60. package/src/components/facts/managed-by.test.tsx +17 -0
  61. package/src/components/facts/managed-by.tsx +36 -0
  62. package/src/components/facts/public-exports.test.tsx +35 -0
  63. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  64. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  65. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  66. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  67. package/src/components/gitops/detail-helpers.ts +1 -1
  68. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  69. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  70. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  71. package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
  72. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  73. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
  74. package/src/components/gitops/tree/layout.test.ts +111 -0
  75. package/src/components/gitops/tree/layout.ts +128 -0
  76. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  77. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  78. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  79. package/src/components/issues/IssuesView.tsx +40 -5
  80. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  81. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  82. package/src/components/issues/index.ts +3 -1
  83. package/src/components/issues/issue-timing.ts +14 -3
  84. package/src/components/issues/issues.test.ts +50 -2
  85. package/src/components/issues/severity.ts +21 -0
  86. package/src/components/issues/types.ts +23 -0
  87. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  88. package/src/components/logs/LogCore.tsx +25 -17
  89. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  90. package/src/components/logs/LogsViewer.test.tsx +291 -0
  91. package/src/components/logs/LogsViewer.tsx +72 -11
  92. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  93. package/src/components/logs/StructuredLogLine.tsx +22 -30
  94. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  95. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  96. package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
  97. package/src/components/logs/useLogBuffer.test.ts +8 -0
  98. package/src/components/logs/useLogStream.ts +28 -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/ResourcesSidebar.keyboard.test.tsx +42 -0
  105. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  106. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  107. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  108. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  109. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  110. package/src/components/resources/ResourcesView.tsx +309 -117
  111. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  112. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  113. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  114. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  115. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
  116. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  117. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  118. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  119. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  120. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  121. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  122. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  124. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
  125. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  127. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
  128. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  130. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  131. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  132. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  133. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  134. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  135. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  136. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  137. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  138. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
  139. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
  140. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  141. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  142. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  143. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  144. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  145. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  146. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  147. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  148. package/src/components/resources/renderers/HPARenderer.tsx +57 -17
  149. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  150. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  151. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  152. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  153. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  154. package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
  155. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  156. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  157. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  158. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  159. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  160. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
  161. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  163. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  164. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  165. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  166. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  167. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  170. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  171. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  172. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  173. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  174. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  175. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  176. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  177. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  178. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  179. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  180. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  181. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  182. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  183. package/src/components/resources/renderers/PodRenderer.tsx +62 -36
  184. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  185. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  186. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  187. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  188. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  189. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  190. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  191. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
  192. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  193. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  194. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  195. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  196. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  197. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  198. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  199. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  200. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  201. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  202. package/src/components/resources/resource-utils-capi.ts +1 -1
  203. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  204. package/src/components/resources/resource-utils-cnpg.ts +51 -24
  205. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  206. package/src/components/resources/resource-utils-contour.ts +16 -0
  207. package/src/components/resources/resource-utils-istio.ts +12 -8
  208. package/src/components/resources/resource-utils-keda.ts +1 -7
  209. package/src/components/resources/resource-utils-knative.ts +18 -0
  210. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  211. package/src/components/resources/resource-utils-velero.ts +0 -12
  212. package/src/components/resources/resource-utils.ts +99 -39
  213. package/src/components/resources/rules-column-width.test.ts +1 -1
  214. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  215. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  216. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  217. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  218. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  219. package/src/components/shared/DetailShell.test.tsx +80 -0
  220. package/src/components/shared/DetailShell.tsx +85 -10
  221. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  222. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  223. package/src/components/shared/EditableYamlView.tsx +1 -1
  224. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  225. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  226. package/src/components/shared/ResourceActionsBar.tsx +20 -24
  227. package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
  228. package/src/components/shared/SetImageDialog.test.ts +75 -6
  229. package/src/components/shared/SetImageDialog.tsx +92 -84
  230. package/src/components/shared/index.ts +4 -1
  231. package/src/components/timeline/TimelineList.tsx +2 -15
  232. package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
  233. package/src/components/timeline/timeline-filters.ts +1 -0
  234. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  235. package/src/components/topology/K8sResourceNode.tsx +44 -2
  236. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  237. package/src/components/topology/TopologyGraph.tsx +8 -74
  238. package/src/components/topology/TopologySearch.tsx +1 -0
  239. package/src/components/topology/internet-groups.test.ts +35 -0
  240. package/src/components/topology/internet-groups.ts +55 -0
  241. package/src/components/topology/layout.ts +1 -1
  242. package/src/components/ui/Badge.tsx +1 -0
  243. package/src/components/ui/FoldSection.tsx +68 -0
  244. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  245. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  246. package/src/components/ui/RefLink.tsx +20 -0
  247. package/src/components/ui/RowActionMenu.tsx +8 -3
  248. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  249. package/src/components/ui/ScheduleValue.tsx +3 -2
  250. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  251. package/src/components/ui/drawer-components.test.tsx +175 -1
  252. package/src/components/ui/drawer-components.tsx +103 -36
  253. package/src/components/ui/index.ts +3 -1
  254. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  255. package/src/components/ui/monacoRuntime.ts +14 -19
  256. package/src/components/ui/status-tone.test.ts +10 -1
  257. package/src/components/ui/status-tone.tsx +29 -0
  258. package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
  259. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  260. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  261. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  262. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  263. package/src/components/workload/WorkloadView.tsx +297 -104
  264. package/src/index.ts +14 -1
  265. package/src/monaco-deep.d.ts +19 -12
  266. package/src/theme/components.css +13 -0
  267. package/src/types/capacity.ts +21 -2
  268. package/src/types/core.ts +60 -2
  269. package/src/types/gitops-flux-status.test.ts +32 -1
  270. package/src/types/gitops-insights.ts +19 -2
  271. package/src/types/gitops-tree.ts +10 -0
  272. package/src/types/gitops.ts +26 -6
  273. package/src/utils/api-resources.ts +7 -0
  274. package/src/utils/application-history.ts +1 -0
  275. package/src/utils/cnpg-backup.test.ts +26 -0
  276. package/src/utils/cnpg-backup.ts +70 -0
  277. package/src/utils/format.test.ts +49 -2
  278. package/src/utils/format.ts +9 -1
  279. package/src/utils/gateway-references.test.ts +17 -0
  280. package/src/utils/gateway-references.ts +29 -0
  281. package/src/utils/gitops-owner.ts +3 -2
  282. package/src/utils/gitops-write-guard.test.ts +401 -0
  283. package/src/utils/gitops-write-guard.ts +509 -0
  284. package/src/utils/grant.test.ts +19 -0
  285. package/src/utils/grant.ts +35 -0
  286. package/src/utils/index.ts +6 -0
  287. package/src/utils/ingress-class-references.ts +22 -0
  288. package/src/utils/k8s-errors.ts +7 -7
  289. package/src/utils/log-format.test.ts +41 -0
  290. package/src/utils/log-format.ts +71 -15
  291. package/src/utils/log-level.ts +12 -8
  292. package/src/utils/navigation.test.ts +70 -0
  293. package/src/utils/navigation.ts +91 -8
  294. package/src/utils/node-lifecycle.test.ts +36 -0
  295. package/src/utils/node-lifecycle.ts +77 -0
  296. package/src/utils/object-reference-navigation.test.ts +43 -0
  297. package/src/utils/resource-hierarchy.test.ts +69 -0
  298. package/src/utils/resource-hierarchy.ts +41 -7
  299. package/src/utils/resource-icons.ts +2 -0
  300. package/src/utils/resource-refs.test.ts +23 -0
  301. package/src/utils/resource-refs.ts +30 -0
  302. package/src/utils/skeleton-yaml.test.ts +21 -0
  303. package/src/utils/skeleton-yaml.ts +16 -0
  304. package/src/utils/url-path.test.ts +35 -0
  305. package/src/utils/url-path.ts +21 -0
  306. package/src/utils/validators.test.ts +4 -13
  307. package/src/utils/validators.ts +5 -19
  308. package/src/utils/view-transition.test.ts +35 -1
  309. package/src/utils/view-transition.ts +16 -1
@@ -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
@@ -752,10 +726,10 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
752
726
  // detail title row and fleet rows.
753
727
  if (isNodeTerminating(node)) {
754
728
  chips.push({ value: 'Terminating', tone: 'warning' })
755
- if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
729
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
756
730
  return chips
757
731
  }
758
- if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
732
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
759
733
  const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
760
734
  if (revision) chips.push({ label: 'rev', value: revision })
761
735
  const attempted = stringData(data.attemptedRevision)
@@ -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
@@ -886,6 +869,3 @@ function pluralize(kind: string): string {
886
869
  return `${kind}s`
887
870
  }
888
871
 
889
- function formatAge(timestamp: string): string {
890
- return formatCompactAge(timestamp)
891
- }
@@ -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,17 @@
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 bare Kubernetes kinds across API groups', () => {
10
+ const nodes = [node(''), node('serving.knative.dev')]
11
+ expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
12
+ for (const n of nodes) {
13
+ expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Service']) })).toBe(true)
14
+ expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Deployment']) })).toBe(false)
15
+ }
16
+ })
17
+ })
@@ -14,6 +14,12 @@ 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
+
17
23
  export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
18
24
  if (!filters) return true
19
25
  const kinds = gitOpsFilterSet(filters.kinds)
@@ -26,7 +32,7 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
26
32
  if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
27
33
  if (sync && !sync.has(node.sync || 'Unknown')) return false
28
34
  if (health && !health.has(node.health || 'Unknown')) return false
29
- if (roles && !roles.has(node.role)) return false
35
+ if (roles && !roles.has(treeNodeFilterRole(node))) return false
30
36
  return true
31
37
  }
32
38
 
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { IssueRow } from './IssuesView'
6
+ import type { Issue } from './types'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const condition =
11
+ 'If victoria-metrics-operator was uninstalled on purpose (and nothing else should finalize this object), remove the finalizer'
12
+ const kubectl = `kubectl patch 'vmagents.operator.victoriametrics.com' 'stuck' -n 'team' --type=json -p '[]' --dry-run=server`
13
+
14
+ const issue: Issue = {
15
+ id: 'vmagent-stuck',
16
+ severity: 'warning',
17
+ source: 'problem',
18
+ category: 'termination_stuck',
19
+ category_group: 'control_plane',
20
+ grouping_scope: 'unknown',
21
+ group: 'operator.victoriametrics.com',
22
+ kind: 'VMAgent',
23
+ namespace: 'team',
24
+ name: 'stuck',
25
+ reason: 'Terminating stuck',
26
+ cause: 'Deletion has been pending for 2h.',
27
+ action:
28
+ 'No recognized controller found for finalizer "apps.victoriametrics.com/finalizer". If victoria-metrics-operator should be running, restore it so it can resume cleanup.',
29
+ conditional_cleanup: {
30
+ condition,
31
+ agent_guidance: 'Not the next step.',
32
+ caution: 'Removing a finalizer skips the cleanup its controller would have done.',
33
+ removals: [
34
+ {
35
+ finalizer: 'apps.victoriametrics.com/finalizer',
36
+ patch_resource: { kind: 'VMAgent', name: 'stuck', dry_run: true },
37
+ kubectl,
38
+ },
39
+ ],
40
+ },
41
+ }
42
+
43
+ it('keeps a conditional cleanup closed behind its condition, apart from the next step', async () => {
44
+ const element = document.createElement('div')
45
+ const root = createRoot(element)
46
+ await act(async () => {
47
+ root.render(<IssueRow issue={issue} open onToggle={() => {}} />)
48
+ })
49
+
50
+ const toggle = [...element.querySelectorAll('button')].find((button) => button.textContent?.includes(condition))
51
+ expect(toggle, 'the toggle is labeled with the condition').toBeTruthy()
52
+ expect(toggle!.getAttribute('aria-expanded')).toBe('false')
53
+ const panel = [...element.querySelectorAll<HTMLElement>('[id]')].find((node) => node.id === toggle!.getAttribute('aria-controls'))!
54
+ expect(panel.style.gridTemplateRows).toBe('0fr')
55
+ expect(panel.firstElementChild!.hasAttribute('inert')).toBe(true)
56
+ expect(panel.textContent).toContain(kubectl)
57
+
58
+ const nextStep = [...element.querySelectorAll('section')].find((section) => section.querySelector('h4')?.textContent === 'Next step')
59
+ expect(nextStep?.textContent).toContain('restore it')
60
+ expect(nextStep?.textContent).not.toContain('kubectl')
61
+ expect(nextStep?.textContent).not.toContain('patch_resource')
62
+ expect(nextStep?.contains(toggle!)).toBe(false)
63
+
64
+ await act(async () => {
65
+ toggle!.click()
66
+ })
67
+ expect(toggle!.getAttribute('aria-expanded')).toBe('true')
68
+ expect(panel.style.gridTemplateRows).toBe('1fr')
69
+ expect(panel.firstElementChild!.hasAttribute('inert')).toBe(false)
70
+
71
+ await act(async () => root.unmount())
72
+ })
73
+
74
+ it('renders no cleanup section when the issue has none', async () => {
75
+ const element = document.createElement('div')
76
+ const root = createRoot(element)
77
+ await act(async () => {
78
+ root.render(<IssueRow issue={{ ...issue, conditional_cleanup: undefined }} open onToggle={() => {}} />)
79
+ })
80
+ expect(element.textContent).not.toContain('Conditional cleanup')
81
+ await act(async () => root.unmount())
82
+ })
@@ -1,6 +1,7 @@
1
+ import { summarizeSchedulerMessage } from '../resources/resource-utils'
1
2
  import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
- import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
- import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
3
+ import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
4
+ import { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
5
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
5
6
  import { Tooltip } from '../ui/Tooltip';
6
7
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -13,7 +14,7 @@ import {
13
14
  ISSUE_SEVERITY_RAIL_CLASS,
14
15
  ISSUE_SEVERITY_SOLID_CLASS,
15
16
  ISSUE_SEVERITY_TEXT_CLASS,
16
- categoryLabel,
17
+ issueTitle,
17
18
  groupBadgeClass,
18
19
  groupLabel,
19
20
  } from './severity';
@@ -24,6 +25,7 @@ import {
24
25
  subjectRef,
25
26
  type Issue,
26
27
  type IssueAffected,
28
+ type IssueConditionalCleanup,
27
29
  type IssueResourceRef,
28
30
  type IssueSeverity,
29
31
  } from './types';
@@ -168,6 +170,7 @@ export interface IssueRowProps {
168
170
  resourceHref?: (ref: IssueResourceRef) => string;
169
171
  onResourceClick?: (ref: IssueResourceRef) => void;
170
172
  as?: 'li' | 'div';
173
+ compact?: boolean;
171
174
  className?: string;
172
175
  dimmed?: boolean;
173
176
  /** Suppress the "Subject" deep-link in the expanded body — set by hosts that
@@ -195,6 +198,7 @@ export function IssueRow({
195
198
  resourceHref,
196
199
  onResourceClick,
197
200
  as = 'li',
201
+ compact = false,
198
202
  className,
199
203
  dimmed,
200
204
  hideSubject,
@@ -208,6 +212,7 @@ export function IssueRow({
208
212
  const cluster = clusterLabel?.(issue);
209
213
  const affected = affectedSummary(issue.affected);
210
214
  const { headline } = issueMessageParts(issue);
215
+ const schedulingCause = compact && issue.reason === 'Unschedulable' ? summarizeSchedulerMessage(issue.cause || issue.message || headline || '', { plain: true }) : undefined;
211
216
  const { panelId, buttonProps } = useDisclosure(open);
212
217
  const Container = as;
213
218
  const severity = normalizeIssueSeverity(issue.severity);
@@ -289,7 +294,7 @@ export function IssueRow({
289
294
 
290
295
  <div className="flex min-w-0 flex-1 flex-col gap-1">
291
296
  <div className="flex min-w-0 items-baseline gap-2">
292
- <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
297
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
293
298
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
294
299
  {renderBadges?.(slotCtx)}
295
300
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -297,7 +302,7 @@ export function IssueRow({
297
302
  cause lives in the WHAT'S WRONG section below, so it fades out
298
303
  here (stays mounted — it's the flex-1 filler, so unmounting
299
304
  wouldn't reflow anything, but fading avoids the pop). */}
300
- {issue.reason ? (
305
+ {issue.reason && !schedulingCause ? (
301
306
  <span
302
307
  aria-hidden={open || undefined}
303
308
  className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
@@ -307,6 +312,7 @@ export function IssueRow({
307
312
  </span>
308
313
  ) : null}
309
314
  </div>
315
+ {schedulingCause && <div className="break-words text-xs text-theme-text-secondary">{schedulingCause}</div>}
310
316
  <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
311
317
  <span className={KIND_CHIP_CLASS}>{issue.kind}</span>
312
318
  <span className="min-w-0 truncate font-medium text-theme-text-secondary">
@@ -544,6 +550,8 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
544
550
  </CardSection>
545
551
  ) : null}
546
552
 
553
+ {issue.conditional_cleanup ? <ConditionalCleanup cleanup={issue.conditional_cleanup} /> : null}
554
+
547
555
  {rawError ? (
548
556
  <CardSection icon={Terminal} label="Raw error">
549
557
  <TerminalBlock>{rawError}</TerminalBlock>
@@ -553,6 +561,33 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
553
561
  );
554
562
  }
555
563
 
564
+ // A step that is right only under its condition. It stays closed behind the
565
+ // condition so the row's next step remains the default path, and it is not
566
+ // toned as a fix.
567
+ function ConditionalCleanup({ cleanup }: { cleanup: IssueConditionalCleanup }) {
568
+ return (
569
+ <CardSection icon={Eraser} label="Conditional cleanup">
570
+ <Disclosure
571
+ summary={<span className="text-sm leading-relaxed">{cleanup.condition}</span>}
572
+ summaryClassName="items-baseline text-theme-text-primary hover:text-theme-text-secondary"
573
+ >
574
+ <div className="flex flex-col gap-2 pt-2">
575
+ {cleanup.caution ? <p className="text-xs leading-relaxed text-theme-text-secondary">{cleanup.caution}</p> : null}
576
+ {cleanup.removals.map((removal) => (
577
+ <div key={removal.finalizer} className="flex flex-col gap-1.5">
578
+ <p className="text-xs text-theme-text-tertiary">
579
+ Finalizer <code className="inline-code">{removal.finalizer}</code>
580
+ </p>
581
+ <TerminalBlock label="patch_resource · dry run">{JSON.stringify(removal.patch_resource)}</TerminalBlock>
582
+ <TerminalBlock label="kubectl · server dry run">{removal.kubectl}</TerminalBlock>
583
+ </div>
584
+ ))}
585
+ </div>
586
+ </Disclosure>
587
+ </CardSection>
588
+ );
589
+ }
590
+
556
591
  function changeContextText(change: NonNullable<Issue['change_context']>): string {
557
592
  const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
558
593
  return parts.join(' · ');