@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
@@ -40,6 +40,7 @@ import { isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/
40
40
  import { toggleSet } from './GitOpsGraphFilterRail'
41
41
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
42
  import { parseContextName } from '../../utils/context-name'
43
+ import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
43
44
 
44
45
  // =============================================================================
45
46
  // GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
@@ -1373,8 +1374,8 @@ function GitOpsTable({
1373
1374
  )}
1374
1375
  <TableCell>
1375
1376
  {row.terminating
1376
- ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
1377
- : formatRelativeAge(row.lastSync || row.createdAt)}
1377
+ ? <span className="text-orange-400/80">{pendingFor(row.terminationStartedAt)}</span>
1378
+ : row.lastSync ? formatRelativeAgeTime(row.lastSync, '') : row.createdAt ? `Created ${formatRelativeAgeTime(row.createdAt, '')}` : ''}
1378
1379
  </TableCell>
1379
1380
  {showActions && onRowAction && (
1380
1381
  <td
@@ -1603,9 +1604,9 @@ function GitOpsTile({
1603
1604
  <div className="truncate font-mono text-[11px] text-theme-text-tertiary">{shortRevision(revision)}</div>
1604
1605
  )}
1605
1606
  {row.terminating ? (
1606
- <div className="font-medium text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</div>
1607
+ <div className="font-medium text-orange-400/80">{pendingFor(row.terminationStartedAt)}</div>
1607
1608
  ) : (
1608
- lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAge(lastSyncRaw)}</div>
1609
+ lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAgeTime(lastSyncRaw, '')}</div>
1609
1610
  )}
1610
1611
  </div>
1611
1612
  {(dest || ns || row.project) && (
@@ -1926,26 +1927,10 @@ function compactClusterURL(dest: string): string {
1926
1927
  .replace(/^kubernetes\.default\.svc(:\d+)?\/?$/, 'in-cluster')
1927
1928
  }
1928
1929
 
1929
- // formatRelativeAge — inline relative-time formatter. Returns "" for unparseable
1930
- // inputs so callers can treat empty as "no timestamp" and skip the time
1931
- // cell gracefully.
1932
- function formatRelativeAge(rfc3339: string): string {
1933
- if (!rfc3339) return ''
1934
- const time = Date.parse(rfc3339)
1935
- if (!Number.isFinite(time)) return ''
1936
- const diffMs = Date.now() - time
1937
- const s = Math.max(0, Math.floor(diffMs / 1000))
1938
- if (s < 60) return `${s}s ago`
1939
- const m = Math.floor(s / 60)
1940
- if (m < 60) return `${m}m ago`
1941
- const h = Math.floor(m / 60)
1942
- if (h < 24) return `${h}h ago`
1943
- const d = Math.floor(h / 24)
1944
- if (d < 30) return `${d}d ago`
1945
- const mo = Math.floor(d / 30)
1946
- if (mo < 12) return `${mo}mo ago`
1947
- const y = Math.floor(d / 365)
1948
- return `${y}y ago`
1930
+ // How long deletion has been pending, not when it started.
1931
+ function pendingFor(startedAt?: string): string {
1932
+ const age = formatCompactAge(startedAt)
1933
+ return age ? `Pending for ${age}` : 'Pending'
1949
1934
  }
1950
1935
 
1951
1936
  // =============================================================================
@@ -82,7 +82,7 @@ export function describeGitOpsTerminating(summary?: {
82
82
  finalizers?: string[]
83
83
  }): { chipTooltip: string; actionDisabledTooltip: string } {
84
84
  const ageText = formatCompactAge(summary?.terminationStartedAt)
85
- const ageSuffix = ageText ? ` ${ageText} ago` : ''
85
+ const ageSuffix = ageText ? ` for ${ageText}` : ''
86
86
  const finalizers = summary?.finalizers ?? []
87
87
  const finSuffix = finalizers.length > 0 ? ` Finalizers: ${finalizers.join(', ')}.` : ''
88
88
  const ageInline = ageText ? ` (${ageText})` : ''
@@ -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
@@ -72,7 +73,7 @@ export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitO
72
73
  const revision = summary.lastRevision || summary.targetRevision || ''
73
74
  const shortRev = shortRevisionForCommit(revision)
74
75
  const commitUrl = revision ? commitURLForRepo(summary.source, revision) : null
75
- const reconcileAge = formatRelative(summary.lastReconcile)
76
+ const reconcileAge = formatRelativeAgeTime(summary.lastReconcile)
76
77
  const healthSummary = buildHealthSummary(insight.changes ?? [])
77
78
  // STUCK belongs with the operation chip — it's a property of the same
78
79
  // state ("failed AND won't self-recover"). Co-locating FAILED + STUCK ·
@@ -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
@@ -257,7 +261,7 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
257
261
  // disclosure — still available for forensics but not noise during deletion.
258
262
  function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsight['summary']> }) {
259
263
  const [showHistorical, setShowHistorical] = useState(false)
260
- const pending = formatRelative(summary.terminationStartedAt) || 'recently'
264
+ const pending = formatRelativeAgeTime(summary.terminationStartedAt) || 'recently'
261
265
  const finalizers = summary.finalizers ?? []
262
266
  return (
263
267
  <div className="border-b border-orange-500/20 bg-orange-500/[0.04] px-4 py-2">
@@ -282,7 +286,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
282
286
  {(summary.lastRevision || summary.targetRevision) && (
283
287
  <MetaFact label="Revision" value={summary.lastRevision || summary.targetRevision || '-'} mono />
284
288
  )}
285
- {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelative(summary.lastReconcile)} />}
289
+ {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelativeAgeTime(summary.lastReconcile)} />}
286
290
  {summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
287
291
  </div>
288
292
  </Collapse>
@@ -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
@@ -1628,7 +1581,7 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
1628
1581
  {e.count && e.count > 1 && (
1629
1582
  <span className="text-[10px] text-theme-text-tertiary">×{e.count}</span>
1630
1583
  )}
1631
- <span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeTime(e.lastTimestamp)}</span>
1584
+ <span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeAgeTime(e.lastTimestamp, '')}</span>
1632
1585
  </div>
1633
1586
  <p className="mt-0.5 text-theme-text-secondary">{e.message}</p>
1634
1587
  </div>
@@ -1639,10 +1592,6 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
1639
1592
  )
1640
1593
  }
1641
1594
 
1642
- function formatRelativeTime(value: string): string {
1643
- return formatRelativeAgeTime(value, '')
1644
- }
1645
-
1646
1595
  interface GitOpsActivityInsightViewProps {
1647
1596
  insight?: GitOpsInsight | null
1648
1597
  error?: Error | null
@@ -1729,7 +1678,7 @@ function HistoryRows({
1729
1678
  <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
1730
1679
  <span className="font-mono text-xs text-theme-text-primary">{item.revision || item.phase || '-'}</span>
1731
1680
  <PhaseChip phase={item.phase} message={item.message} />
1732
- <span className="text-[11px] text-theme-text-tertiary">{formatRelative(item.deployedAt)}</span>
1681
+ <span className="text-[11px] text-theme-text-tertiary">{formatRelativeAgeTime(item.deployedAt)}</span>
1733
1682
  {item.initiatedBy && (
1734
1683
  <span className="text-[11px] text-theme-text-tertiary">by {item.initiatedBy}</span>
1735
1684
  )}
@@ -1831,6 +1780,3 @@ function InsightErrorState({ error }: { error: Error }) {
1831
1780
  )
1832
1781
  }
1833
1782
 
1834
- function formatRelative(value?: string) {
1835
- return formatRelativeAgeTime(value)
1836
- }
@@ -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
+ })